feat: library page sections, sorting, view options

fix: tmdb library items not being cached
This commit is contained in:
Aleksi Lassila
2025-02-15 05:03:20 +02:00
parent 8f83a34b35
commit ef4fa1d13a
21 changed files with 575 additions and 178 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -52,6 +52,7 @@
{/if}
<AnimatedSelection hasFocus={$hasFocus}>
<Container
{...$$restProps}
{disabled}
on:clickOrSelect={() => {
if (tmdbId || tvdbId) navigate(`/${type}/${tmdbId || tvdbId}`);

View File

@@ -48,6 +48,7 @@
/>
<Container
{...$$restProps}
direction="grid"
gridCols={cols}
class={classNames(

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

View File

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

View File

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

View File

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

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

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

View File

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