mirror of
https://github.com/aleksilassila/reiverr.git
synced 2026-04-17 21:53:12 +02:00
feat: library page sections, sorting, view options
fix: tmdb library items not being cached
This commit is contained in:
@@ -1,48 +0,0 @@
|
||||
import { Controller } from '@nestjs/common';
|
||||
import { MetadataService } from './metadata.service';
|
||||
|
||||
// @UseGuards(OptionalAccessControl)
|
||||
@Controller()
|
||||
export class MetadataController {
|
||||
constructor(private mediaService: MetadataService) {}
|
||||
|
||||
// @ApiTags('movies')
|
||||
// @Get('movies/tmdb/:tmdbId')
|
||||
// @ApiOkResponse({ type: MovieDto })
|
||||
// async getMovieByTmdbId(
|
||||
// @GetAuthUser() user: User,
|
||||
// @Param('tmdbId') tmdbId: string,
|
||||
// ): Promise<MovieDto> {
|
||||
// // let userData: MovieDto['userData'];
|
||||
|
||||
// // if (user) {
|
||||
// // const libraryItem = await this.libraryService.findByTmdbId(
|
||||
// // user.id,
|
||||
// // tmdbId,
|
||||
// // );
|
||||
|
||||
// // userData = {
|
||||
// // inLibrary: !!libraryItem,
|
||||
// // };
|
||||
// // }
|
||||
|
||||
// return this.mediaService.getMovieByTmdbId(tmdbId);
|
||||
// }
|
||||
|
||||
// @ApiTags('movies')
|
||||
// @Get('movies/library')
|
||||
// @PaginatedApiOkResponse(MovieDto)
|
||||
// @UseGuards(UserAccessControl)
|
||||
// async getLibraryMovies(
|
||||
// @GetAuthUser() user: User,
|
||||
// @GetPaginationParams() pagination: PaginationParamsDto,
|
||||
// ): Promise<MovieDto[]> {
|
||||
// const libraryItems = await this.libraryService.getLibraryItems(user.id);
|
||||
|
||||
// const items = await Promise.all(
|
||||
// libraryItems.map((item) => this.mediaService.getm),
|
||||
// );
|
||||
|
||||
// return {};
|
||||
// }
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
import { TmdbMovieFull, TmdbSeriesFull } from './tmdb/tmdb.dto';
|
||||
import { TMDB_CACHE_TTL } from 'src/consts';
|
||||
|
||||
@Entity()
|
||||
export class Movie {
|
||||
@@ -21,6 +22,7 @@ export class Movie {
|
||||
@Column('json')
|
||||
tmdbMovie: TmdbMovieFull;
|
||||
|
||||
@ApiProperty({ type: 'string' })
|
||||
@UpdateDateColumn()
|
||||
updatedAt: Date;
|
||||
}
|
||||
@@ -39,6 +41,33 @@ export class Series {
|
||||
@Column('json')
|
||||
tmdbSeries: TmdbSeriesFull;
|
||||
|
||||
@ApiProperty({ type: 'string' })
|
||||
@UpdateDateColumn()
|
||||
updatedAt: Date;
|
||||
|
||||
isStale() {
|
||||
console.log(this.updatedAt);
|
||||
if (!this.updatedAt) return true;
|
||||
|
||||
console.log(
|
||||
new Date().getTime() - this.updatedAt.getTime(),
|
||||
TMDB_CACHE_TTL,
|
||||
);
|
||||
if (new Date().getTime() - this.updatedAt.getTime() > TMDB_CACHE_TTL)
|
||||
return true;
|
||||
|
||||
console.log(
|
||||
'Checking if series is stale',
|
||||
this.tmdbSeries.name,
|
||||
this.tmdbSeries.next_episode_to_air?.air_date,
|
||||
);
|
||||
if (
|
||||
this.tmdbSeries?.next_episode_to_air?.air_date &&
|
||||
new Date() > new Date(this.tmdbSeries.next_episode_to_air.air_date)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { DatabaseModule } from 'src/database/database.module';
|
||||
import { MetadataController } from './metadata.controller';
|
||||
import { metadataProviders } from './metadata.providers';
|
||||
import { MetadataService } from './metadata.service';
|
||||
import { TmdbModule } from './tmdb/tmdb.module';
|
||||
|
||||
@Module({
|
||||
imports: [TmdbModule],
|
||||
controllers: [MetadataController],
|
||||
providers: [...metadataProviders, MetadataService],
|
||||
exports: [MetadataService],
|
||||
})
|
||||
|
||||
@@ -5,6 +5,7 @@ import { MOVIE_REPOSITORY, SERIES_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';
|
||||
import { TmdbService } from './tmdb/tmdb.service';
|
||||
|
||||
@Injectable()
|
||||
export class MetadataService {
|
||||
@@ -17,8 +18,15 @@ export class MetadataService {
|
||||
|
||||
@Inject(SERIES_REPOSITORY)
|
||||
private seriesRepository: Repository<Series>,
|
||||
|
||||
private readonly tmdbService: TmdbService,
|
||||
) {}
|
||||
|
||||
async clearMetadataCache() {
|
||||
await this.movieRepository.clear();
|
||||
await this.seriesRepository.clear();
|
||||
}
|
||||
|
||||
async getMovieByTmdbId(tmdbId: string): Promise<Movie | undefined> {
|
||||
let movie = await this.movieRepository.findOne({ where: { tmdbId } });
|
||||
|
||||
@@ -39,6 +47,8 @@ export class MetadataService {
|
||||
movie.tmdbMovie = tmdbMovie;
|
||||
}
|
||||
|
||||
await this.movieRepository.save(movie);
|
||||
|
||||
return movie;
|
||||
}
|
||||
|
||||
@@ -54,20 +64,14 @@ export class MetadataService {
|
||||
series.tmdbId = tmdbId;
|
||||
}
|
||||
|
||||
if (
|
||||
!series.updatedAt ||
|
||||
new Date().getTime() - series.updatedAt.getTime() > TMDB_CACHE_TTL
|
||||
) {
|
||||
const tmdbSeries = await this.tmdbApi.v3
|
||||
.tvSeriesDetails(Number(tmdbId))
|
||||
.then((r) => r.data)
|
||||
.catch((e) => {
|
||||
console.error('could not get metadata for series', tmdbId, e);
|
||||
return e;
|
||||
});
|
||||
series.tmdbSeries = tmdbSeries;
|
||||
if (series.isStale()) {
|
||||
console.log('getting metadata for series', tmdbId);
|
||||
const tmdbSeries = await this.tmdbService.getFullSeries(Number(tmdbId));
|
||||
if (tmdbSeries) series.tmdbSeries = tmdbSeries;
|
||||
}
|
||||
|
||||
await this.seriesRepository.save(series);
|
||||
|
||||
return series;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,27 +1,20 @@
|
||||
import {
|
||||
Cache,
|
||||
CACHE_MANAGER
|
||||
} from '@nestjs/cache-manager';
|
||||
import {
|
||||
All,
|
||||
Controller,
|
||||
Get,
|
||||
Inject,
|
||||
Next,
|
||||
Param,
|
||||
Req,
|
||||
Res,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
UseGuards
|
||||
} from '@nestjs/common';
|
||||
import { Request, Response } from 'express';
|
||||
import { GetAuthUser, UserAccessControl } from 'src/auth/auth.guard';
|
||||
import { TMDB_CACHE_TTL, TMDB_API_KEY } from 'src/consts';
|
||||
import { TMDB_API_KEY, TMDB_CACHE_TTL } from 'src/consts';
|
||||
import { User } from 'src/users/user.entity';
|
||||
import { Readable } from 'stream';
|
||||
import { NextFunction, Request, Response } from 'express';
|
||||
import { MetadataService } from '../metadata.service';
|
||||
import {
|
||||
Cache,
|
||||
CACHE_MANAGER,
|
||||
CacheInterceptor,
|
||||
CacheTTL,
|
||||
} from '@nestjs/cache-manager';
|
||||
|
||||
@UseGuards(UserAccessControl)
|
||||
@Controller('tmdb')
|
||||
|
||||
@@ -4,14 +4,15 @@ import { TMDB_CACHE_TTL } from 'src/consts';
|
||||
import { UsersModule } from 'src/users/users.module';
|
||||
import { TmdbController } from './tmdb.controller';
|
||||
import { tmdbProviders } from './tmdb.providers';
|
||||
import { TmdbService } from './tmdb.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
UsersModule,
|
||||
CacheModule.register({ ttl: TMDB_CACHE_TTL, max: 10_000 }),
|
||||
],
|
||||
providers: [...tmdbProviders],
|
||||
exports: [...tmdbProviders],
|
||||
providers: [...tmdbProviders, TmdbService],
|
||||
exports: [...tmdbProviders, TmdbService],
|
||||
controllers: [TmdbController],
|
||||
})
|
||||
export class TmdbModule {}
|
||||
|
||||
23
backend/src/metadata/tmdb/tmdb.service.ts
Normal file
23
backend/src/metadata/tmdb/tmdb.service.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { TmdbSeriesFull } from './tmdb.dto';
|
||||
import { TMDB_API, TmdbApi } from './tmdb.providers';
|
||||
|
||||
@Injectable()
|
||||
export class TmdbService {
|
||||
constructor(
|
||||
@Inject(TMDB_API)
|
||||
private tmdbApi: TmdbApi,
|
||||
) {}
|
||||
|
||||
async getFullSeries(tmdbId: number): Promise<TmdbSeriesFull> {
|
||||
const tmdbSeries = await this.tmdbApi.v3
|
||||
.tvSeriesDetails(Number(tmdbId))
|
||||
.then((r) => r.data);
|
||||
// .catch((e) => {
|
||||
// console.error('could not get metadata for series', tmdbId, e);
|
||||
// return e;
|
||||
// });
|
||||
|
||||
return tmdbSeries;
|
||||
}
|
||||
}
|
||||
@@ -37,7 +37,7 @@ export class LibraryController {
|
||||
): Promise<PaginatedResponseDto<LibraryItemDto>> {
|
||||
// const user = await this.userService.findOne(userId);
|
||||
|
||||
const items = await this.libraryService.getLibraryItemsWithMetadata(
|
||||
const items = await this.libraryService.getLibraryItemDtos(
|
||||
userId,
|
||||
pagination,
|
||||
);
|
||||
|
||||
@@ -1,22 +1,20 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { ApiProperty, PickType } 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';
|
||||
import { Series } from 'src/metadata/metadata.entity';
|
||||
import { LibraryItem } from './library.entity';
|
||||
|
||||
export class LibraryItemDto {
|
||||
@ApiProperty()
|
||||
tmdbId: string;
|
||||
|
||||
@ApiProperty({ enum: MediaType })
|
||||
mediaType: MediaType;
|
||||
|
||||
@ApiProperty({ type: [PlayStateDto], required: false })
|
||||
playStates?: PlayStateDto[];
|
||||
|
||||
export class LibraryItemDto extends PickType(LibraryItem, [
|
||||
'tmdbId',
|
||||
'mediaType',
|
||||
'playStates',
|
||||
'createdAt',
|
||||
]) {
|
||||
@ApiProperty({ type: MovieDto, required: false })
|
||||
movieMetadata?: MovieDto;
|
||||
|
||||
@ApiProperty({ type: Series, required: false })
|
||||
seriesMetadata?: Series;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
watched?: boolean;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { MediaType } from 'src/common/common.dto';
|
||||
import { User } from 'src/users/user.entity';
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
@@ -10,8 +11,10 @@ import {
|
||||
PrimaryColumn,
|
||||
PrimaryGeneratedColumn,
|
||||
Unique,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
import { PlayState } from '../play-state/play-state.entity';
|
||||
import { PlayStateDto } from '../play-state/play-state.dto';
|
||||
|
||||
@Entity()
|
||||
@Unique(['tmdbId', 'userId'])
|
||||
@@ -20,7 +23,7 @@ export class LibraryItem {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@ApiProperty({ required: true, type: 'number' })
|
||||
@ApiProperty({ required: true })
|
||||
@Column({ unique: true })
|
||||
tmdbId: string;
|
||||
|
||||
@@ -37,6 +40,16 @@ export class LibraryItem {
|
||||
@JoinColumn({ name: 'userId' })
|
||||
user: User;
|
||||
|
||||
@ApiProperty({ type: [PlayStateDto], required: false })
|
||||
@OneToMany(() => PlayState, (playState) => playState.libraryItem)
|
||||
playStates?: PlayState[];
|
||||
|
||||
/** @deprecated */
|
||||
@ApiProperty({ type: 'string' })
|
||||
@UpdateDateColumn({ default: () => 'CURRENT_TIMESTAMP' })
|
||||
updatedAt: Date;
|
||||
|
||||
@ApiProperty({ type: 'string' })
|
||||
@CreateDateColumn({ default: () => 'CURRENT_TIMESTAMP' })
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ export class LibraryService {
|
||||
private readonly metadataService: MetadataService,
|
||||
) {}
|
||||
|
||||
async getLibraryItemsWithMetadata(
|
||||
async getLibraryItemDtos(
|
||||
userId: string,
|
||||
pagination: PaginationParamsDto,
|
||||
): Promise<LibraryItemDto[]> {
|
||||
@@ -22,16 +22,37 @@ export class LibraryService {
|
||||
|
||||
return Promise.all(
|
||||
items.map(async (item) => {
|
||||
const seriesMetadata =
|
||||
item.mediaType === MediaType.Series
|
||||
? await this.metadataService.getSeriesByTmdbId(item.tmdbId)
|
||||
: undefined;
|
||||
let watched = false;
|
||||
|
||||
if (item.mediaType === MediaType.Movie) {
|
||||
watched = item.playStates?.some((state) => state.watched) ?? false;
|
||||
} else if (
|
||||
item.mediaType === MediaType.Series &&
|
||||
seriesMetadata.tmdbSeries?.last_episode_to_air
|
||||
) {
|
||||
const { season_number: season, episode_number: episode } =
|
||||
seriesMetadata.tmdbSeries.last_episode_to_air;
|
||||
watched =
|
||||
item.playStates?.some(
|
||||
(state) =>
|
||||
state.season === season &&
|
||||
state.episode === episode &&
|
||||
state.watched,
|
||||
) ?? false;
|
||||
}
|
||||
|
||||
return {
|
||||
...item,
|
||||
watched,
|
||||
movieMetadata:
|
||||
item.mediaType === MediaType.Movie
|
||||
? await this.metadataService.getMovieByTmdbId(item.tmdbId)
|
||||
: undefined,
|
||||
seriesMetadata:
|
||||
item.mediaType === MediaType.Series
|
||||
? await this.metadataService.getSeriesByTmdbId(item.tmdbId)
|
||||
: undefined,
|
||||
seriesMetadata,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -81,12 +81,37 @@ export interface PlayState {
|
||||
lastPlayedAt: string;
|
||||
}
|
||||
|
||||
export interface LibraryItem {
|
||||
id?: string;
|
||||
export interface PlayStateDto {
|
||||
id: string;
|
||||
tmdbId: number;
|
||||
mediaType: 'Movie' | 'Series' | 'Episode';
|
||||
userId: string;
|
||||
season?: number;
|
||||
episode?: number;
|
||||
/**
|
||||
* Whether the user has watched this media
|
||||
* @default false
|
||||
*/
|
||||
watched: boolean;
|
||||
/**
|
||||
* A number between 0 and 1
|
||||
* @default false
|
||||
* @example 0.5
|
||||
*/
|
||||
progress: number;
|
||||
/** Last time the user played this media */
|
||||
lastPlayedAt: string;
|
||||
}
|
||||
|
||||
export interface LibraryItem {
|
||||
id?: string;
|
||||
tmdbId: string;
|
||||
mediaType: 'Movie' | 'Series' | 'Episode';
|
||||
userId: string;
|
||||
user?: string;
|
||||
playStates?: PlayStateDto[];
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface UserDto {
|
||||
@@ -439,28 +464,6 @@ export interface UpdateOrCreateMediaSourceDto {
|
||||
priority?: number;
|
||||
}
|
||||
|
||||
export interface PlayStateDto {
|
||||
id: string;
|
||||
tmdbId: number;
|
||||
mediaType: 'Movie' | 'Series' | 'Episode';
|
||||
userId: string;
|
||||
season?: number;
|
||||
episode?: number;
|
||||
/**
|
||||
* Whether the user has watched this media
|
||||
* @default false
|
||||
*/
|
||||
watched: boolean;
|
||||
/**
|
||||
* A number between 0 and 1
|
||||
* @default false
|
||||
* @example 0.5
|
||||
*/
|
||||
progress: number;
|
||||
/** Last time the user played this media */
|
||||
lastPlayedAt: string;
|
||||
}
|
||||
|
||||
export interface MovieUserDataDto {
|
||||
tmdbId: string;
|
||||
inLibrary: boolean;
|
||||
@@ -498,20 +501,24 @@ export interface MovieDto {
|
||||
id?: string;
|
||||
tmdbId: string;
|
||||
tmdbMovie?: object;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface Series {
|
||||
id?: string;
|
||||
tmdbId: string;
|
||||
tmdbSeries?: object;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface LibraryItemDto {
|
||||
tmdbId: string;
|
||||
mediaType: 'Movie' | 'Series' | 'Episode';
|
||||
playStates?: PlayStateDto[];
|
||||
createdAt: string;
|
||||
movieMetadata?: MovieDto;
|
||||
seriesMetadata?: Series;
|
||||
watched?: boolean;
|
||||
}
|
||||
|
||||
export interface SuccessResponseDto {
|
||||
|
||||
@@ -52,6 +52,7 @@
|
||||
{/if}
|
||||
<AnimatedSelection hasFocus={$hasFocus}>
|
||||
<Container
|
||||
{...$$restProps}
|
||||
{disabled}
|
||||
on:clickOrSelect={() => {
|
||||
if (tmdbId || tvdbId) navigate(`/${type}/${tmdbId || tvdbId}`);
|
||||
|
||||
@@ -48,6 +48,7 @@
|
||||
/>
|
||||
|
||||
<Container
|
||||
{...$$restProps}
|
||||
direction="grid"
|
||||
gridCols={cols}
|
||||
class={classNames(
|
||||
|
||||
66
src/lib/components/SelectButtonGroup.svelte
Normal file
66
src/lib/components/SelectButtonGroup.svelte
Normal file
@@ -0,0 +1,66 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import Container from './Container.svelte';
|
||||
import classNames from 'classnames';
|
||||
import { Check } from 'radix-icons-svelte';
|
||||
|
||||
type Option = {
|
||||
label: string;
|
||||
value?: string;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export let options: Option[] = [];
|
||||
export let selected: string | undefined = undefined;
|
||||
export let name: string | undefined = undefined;
|
||||
export let style: 'primary' | 'secondary' = 'secondary';
|
||||
|
||||
const dispatch = createEventDispatcher<{
|
||||
select: string;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<div>
|
||||
{#if name}
|
||||
<h2 class="font-semibold text-sm text-secondary-400 mb-2">{name}</h2>
|
||||
{/if}
|
||||
<Container
|
||||
direction="vertical"
|
||||
class={classNames(
|
||||
'rounded-xl',
|
||||
{
|
||||
'bg-primary-900': style === 'secondary',
|
||||
'bg-secondary-800': style === 'primary'
|
||||
},
|
||||
$$restProps.class
|
||||
)}
|
||||
>
|
||||
{#each options as option, index}
|
||||
{@const first = index === 0}
|
||||
{@const last = index === options.length - 1}
|
||||
{#if !first}
|
||||
<div class="w-0.5 h-full bg-secondary-700" />
|
||||
{/if}
|
||||
<Container
|
||||
on:clickOrSelect={() => dispatch('select', option.value ?? option.label)}
|
||||
let:hasFocus
|
||||
>
|
||||
<div
|
||||
class={classNames('h-12 px-6 py-3 border-2 flex items-center justify-between', {
|
||||
'cursor-pointer hover:border-primary-500': !option.disabled,
|
||||
'cursor-not-allowed pointer-events-none opacity-40': option.disabled,
|
||||
'border-primary-500': hasFocus,
|
||||
'border-transparent': !hasFocus,
|
||||
'rounded-t-xl': first,
|
||||
'rounded-b-xl': last
|
||||
})}
|
||||
>
|
||||
<span class="font-medium">{option.label}</span>
|
||||
{#if selected === option.value}
|
||||
<Check size={24} />
|
||||
{/if}
|
||||
</div>
|
||||
</Container>
|
||||
{/each}
|
||||
</Container>
|
||||
</div>
|
||||
@@ -1,6 +1,6 @@
|
||||
import { type ComponentType } from 'svelte';
|
||||
import { derived, get, writable } from 'svelte/store';
|
||||
import LibraryPage from '../../pages/LibraryPage.svelte';
|
||||
import LibraryPage from '../../pages/LibraryPage/LibraryPage.svelte';
|
||||
import ManagePage from '../../pages/ManagePage/ManagePage.svelte';
|
||||
import MoviesHomePage from '../../pages/MoviesHomePage.svelte';
|
||||
import PageNotFound from '../../pages/PageNotFound.svelte';
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
}>();
|
||||
|
||||
export let checked: boolean;
|
||||
export let label: string | undefined = undefined;
|
||||
let input: HTMLInputElement;
|
||||
|
||||
const handleChange = (e: Event) => {
|
||||
@@ -17,22 +18,32 @@
|
||||
};
|
||||
</script>
|
||||
|
||||
<Container
|
||||
class="relative inline-flex items-center cursor-pointer w-min h-min"
|
||||
on:enter={(e) => {
|
||||
e.detail.options.setFocusedElement = input;
|
||||
}}
|
||||
on:clickOrSelect={() => input?.click()}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked
|
||||
class="sr-only peer"
|
||||
bind:this={input}
|
||||
on:input={handleChange}
|
||||
/>
|
||||
<div
|
||||
class="w-[3.25rem] h-7 rounded-full bg-secondary-700 peer-checked:bg-primary-500 peer-selectable
|
||||
<div class="flex items-center justify-between">
|
||||
<slot>
|
||||
{#if label}
|
||||
<label class="mr-2">
|
||||
{label}
|
||||
</label>
|
||||
{/if}
|
||||
</slot>
|
||||
|
||||
<Container
|
||||
class="relative inline-flex items-center cursor-pointer w-min h-min"
|
||||
on:enter={(e) => {
|
||||
e.detail.options.setFocusedElement = input;
|
||||
}}
|
||||
on:clickOrSelect={() => input?.click()}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked
|
||||
class="sr-only peer"
|
||||
bind:this={input}
|
||||
on:input={handleChange}
|
||||
/>
|
||||
<div
|
||||
class="w-[3.25rem] h-7 rounded-full bg-secondary-700 peer-checked:bg-primary-500 peer-selectable
|
||||
after:bg-white after:border-gray-300 after:border after:rounded-full after:h-6 after:w-6 after:transition-all peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px]"
|
||||
/>
|
||||
</Container>
|
||||
/>
|
||||
</Container>
|
||||
</div>
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { libraryItemsDataStore } from '$lib/stores/data.store';
|
||||
import { onMount } from 'svelte';
|
||||
import { jellyfinApi } from '../apis/jellyfin/jellyfin-api';
|
||||
import { radarrApi } from '../apis/radarr/radarr-api';
|
||||
import { sonarrApi } from '../apis/sonarr/sonarr-api';
|
||||
import { tmdbApi, type TmdbMovie2, type TmdbSeries2 } from '../apis/tmdb/tmdb-api';
|
||||
import TmdbCard from '../components/Card/TmdbCard.svelte';
|
||||
import CardGrid from '../components/CardGrid.svelte';
|
||||
import DetachedPage from '../components/DetachedPage/DetachedPage.svelte';
|
||||
import { scrollIntoView } from '../selectable';
|
||||
|
||||
const { promise: libraryItems } = libraryItemsDataStore.subscribe();
|
||||
</script>
|
||||
|
||||
<DetachedPage class="py-16 space-y-8">
|
||||
{#await $libraryItems then items}
|
||||
<div class="px-32">
|
||||
<div class="mb-6">
|
||||
<div class="h3">My Library</div>
|
||||
</div>
|
||||
<CardGrid>
|
||||
{#each items as item}
|
||||
<TmdbCard
|
||||
item={item.metadata}
|
||||
progress={item.playStates?.[0]?.progress || 0}
|
||||
on:enter={scrollIntoView({ all: 64 })}
|
||||
size="dynamic"
|
||||
navigateWithType
|
||||
/>
|
||||
{/each}
|
||||
</CardGrid>
|
||||
</div>
|
||||
{/await}
|
||||
</DetachedPage>
|
||||
218
src/lib/pages/LibraryPage/LibraryPage.svelte
Normal file
218
src/lib/pages/LibraryPage/LibraryPage.svelte
Normal file
@@ -0,0 +1,218 @@
|
||||
<script lang="ts">
|
||||
import Button from '$lib/components/Button.svelte';
|
||||
import { createModal } from '$lib/components/Modal/modal.store';
|
||||
import { libraryItemsDataStore } from '$lib/stores/data.store';
|
||||
import { MixerHorizontal } from 'radix-icons-svelte';
|
||||
import { onDestroy } from 'svelte';
|
||||
import TmdbCard from '../../components/Card/TmdbCard.svelte';
|
||||
import CardGrid from '../../components/CardGrid.svelte';
|
||||
import DetachedPage from '../../components/DetachedPage/DetachedPage.svelte';
|
||||
import { scrollIntoView } from '../../selectable';
|
||||
import OptionsDialog from './OptionsDialog.LibraryPage.svelte';
|
||||
import { libraryViewSettings, type LibraryViewSettings } from '$lib/stores/localstorage.store';
|
||||
import type { LibraryItemDto } from '$lib/apis/reiverr/reiverr.openapi';
|
||||
import type { TmdbMovieFull2, TmdbSeriesFull2 } from '$lib/apis/tmdb/tmdb-api';
|
||||
import Container from '$lib/components/Container.svelte';
|
||||
import { derived } from 'svelte/store';
|
||||
import Carousel from '$lib/components/Carousel/Carousel.svelte';
|
||||
|
||||
type LibraryItemWithMetadata = LibraryItemDto & { metadata: TmdbSeriesFull2 | TmdbMovieFull2 };
|
||||
|
||||
const { isLoading, unsubscribe, ...libraryItems } = libraryItemsDataStore.subscribe();
|
||||
|
||||
const sortedLibraryItems = derived(
|
||||
[libraryItems, libraryViewSettings],
|
||||
([$libraryItems, $libraryViewSettings]) => sortItems($libraryItems, $libraryViewSettings)
|
||||
);
|
||||
|
||||
const libraryItemsCategorized = derived(
|
||||
[sortedLibraryItems, libraryViewSettings],
|
||||
([items, viewSettings]) => {
|
||||
let categorizedItems = {
|
||||
upcoming: [] as LibraryItemWithMetadata[],
|
||||
main: [] as LibraryItemWithMetadata[],
|
||||
watched: [] as LibraryItemWithMetadata[]
|
||||
};
|
||||
|
||||
if (!viewSettings.separateUpcoming && !viewSettings.separateWatched) {
|
||||
return { main: items || [], upcoming: [], watched: [] };
|
||||
}
|
||||
|
||||
for (const item of items) {
|
||||
const releaseDate = new Date(
|
||||
('release_date' in item.metadata && item.metadata.release_date) ||
|
||||
(item.watched &&
|
||||
'next_episode_to_air' in item.metadata &&
|
||||
(item.metadata.next_episode_to_air as any)?.air_date) ||
|
||||
0
|
||||
);
|
||||
const hasFutureReleases = item.watched
|
||||
? 'seasons' in item.metadata && item.metadata.seasons?.some((s) => s.air_date === null)
|
||||
: 'last_air_date' in item.metadata && item.metadata.last_air_date === null;
|
||||
|
||||
if (viewSettings.separateUpcoming && (releaseDate > new Date() || hasFutureReleases)) {
|
||||
categorizedItems.upcoming.push(item);
|
||||
} else if (viewSettings.separateWatched && item.watched) {
|
||||
categorizedItems.watched.push(item);
|
||||
} else {
|
||||
categorizedItems.main.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
categorizedItems.upcoming.sort((a, b) => {
|
||||
const aReleaseDate = new Date(
|
||||
('release_date' in a.metadata && a.metadata.release_date) ||
|
||||
('next_episode_to_air' in a.metadata &&
|
||||
(a.metadata.next_episode_to_air as any)?.air_date) ||
|
||||
new Date().getTime() + 1000 * 60 * 60 * 24 * 365 * 20
|
||||
);
|
||||
|
||||
const bReleaseDate = new Date(
|
||||
('release_date' in b.metadata && b.metadata.release_date) ||
|
||||
('next_episode_to_air' in b.metadata &&
|
||||
(b.metadata.next_episode_to_air as any)?.air_date) ||
|
||||
new Date().getTime() + 1000 * 60 * 60 * 24 * 365 * 20
|
||||
);
|
||||
|
||||
console.log(a.tmdbId, b.tmdbId, aReleaseDate, bReleaseDate);
|
||||
|
||||
return aReleaseDate > bReleaseDate ? 1 : -1;
|
||||
});
|
||||
|
||||
return categorizedItems;
|
||||
}
|
||||
);
|
||||
|
||||
function sortItems(
|
||||
items: LibraryItemWithMetadata[] | undefined,
|
||||
viewSettings: LibraryViewSettings
|
||||
) {
|
||||
return (
|
||||
items?.sort((a, b) => {
|
||||
const aCreatedAt = a.createdAt;
|
||||
const bCreatedAt = b.createdAt;
|
||||
|
||||
const aReleaseDate = ('release_date' in a.metadata && a.metadata.release_date) || '';
|
||||
const bReleaseDate = ('release_date' in b.metadata && b.metadata.release_date) || '';
|
||||
|
||||
const aFirstAirDate =
|
||||
('first_air_date' in a.metadata && a.metadata.first_air_date) || aReleaseDate;
|
||||
const bFirstAirDate =
|
||||
('first_air_date' in b.metadata && b.metadata.first_air_date) || bReleaseDate;
|
||||
|
||||
const aLastAirDate =
|
||||
('last_air_date' in a.metadata && a.metadata.last_air_date) ||
|
||||
aFirstAirDate ||
|
||||
aReleaseDate;
|
||||
const bLastAirDate =
|
||||
('last_air_date' in b.metadata && b.metadata.last_air_date) ||
|
||||
bFirstAirDate ||
|
||||
bReleaseDate;
|
||||
|
||||
const aTitle =
|
||||
('title' in a.metadata && a.metadata.title) ||
|
||||
('name' in a.metadata && a.metadata.name) ||
|
||||
'';
|
||||
|
||||
const bTitle =
|
||||
('title' in b.metadata && b.metadata.title) ||
|
||||
('name' in b.metadata && b.metadata.name) ||
|
||||
'';
|
||||
|
||||
const direction = viewSettings.sortDirection === 'asc' ? 1 : -1;
|
||||
if (viewSettings.sortBy === 'date-added') {
|
||||
return direction * aCreatedAt.localeCompare(bCreatedAt);
|
||||
} else if (viewSettings.sortBy === 'first-release-date') {
|
||||
return direction * aFirstAirDate.localeCompare(bFirstAirDate);
|
||||
} else if (viewSettings.sortBy === 'last-release-date') {
|
||||
return direction * aLastAirDate.localeCompare(bLastAirDate);
|
||||
} else if (viewSettings.sortBy === 'title') {
|
||||
return direction * aTitle.localeCompare(bTitle);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}) || []
|
||||
);
|
||||
}
|
||||
|
||||
$: viewSettingsKey = $libraryViewSettings && Symbol();
|
||||
|
||||
onDestroy(() => {
|
||||
unsubscribe();
|
||||
});
|
||||
</script>
|
||||
|
||||
<DetachedPage class="py-16 space-y-8 min-h-screen flex flex-col">
|
||||
{#if !$isLoading}
|
||||
<div class="h-full flex-1 flex flex-col">
|
||||
<div class="px-32 flex items-center justify-between">
|
||||
<div />
|
||||
<Button icon={MixerHorizontal} on:clickOrSelect={() => createModal(OptionsDialog, {})}>
|
||||
Options
|
||||
</Button>
|
||||
</div>
|
||||
{#if $libraryItemsCategorized.main.length + $libraryItemsCategorized.upcoming.length + $libraryItemsCategorized.watched.length}
|
||||
{#if $libraryItemsCategorized.upcoming.length}
|
||||
<div class="mb-6">
|
||||
<div class="px-32 flex items-center justify-between">
|
||||
<div class="h3">Upcoming</div>
|
||||
<div />
|
||||
</div>
|
||||
<Carousel scrollClass="px-32" on:enter={scrollIntoView({ bottom: 0 })}>
|
||||
{#key viewSettingsKey}
|
||||
{#each $libraryItemsCategorized.upcoming as item}
|
||||
<TmdbCard
|
||||
on:enter={scrollIntoView({ horizontal: 128 })}
|
||||
size="lg"
|
||||
item={item.metadata}
|
||||
/>
|
||||
{/each}
|
||||
{/key}
|
||||
</Carousel>
|
||||
</div>
|
||||
{/if}
|
||||
{#if $libraryItemsCategorized.main.length}
|
||||
<div class="mb-16">
|
||||
<div class="px-32 mb-8 h3">My Library</div>
|
||||
<CardGrid class="px-32" focusOnMount>
|
||||
{#key viewSettingsKey}
|
||||
{#each $libraryItemsCategorized.main as item, index (item.tmdbId)}
|
||||
<TmdbCard
|
||||
item={item.metadata}
|
||||
progress={item.playStates?.[0]?.progress || 0}
|
||||
on:enter={scrollIntoView(index === 0 ? { top: 128 + 64 } : { vertical: 128 })}
|
||||
size="dynamic"
|
||||
navigateWithType
|
||||
focusedChild={index === 0}
|
||||
/>
|
||||
{/each}
|
||||
{/key}
|
||||
</CardGrid>
|
||||
</div>
|
||||
{/if}
|
||||
{#if $libraryItemsCategorized.watched.length}
|
||||
<div>
|
||||
<div class="px-32 mb-8 h3">Watched</div>
|
||||
<CardGrid class="px-32">
|
||||
{#key viewSettingsKey}
|
||||
{#each $libraryItemsCategorized.watched as item (item.tmdbId)}
|
||||
<TmdbCard
|
||||
item={item.metadata}
|
||||
progress={item.playStates?.[0]?.progress || 0}
|
||||
on:enter={scrollIntoView({ vertical: 128 })}
|
||||
size="dynamic"
|
||||
navigateWithType
|
||||
/>
|
||||
{/each}
|
||||
{/key}
|
||||
</CardGrid>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<Container focusOnMount class="h-ghost m-auto">
|
||||
You haven't added anything to your library
|
||||
</Container>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</DetachedPage>
|
||||
70
src/lib/pages/LibraryPage/OptionsDialog.LibraryPage.svelte
Normal file
70
src/lib/pages/LibraryPage/OptionsDialog.LibraryPage.svelte
Normal file
@@ -0,0 +1,70 @@
|
||||
<script lang="ts">
|
||||
import Dialog from '$lib/components/Dialog/Dialog.svelte';
|
||||
import SelectButtonGroup from '$lib/components/SelectButtonGroup.svelte';
|
||||
import Toggle from '$lib/components/Toggle.svelte';
|
||||
import { libraryViewSettings } from '$lib/stores/localstorage.store';
|
||||
import { MixerHorizontal } from 'radix-icons-svelte';
|
||||
|
||||
const sortByOptions = [
|
||||
{ label: 'Date Added', value: 'date-added' },
|
||||
{ label: 'Last Release Date', value: 'last-release-date' },
|
||||
{ label: 'First Release Date', value: 'first-release-date' },
|
||||
{ label: 'Title', value: 'title' }
|
||||
];
|
||||
|
||||
const sortByDirectionOptions = [
|
||||
{ label: 'Ascending', value: 'asc' },
|
||||
{ label: 'Descending', value: 'desc' }
|
||||
];
|
||||
|
||||
function updateSortBy(sortBy: string) {
|
||||
libraryViewSettings.update((settings) => ({ ...settings, sortBy: sortBy as any }));
|
||||
}
|
||||
|
||||
function updateSortByDirection(direction: string) {
|
||||
libraryViewSettings.update((settings) => ({ ...settings, sortDirection: direction as any }));
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog let:close on:close class="space-y-8">
|
||||
<h1 class="h3 mb-4 flex items-center space-x-4">
|
||||
<span>View Options</span>
|
||||
<!-- <MixerHorizontal size={28} /> -->
|
||||
</h1>
|
||||
|
||||
<SelectButtonGroup
|
||||
name="Sort by"
|
||||
options={sortByOptions}
|
||||
selected={$libraryViewSettings.sortBy}
|
||||
on:select={({ detail: sortBy }) => updateSortBy(sortBy)}
|
||||
/>
|
||||
|
||||
<SelectButtonGroup
|
||||
name="Direction"
|
||||
options={sortByDirectionOptions}
|
||||
selected={$libraryViewSettings.sortDirection}
|
||||
on:select={({ detail: direction }) => updateSortByDirection(direction)}
|
||||
/>
|
||||
|
||||
<div class="space-y-4">
|
||||
<Toggle
|
||||
label="Include upcoming"
|
||||
checked={!$libraryViewSettings.separateUpcoming}
|
||||
on:change={({ detail: separateUpcoming }) =>
|
||||
libraryViewSettings.update((settings) => ({
|
||||
...settings,
|
||||
separateUpcoming: !separateUpcoming
|
||||
}))}
|
||||
/>
|
||||
|
||||
<Toggle
|
||||
label="Include watched"
|
||||
checked={!$libraryViewSettings.separateWatched}
|
||||
on:change={({ detail: separateWatched }) =>
|
||||
libraryViewSettings.update((settings) => ({
|
||||
...settings,
|
||||
separateWatched: !separateWatched
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
</Dialog>
|
||||
@@ -3,16 +3,25 @@ import { get, writable } from 'svelte/store';
|
||||
export function createLocalStorageStore<T>(key: string, defaultValue: T) {
|
||||
const store = writable<T>(JSON.parse(localStorage.getItem(key) || 'null') || defaultValue);
|
||||
|
||||
function writeValue(value: T) {
|
||||
const strigified = JSON.stringify(value);
|
||||
if (strigified === JSON.stringify(defaultValue)) {
|
||||
localStorage.removeItem(key);
|
||||
} else {
|
||||
localStorage.setItem(key, strigified);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
subscribe: store.subscribe,
|
||||
get: () => get(store),
|
||||
set: (value: T) => {
|
||||
localStorage.setItem(key, JSON.stringify(value));
|
||||
writeValue(value);
|
||||
store.set(value);
|
||||
},
|
||||
update: (updater: (value: T) => T) => {
|
||||
const newValue = updater(get(store));
|
||||
localStorage.setItem(key, JSON.stringify(newValue));
|
||||
writeValue(newValue);
|
||||
store.set(newValue);
|
||||
},
|
||||
remove: () => {
|
||||
@@ -41,3 +50,20 @@ export const localSettings = createLocalStorageStore<{
|
||||
checkForUpdates: true,
|
||||
skippedVersion: ''
|
||||
});
|
||||
|
||||
export type LibraryViewSettings = {
|
||||
sortBy: 'date-added' | 'title' | 'first-release-date' | 'last-release-date';
|
||||
sortDirection: 'asc' | 'desc';
|
||||
separateUpcoming: boolean;
|
||||
separateWatched: boolean;
|
||||
};
|
||||
|
||||
export const libraryViewSettings = createLocalStorageStore<LibraryViewSettings>(
|
||||
'library-view-settings',
|
||||
{
|
||||
sortBy: 'last-release-date',
|
||||
sortDirection: 'desc',
|
||||
separateUpcoming: true,
|
||||
separateWatched: true
|
||||
}
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user