feat: server side continue watching before refactoring

This commit is contained in:
Aleksi Lassila
2025-03-30 13:25:44 +03:00
parent 97223a1d40
commit be27a0ca90
11 changed files with 91 additions and 165 deletions

View File

@@ -12,6 +12,7 @@ export enum MyListOrder {
Name = 'name',
FirstReleaseDate = 'first-release-date',
LastReleaseDate = 'last-release-date',
LastPlayed = 'last-played',
}
export enum MyListStatusFilter {
@@ -19,7 +20,7 @@ export enum MyListStatusFilter {
Upcoming = 'upcoming',
Unwatched = 'unwatched',
Watched = 'watched',
ContinueWatching = 'continueWatching',
ContinueWatching = 'continue-watching',
}
export enum MyListTypeFilter {

View File

@@ -25,13 +25,17 @@ export class LibraryItem {
id: string;
@ApiProperty({ required: true })
@Column({ unique: true })
@Column()
tmdbId: string;
@ApiProperty({ required: true, enum: MediaType })
@Column()
mediaType: MediaType;
@ApiProperty({ type: 'string', required: false })
@Column({ nullable: true })
lastPlayedAt?: Date;
@ApiProperty({ required: false, type: MovieMetadata })
@ManyToOne(() => MovieMetadata, {
createForeignKeyConstraints: false,

View File

@@ -57,66 +57,12 @@ export class LibraryService {
direction = OrderDirection.Desc,
} = options;
// const order = {
// [MyListOrder.DateAdded]: { createdAt: directon } as const,
// [MyListOrder.Name]: {
// seriesMetadata: {
// name: directon,
// },
// movieMetadata: {
// name: directon,
// },
// } as const,
// [MyListOrder.FirstReleaseDate]: {
// movieMetadata: {
// releaseDate: directon,
// },
// seriesMetadata: {
// firstReleaseDate: directon,
// },
// } as const,
// [MyListOrder.LastReleaseDate]: {
// movieMetadata: {
// releaseDate: directon,
// },
// seriesMetadata: {
// lastReleaseDate: directon,
// },
// } as const,
// }[sortBy];
const mediaType = type
? type === MyListTypeFilter.Movies
? MediaType.Movie
: MediaType.Series
: undefined;
// const [items, total] = await this.libraryRepository.findAndCount({
// relations: {
// playStates: true,
// seriesMetadata: true,
// movieMetadata: true,
// },
// select: {
// seriesMetadata: {
// firstReleaseDate: true,
// lastReleaseDate: true,
// name: true,
// },
// movieMetadata: {
// releaseDate: true,
// name: true,
// },
// },
// where: {
// userId,
// ...(mediaType ? { mediaType } : {}),
// },
// order,
// take: pagination.itemsPerPage,
// skip: pagination.itemsPerPage * (pagination.page - 1),
// });
let builder = this.libraryRepository
.createQueryBuilder('libraryItem')
.leftJoinAndSelect('libraryItem.playStates', 'playStates')
@@ -176,7 +122,10 @@ export class LibraryService {
builder = builder.andWhere(upcoming);
} else if (status === MyListStatusFilter.Watched) {
builder = builder.andWhere(watchedAndNotUpcoming);
} else if (status === MyListStatusFilter.Unwatched) {
} else if (
status === MyListStatusFilter.Unwatched ||
status === MyListStatusFilter.ContinueWatching
) {
builder = builder.andWhere(
new Brackets((qb) =>
qb
@@ -210,6 +159,12 @@ export class LibraryService {
),
),
);
if (status === MyListStatusFilter.ContinueWatching) {
builder = builder.andWhere(
"libraryItem.lastPlayedAt IS NOT NULL AND libraryItem.lastPlayedAt > date('now', '-1 month')",
);
}
}
const DIRECTION = direction === OrderDirection.Asc ? 'ASC' : 'DESC';
@@ -224,6 +179,8 @@ export class LibraryService {
} else if (order === MyListOrder.LastReleaseDate) {
builder.addOrderBy('movieMetadata.releaseDate', DIRECTION);
builder.addOrderBy('seriesMetadata.lastReleaseDate', DIRECTION);
} else if (order === MyListOrder.LastPlayed) {
builder.addOrderBy('libraryItem.lastPlayedAt', DIRECTION);
}
const [items, total] = await builder

View File

@@ -2,10 +2,11 @@ import { Module } from '@nestjs/common';
import { playStateProviders } from './play-state.providers';
import { PlayStatesController } from './play-states.controller';
import { PlayStatesService } from './play-states.service';
import { libraryProviders } from '../library/library.providers';
@Module({
imports: [],
providers: [...playStateProviders, PlayStatesService],
providers: [...playStateProviders, ...libraryProviders, PlayStatesService],
controllers: [PlayStatesController],
exports: [PlayStatesService],
})

View File

@@ -1,15 +1,19 @@
import { Inject, Injectable } from '@nestjs/common';
import { MediaTypeFull } from 'src/common/common.dto';
import { Repository } from 'typeorm';
import { BulkUpdatePlayStateDto, UpdatePlayStateDto } from './play-state.dto';
import { UpdatePlayStateDto } from './play-state.dto';
import { PlayState } from './play-state.entity';
import { USER_PLAY_STATE_REPOSITORY } from './play-state.providers';
import { USER_LIBRARY_REPOSITORY } from '../library/library.providers';
import { LibraryItem } from '../library/library.entity';
@Injectable()
export class PlayStatesService {
constructor(
@Inject(USER_PLAY_STATE_REPOSITORY)
private readonly playStateRepository: Repository<PlayState>,
@Inject(USER_LIBRARY_REPOSITORY)
private readonly libraryRepository: Repository<LibraryItem>,
) {}
async findMoviePlayState(userId: string, tmdbId: string) {
@@ -106,7 +110,14 @@ export class PlayStatesService {
if (playState.progress !== undefined) state.progress = playState.progress;
if (playState.watched !== undefined) state.watched = playState.watched;
return this.playStateRepository.save(state);
return this.playStateRepository.save(state).then(async (state) => {
await this.libraryRepository.update(
{ tmdbId, userId },
{ lastPlayedAt: new Date() },
);
return state;
});
}
async updateOrCreateEpisodePlayState(
@@ -125,7 +136,14 @@ export class PlayStatesService {
if (playState.progress !== undefined) state.progress = playState.progress;
if (playState.watched !== undefined) state.watched = playState.watched;
return this.playStateRepository.save(state);
return this.playStateRepository.save(state).then(async (state) => {
await this.libraryRepository.update(
{ tmdbId, userId },
{ lastPlayedAt: new Date() },
);
return state;
});
}
async deleteMoviePlayState(userId: string, tmdbId: string) {

View File

@@ -1617,9 +1617,9 @@ export class Api<SecurityDataType extends unknown> extends HttpClient<SecurityDa
getMyList: (
userId: string,
query?: {
status?: 'all' | 'upcoming' | 'unwatched' | 'watched' | 'continueWatching';
status?: 'all' | 'upcoming' | 'unwatched' | 'watched' | 'continue-watching';
type?: 'movies' | 'series' | 'all';
order?: 'date-added' | 'name' | 'first-release-date' | 'last-release-date';
order?: 'date-added' | 'name' | 'first-release-date' | 'last-release-date' | 'last-played';
direction?: 'asc' | 'desc';
page?: number;
itemsPerPage?: number;

View File

@@ -1,12 +1,11 @@
<script lang="ts">
import type {
MediaSource,
MediaSourceDto,
StreamDto,
SubtitlesDto as Subtitles
} from '$lib/apis/reiverr/reiverr.openapi';
import {
episodeUserDataStore,
libraryItemsDataStore,
movieUserDataStore,
seriesUserDataStore,
tmdbMovieDataStore,
@@ -28,7 +27,7 @@
export let tmdbId: string;
export let season: number | undefined = undefined;
export let episode: number | undefined = undefined;
export let source: MediaSource;
export let source: MediaSourceDto;
export let key: string = '';
export let progress: number = 0;
@@ -228,6 +227,7 @@
movieUserDataStore.refresh(tmdbId);
}
libraryItemsDataStore.refreshIn(1500);
continuewa
});
});
</script>

View File

@@ -1,53 +1,28 @@
<script lang="ts">
import CollectionCard from '$lib/components/Collection/CollectionCard.svelte';
import {
collectionsList,
companiesList,
type Collection
} from '$lib/components/Collection/collections';
import { collectionsList, companiesList } from '$lib/components/Collection/collections';
import CompanyCard from '$lib/components/Collection/CompanyCard.svelte';
import Container from '$lib/components/Container.svelte';
import { createBackgroundPage } from '$lib/components/GlobalBackground/BackgroundStack';
import TmdbMoviesHeroShowcase from '$lib/components/HeroShowcase/TmdbMoviesHeroShowcase.svelte';
import { scrollIntoView } from '$lib/selectable';
import { libraryItemsDataStore } from '$lib/stores/data.store';
import { continueWatchingMoviesDataStore } from '$lib/stores/data.store';
import { setScrollContext } from '$lib/stores/scroll.store';
import { setUiVisibilityContext } from '$lib/stores/ui-visibility.store';
import { tmdbApi, tmdbApi4 } from '$lib/stores/user.store';
import { onDestroy } from 'svelte';
import { derived } from 'svelte/store';
import { TMDB_MOVIE_GENRES } from '../apis/tmdb/tmdb-api';
import TmdbCard from '../components/Card/TmdbCard.svelte';
import Carousel from '../components/Carousel/Carousel.svelte';
import { TMDB_BACKDROP_SMALL } from '$lib/constants';
createBackgroundPage();
const { registrar: registerScroll } = setScrollContext();
const { visibleStyle } = setUiVisibilityContext();
const { ...libraryData } = libraryItemsDataStore.subscribe();
const libraryContinueWatching = derived(libraryData, (libraryData) => {
if (!libraryData) return [];
const { ...continueWatching } = continueWatchingMoviesDataStore.subscribe();
const movies = libraryData.filter(
(i) => i.mediaType === 'Movie' && i.playStates?.length && !i.watched
);
movies.sort((a, b) => {
const aMax = Math.max(
...(a.playStates?.map((p) => new Date(p.lastPlayedAt).getTime()) || [0])
);
const bMax = Math.max(
...(b.playStates?.map((p) => new Date(p.lastPlayedAt).getTime()) || [0])
);
return bMax - aMax;
});
return movies;
});
$: libraryContinueWatchingKey = $libraryContinueWatching && Symbol();
$: libraryContinueWatchingKey = $continueWatching && Symbol();
const popularMovies = tmdbApi.getTrendingMovies();
const newDigitalReleases = tmdbApi.getDigitalMovieReleases();
@@ -73,7 +48,7 @@
// });
onDestroy(() => {
libraryData.unsubscribe();
continueWatching.unsubscribe();
});
</script>
@@ -87,12 +62,12 @@
/>
</Container>
<div class="my-16 space-y-8 relative z-10" style={$visibleStyle}>
{#if $libraryContinueWatching.length}
{#if $continueWatching?.items.length}
<Carousel scrollClass="px-32" on:enter={scrollIntoView({ vertical: 128 })}>
<span slot="header">Continue Watching</span>
{#key libraryContinueWatchingKey}
{#each $libraryContinueWatching as item (item.id)}
<TmdbCard on:enter={scrollIntoView({ left: 128 })} size="lg" {item} />
{#each $continueWatching?.items ?? [] as item (item.tmdbId)}
<TmdbCard on:enter={scrollIntoView({ left: 128 })} size="lg" item={item.tmdbItem} />
{/each}
{/key}
</Carousel>

View File

@@ -1,47 +1,26 @@
<script lang="ts">
import { networksList } from '$lib/components/Collection/collections';
import NetworkCard from '$lib/components/Collection/NetworkCard.svelte';
import Container from '$lib/components/Container.svelte';
import { createBackgroundPage } from '$lib/components/GlobalBackground/BackgroundStack';
import TmdbSeriesHeroShowcase from '$lib/components/HeroShowcase/TmdbSeriesHeroShowcase.svelte';
import { scrollIntoView } from '$lib/selectable';
import { libraryItemsDataStore } from '$lib/stores/data.store';
import { continueWatchingSeriesDataStore } from '$lib/stores/data.store';
import { setScrollContext } from '$lib/stores/scroll.store';
import { setUiVisibilityContext } from '$lib/stores/ui-visibility.store';
import { tmdbApi, tmdbApi4 } from '$lib/stores/user.store';
import { onDestroy } from 'svelte';
import { derived } from 'svelte/store';
import { TMDB_SERIES_GENRES } from '../apis/tmdb/tmdb-api';
import TmdbCard from '../components/Card/TmdbCard.svelte';
import Carousel from '../components/Carousel/Carousel.svelte';
import { networks, networksList } from '$lib/components/Collection/collections';
import NetworkCard from '$lib/components/Collection/NetworkCard.svelte';
createBackgroundPage();
const { registrar: registerScroll } = setScrollContext();
const { visibleStyle } = setUiVisibilityContext();
const { ...libraryData } = libraryItemsDataStore.subscribe();
const libraryContinueWatching = derived(libraryData, (libraryData) => {
if (!libraryData) return [];
const series = libraryData.filter(
(i) => i.mediaType === 'Series' && i.playStates?.length && !i.watched
);
series.sort((a, b) => {
const aMax = Math.max(
...(a.playStates?.map((p) => new Date(p.lastPlayedAt).getTime()) || [0])
);
const bMax = Math.max(
...(b.playStates?.map((p) => new Date(p.lastPlayedAt).getTime()) || [0])
);
return bMax - aMax;
});
return series;
});
$: libraryContinueWatchingKey = $libraryContinueWatching && Symbol();
const { ...continueWatching } = continueWatchingSeriesDataStore.subscribe();
$: libraryContinueWatchingKey = $continueWatching && Symbol();
const popular = tmdbApi.getTrendingSeries();
const nowStreaming = tmdbApi.getNowStreamingSeries();
@@ -49,7 +28,7 @@
const recommendations = tmdbApi4.getRecommendedSeries();
onDestroy(() => {
libraryData.unsubscribe();
continueWatching.unsubscribe();
});
</script>
@@ -63,12 +42,12 @@
/>
</Container>
<div class="my-16 space-y-8 relative z-10" style={$visibleStyle}>
{#if $libraryContinueWatching.length}
{#if $continueWatching?.items?.length}
<Carousel scrollClass="px-32" on:enter={scrollIntoView({ vertical: 128 })}>
<span slot="header">Continue Watching</span>
{#key libraryContinueWatchingKey}
{#each $libraryContinueWatching as item (item.id)}
<TmdbCard on:enter={scrollIntoView({ left: 128 })} size="lg" {item} />
{#each $continueWatching?.items ?? [] as item (item.tmdbId)}
<TmdbCard on:enter={scrollIntoView({ left: 128 })} size="lg" item={item.tmdbItem} />
{/each}
{/key}
</Carousel>

View File

@@ -195,7 +195,6 @@ export function usePaginatedRequest<TResponseItem>(
if (options.loadFirstPage !== false) requestNextPage();
async function requestNextPage() {
console.log('herer');
if (get(loadingPage) === get(nextPage)) return;
if (!hasNextPage) return;
@@ -204,8 +203,6 @@ export function usePaginatedRequest<TResponseItem>(
const currentPage = get(nextPage);
const id = requestId;
console.log('requesting page', currentPage, id);
if (promise) await promise;
if (!hasNextPage) return;
@@ -217,7 +214,6 @@ export function usePaginatedRequest<TResponseItem>(
if (res.items.length < res.itemsPerPage) {
hasNextPage = false;
console.log('no more pages', res);
}
data.update((d) => [...d, ...res.items]);
@@ -296,37 +292,33 @@ export const episodeUserDataStore = useRequestsStore(
.then((r) => r.data)
);
export const libraryItemsDataStore = useRequestsStore(
() => reiverrApi.library.getMyList(get(user)?.id as string).then((r) => r.data.items),
{ persistant: true }
export const continueWatchingMoviesDataStore = useRequestsStore(() =>
reiverrApi.library
.getMyList(String(get(user)?.id), {
type: 'movies',
order: 'last-played',
status: 'continue-watching'
})
.then((r) => r.data)
);
// const continueWatchingDataStore = useDerivedRequestsStore(
// libraryItemsDataStore,
// async (libraryData) => {
// if (!libraryData) return [];
// const movies = libraryData.filter(
// (i) => i.mediaType === 'Movie' && i.playStates?.length && !i.watched
// );
// movies.sort((a, b) => {
// const aMax = Math.max(
// ...(a.playStates?.map((p) => new Date(p.lastPlayedAt).getTime()) || [0])
// );
// const bMax = Math.max(
// ...(b.playStates?.map((p) => new Date(p.lastPlayedAt).getTime()) || [0])
// );
// return bMax - aMax;
// });
// return movies.map((i) => i.metadata);
// }
// );
export const continueWatchingSeriesDataStore = useRequestsStore(() =>
reiverrApi.library
.getMyList(String(get(user)?.id), {
type: 'series',
order: 'last-played',
status: 'continue-watching'
})
.then((r) => r.data)
);
export const mediaSourcesDataStore = useRequestsStore(() =>
reiverrApi.users
.findUserById(get(user)?.id || '')
.then((r) => r.data.mediaSources?.sort((a, b) => a.priority - b.priority) ?? [])
);
export function refreshLibraryDerivatives(timeout = 0) {
continueWatchingMoviesDataStore.refreshIn(timeout);
continueWatchingSeriesDataStore.refreshIn(timeout);
}

View File

@@ -10,7 +10,6 @@ import type {
SeriesUserDataDto,
StreamCandidateDto
} from '../apis/reiverr/reiverr.openapi';
import type { MediaType } from '../types';
import {
episodeUserDataStore,
libraryItemsDataStore,
@@ -76,7 +75,7 @@ async function getAutoplayStream(options: { tmdbId: string; season?: number; epi
}
function useUserLibrary(
mediaType: MediaType,
mediaType: 'movie' | 'series',
tmdbId: string,
userDataP: Readable<MovieUserDataDto | SeriesUserDataDto | undefined>
) {
@@ -94,7 +93,7 @@ function useUserLibrary(
return;
}
const success = await reiverrApi.users
const success = await reiverrApi.library
.addLibraryItem(userId, tmdbId, { mediaType })
.then((r) => r.data.success);
if (success) {
@@ -111,7 +110,7 @@ function useUserLibrary(
return;
}
const success = await reiverrApi.users
const success = await reiverrApi.library
.removeLibraryItem(userId, tmdbId)
.then((r) => r.data.success);
if (success) {
@@ -170,7 +169,7 @@ export function useSeriesUserData(tmdbId: string) {
const userDataRequest = seriesUserDataStore.subscribe(tmdbId);
const tmdbSeriesRequest = tmdbSeriesDataStore.subscribe(Number(tmdbId));
const libraryStore = useUserLibrary('Series', tmdbId, userDataRequest);
const libraryStore = useUserLibrary('series', tmdbId, userDataRequest);
const canStreamStore = useCanStream();
const episodesUserData = writable<EpisodeData[]>([]);
const nextEpisode = writable<EpisodeData>({
@@ -323,7 +322,7 @@ export function useSeriesUserData(tmdbId: string) {
export function useMovieUserData(tmdbId: string) {
const background = getBackgroundPage();
const userData = movieUserDataStore.subscribe(tmdbId);
const libraryStore = useUserLibrary('Movie', tmdbId, userData);
const libraryStore = useUserLibrary('movie', tmdbId, userData);
const canStreamStore = useCanStream();
const isWatchedStore = useIsWatched(userData, (userId, watched) =>
reiverrApi.users.updateMoviePlayStateByTmdbId(userId, tmdbId, {