From be7d0bc53535afc4fbed20bd325dec925dadc316 Mon Sep 17 00:00:00 2001 From: Jurgis Sakalauskas Date: Mon, 12 Oct 2026 10:01:39 +0300 Subject: [PATCH] add shared watch state, continue watching and new main menu - watch_state table: resume point, watched, play count, last played per file, shared by all clients (Kodi plugin, Android TV app) - POST/GET /api/state/:fileId for players' progress reports (watched at 90% or on end; late resends carry `at` and lose to newer state) - listings carry watch state (files: playcount, resume, lastplayed; shows and seasons: watched/total episodes), episodes sorted by season/episode - all/continue: unfinished files and next episodes, latest activity first - context menus: mark (un)watched for a file, season or show; hide from continue watching - main menu: Continue watching, Movies, Shows, scans (Torr/LRT unlisted) - DATA_ROOT env overrides /srv/data for local runs outside docker Co-Authored-By: Claude Opus 5.5 --- .../1791786266498-CreateWatchStateTable.ts | 29 ++ src/KodiApi/AllFiles/all-files.controller.ts | 46 +++ src/KodiApi/AllFiles/all-files.module.ts | 3 +- src/KodiApi/AllFiles/all-files.service.ts | 218 ++++++++++++-- src/KodiApi/Dto/response-item.dto.ts | 45 +++ src/KodiApi/kodi-api.service.ts | 15 +- src/Shared/Entity/watch-state.entity.ts | 38 +++ src/Shared/Util/file-order.ts | 48 +++ src/WatchState/Dto/playback-report.dto.ts | 37 +++ src/WatchState/Dto/playback-state.dto.ts | 24 ++ src/WatchState/watch-state.controller.ts | 38 +++ src/WatchState/watch-state.module.ts | 14 + src/WatchState/watch-state.service.ts | 275 ++++++++++++++++++ src/config/paths.config.ts | 12 +- src/config/recent-searches.config.ts | 3 +- src/config/type-orm.config.ts | 9 +- 16 files changed, 819 insertions(+), 35 deletions(-) create mode 100644 migrations/1791786266498-CreateWatchStateTable.ts create mode 100644 src/Shared/Entity/watch-state.entity.ts create mode 100644 src/Shared/Util/file-order.ts create mode 100644 src/WatchState/Dto/playback-report.dto.ts create mode 100644 src/WatchState/Dto/playback-state.dto.ts create mode 100644 src/WatchState/watch-state.controller.ts create mode 100644 src/WatchState/watch-state.module.ts create mode 100644 src/WatchState/watch-state.service.ts diff --git a/migrations/1791786266498-CreateWatchStateTable.ts b/migrations/1791786266498-CreateWatchStateTable.ts new file mode 100644 index 0000000..33da351 --- /dev/null +++ b/migrations/1791786266498-CreateWatchStateTable.ts @@ -0,0 +1,29 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateWatchStateTable1791786266498 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `create table watch_state + ( + file_id INTEGER primary key, + position REAL default 0 not null, + duration REAL, + watched TINYINT default 0 not null, + play_count INTEGER default 0 not null, + last_played INTEGER, + updated_at INTEGER not null, + client VARCHAR(32), + hidden TINYINT default 0 not null + );`, + ); + + await queryRunner.query( + `create index watch_state_last_played + on watch_state (last_played);`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE watch_state`); + } +} diff --git a/src/KodiApi/AllFiles/all-files.controller.ts b/src/KodiApi/AllFiles/all-files.controller.ts index 5527925..bcffe69 100644 --- a/src/KodiApi/AllFiles/all-files.controller.ts +++ b/src/KodiApi/AllFiles/all-files.controller.ts @@ -40,6 +40,52 @@ export class AllFilesController { return this.allFilesService.deleteFile(fileId); } + @Get('continue') + getContinueWatching(): Promise { + return this.allFilesService.getContinueWatching(); + } + + @Get('watched/:fileId') + markWatched(@Param('fileId') fileId: string): Promise { + return this.allFilesService.setWatched(fileId, true); + } + + @Get('unwatched/:fileId') + markUnwatched(@Param('fileId') fileId: string): Promise { + return this.allFilesService.setWatched(fileId, false); + } + + @Get('watched-title/:titleId/:seasonId?') + markTitleWatched( + @Param('titleId') titleId: string, + @Param('seasonId') seasonId: string, + ): Promise { + return this.allFilesService.setTitleWatched( + parseInt(titleId), + parseInt(seasonId), + true, + ); + } + + @Get('unwatched-title/:titleId/:seasonId?') + markTitleUnwatched( + @Param('titleId') titleId: string, + @Param('seasonId') seasonId: string, + ): Promise { + return this.allFilesService.setTitleWatched( + parseInt(titleId), + parseInt(seasonId), + false, + ); + } + + @Get('hide/:fileId') + hideFromContinueWatching( + @Param('fileId') fileId: string, + ): Promise { + return this.allFilesService.hideFromContinueWatching(fileId); + } + @Get('play/:fileId') @Head('play/:fileId') play( diff --git a/src/KodiApi/AllFiles/all-files.module.ts b/src/KodiApi/AllFiles/all-files.module.ts index 7ec972c..15cd9aa 100644 --- a/src/KodiApi/AllFiles/all-files.module.ts +++ b/src/KodiApi/AllFiles/all-files.module.ts @@ -4,9 +4,10 @@ import { AllFilesService } from './all-files.service'; import { KodiApiResponseFactory } from '../kodi-api-response.factory'; import { StreamerModule } from '../../Streamer/streamer.module'; import { VideoFilesModule } from '../../VideoFiles/video-files.module'; +import { WatchStateModule } from '../../WatchState/watch-state.module'; @Module({ - imports: [StreamerModule, VideoFilesModule], + imports: [StreamerModule, VideoFilesModule, WatchStateModule], controllers: [AllFilesController], providers: [AllFilesService, KodiApiResponseFactory], exports: [AllFilesService], diff --git a/src/KodiApi/AllFiles/all-files.service.ts b/src/KodiApi/AllFiles/all-files.service.ts index c60c96f..1a57fa5 100644 --- a/src/KodiApi/AllFiles/all-files.service.ts +++ b/src/KodiApi/AllFiles/all-files.service.ts @@ -9,6 +9,13 @@ import { FileEntity } from '../../Shared/Entity/file.entity'; import { TitleEntity } from '../../Shared/Entity/title.entity'; import { KodiApiResponse } from '../Dto/kodi-api-response.type'; import NotificationResponse from '../Dto/notification-response.dto'; +import ResponseItem from '../Dto/response-item.dto'; +import { WatchStateEntity } from '../../Shared/Entity/watch-state.entity'; +import { + WatchStates, + WatchStateService, +} from '../../WatchState/watch-state.service'; +import { compareFiles } from '../../Shared/Util/file-order'; @Injectable() export class AllFilesService { @@ -16,6 +23,7 @@ export class AllFilesService { private readonly koiApiResponseFactory: KodiApiResponseFactory, private readonly streamerFacade: StreamerFacade, private readonly videoFilesFacade: VideoFilesFacade, + private readonly watchStateService: WatchStateService, ) {} getMenu(): ApiResponse { @@ -35,10 +43,11 @@ export class AllFilesService { async getListOfTitles(type: TitleTypeEnum): Promise { const entities = await this.videoFilesFacade.getListOfTitles(type); + const states = await this.watchStateService.getAll(); const response = this.koiApiResponseFactory.createApiResponse(); response.setTitle('All ' + type + 's'); entities.forEach((entity) => { - response + const item = response .createItem() .setLabel(entity.title) .setToFolder() @@ -46,11 +55,47 @@ export class AllFilesService { entity.files.length + (entity.files.length > 1 ? ' files' : ' file'), ) .setPath('all/' + entity.id); + this.addFolderWatchState(item, entity.files, states, '' + entity.id); }); return response; } + /** + * In-progress files and next episodes, one per movie/show, each with the + * folder it's in so players can continue with the rest of it. + */ + async getContinueWatching(): Promise { + const states = await this.watchStateService.getAll(); + const fileEntities = await this.watchStateService.getContinueWatching( + states, + ); + const apiResponse = this.koiApiResponseFactory.createApiResponse(); + apiResponse.setTitle('Continue watching').setNoSort(); + + for (const fileEntity of fileEntities) { + const titleEntity = fileEntity.title; + const folder = + titleEntity.type === TitleTypeEnum.show + ? `all/${titleEntity.id}/${fileEntity.season}` + : `all/${titleEntity.id}`; + const item = this.addFileItem( + apiResponse, + fileEntity, + states.get(fileEntity.id), + ) + .setPlot(titleEntity.title + '\n' + this.buildPlot(fileEntity)) + .setFolder(folder, titleEntity.title) + .addContextMenu( + 'Hide from Continue watching', + 'all/hide/' + fileEntity.id, + ); + this.addWatchedMenus(item, fileEntity, states.get(fileEntity.id)); + } + + return apiResponse; + } + async loadTitle(titleId: number, seasonId: number): Promise { const titleEntity = titleId ? await this.videoFilesFacade.getTitleWithFiles(titleId) @@ -63,8 +108,15 @@ export class AllFilesService { ); } + const states = await this.watchStateService.getAll(); + if (titleEntity.type === TitleTypeEnum.movie) { - return this.buildFilesResponse(titleEntity.title, titleEntity.files); + return this.buildFilesResponse( + titleEntity.title, + titleEntity.files, + false, + states, + ); } if (seasonId) { @@ -73,30 +125,29 @@ export class AllFilesService { titleEntity.files.filter( (fileEntity) => fileEntity.season === seasonId, ), + false, + states, ); } - return this.buildSeasonsResponse(titleEntity); + return this.buildSeasonsResponse(titleEntity, states); } + /** in episode order */ buildFilesResponse( title: string, fileEntities: Array, torrentContext = false, + states: WatchStates = new Map(), ): ApiResponse { const apiResponse = this.koiApiResponseFactory.createApiResponse(); - apiResponse.setTitle(title); - for (const fileEntity of fileEntities) { - const item = apiResponse - .createItem() - .setLabel(fileEntity.fileName) - .setPath('/api?path=all/play/' + fileEntity.id) - .setToPlayable() - .setPlot(this.buildPlot(fileEntity)) - .setSize(fileEntity.size) - .setDuration(fileEntity.duration); + apiResponse.setTitle(title).setNoSort(); + for (const fileEntity of [...fileEntities].sort(compareFiles)) { + const state = states.get(fileEntity.id); + const item = this.addFileItem(apiResponse, fileEntity, state); if (!torrentContext) { + this.addWatchedMenus(item, fileEntity, state); if (fileEntity.torrentId) { item.addContextMenu( 'Show torrent', @@ -111,29 +162,90 @@ export class AllFilesService { return apiResponse; } - private buildSeasonsResponse(titleEntity: TitleEntity): ApiResponse { - const apiResponse = this.koiApiResponseFactory.createApiResponse(); - apiResponse.setTitle(titleEntity.title); + private addFileItem( + apiResponse: ApiResponse, + fileEntity: FileEntity, + state: WatchStateEntity | undefined, + ): ResponseItem { + return apiResponse + .createItem() + .setLabel(fileEntity.fileName) + .setPath('/api?path=all/play/' + fileEntity.id) + .setToPlayable() + .setPlot(this.buildPlot(fileEntity)) + .setSize(fileEntity.size) + .setDuration(fileEntity.duration) + .setWatchState(state); + } - const seasons = []; - const filesCount = {}; + /** "Mark unwatched" also clears a resume point */ + private addWatchedMenus( + item: ResponseItem, + fileEntity: FileEntity, + state: WatchStateEntity | undefined, + ) { + if (!state?.watched) { + item.addContextMenu('Mark watched', 'all/watched/' + fileEntity.id); + } + if (state?.watched || state?.position > 0) { + item.addContextMenu('Mark unwatched', 'all/unwatched/' + fileEntity.id); + } + } + + /** watched count + menus marking all of it; titlePath is `[/]` */ + private addFolderWatchState( + item: ResponseItem, + fileEntities: FileEntity[], + states: WatchStates, + titlePath: string, + ) { + const { watched, total } = this.watchStateService.countWatched( + fileEntities, + states, + ); + item.setWatchedCount(watched, total); + if (watched < total) { + item.addContextMenu('Mark all watched', 'all/watched-title/' + titlePath); + } + if (watched > 0) { + item.addContextMenu( + 'Mark all unwatched', + 'all/unwatched-title/' + titlePath, + ); + } + } + + private buildSeasonsResponse( + titleEntity: TitleEntity, + states: WatchStates, + ): ApiResponse { + const apiResponse = this.koiApiResponseFactory.createApiResponse(); + apiResponse.setTitle(titleEntity.title).setNoSort(); + + const filesBySeason = new Map(); for (const fileEntity of titleEntity.files) { - if (seasons.indexOf(fileEntity.season) === -1) { - seasons.push(fileEntity.season); - filesCount[fileEntity.season] = 0; - } - filesCount[fileEntity.season]++; + const seasonFiles = filesBySeason.get(fileEntity.season) ?? []; + seasonFiles.push(fileEntity); + filesBySeason.set(fileEntity.season, seasonFiles); } + const seasons = [...filesBySeason.keys()].sort((a, b) => a - b); for (const season of seasons) { - apiResponse + const seasonFiles = filesBySeason.get(season); + const item = apiResponse .createItem() .setLabel(season + ' Sezonas') .setToFolder() .setPlot( - filesCount[season] + (filesCount[season] > 1 ? ' files' : ' file'), + seasonFiles.length + (seasonFiles.length > 1 ? ' files' : ' file'), ) .setPath(`all/${titleEntity.id}/${season}`); + this.addFolderWatchState( + item, + seasonFiles, + states, + `${titleEntity.id}/${season}`, + ); } return apiResponse; @@ -212,6 +324,62 @@ export class AllFilesService { ); } + async setWatched( + fileId: string, + watched: boolean, + ): Promise { + await this.watchStateService.setWatched( + [await this.getFileId(fileId)], + watched, + ); + return this.koiApiResponseFactory.createNotificationResponse( + watched ? 'Marked as watched' : 'Marked as unwatched', + ); + } + + /** a whole movie/show, or one season of a show */ + async setTitleWatched( + titleId: number, + seasonId: number, + watched: boolean, + ): Promise { + const titleEntity = titleId + ? await this.videoFilesFacade.getTitleWithFiles(titleId) + : null; + if (titleEntity === null) { + throw new NotFoundException(`title #${titleId} not found`); + } + + const fileEntities = seasonId + ? titleEntity.files.filter((fileEntity) => fileEntity.season === seasonId) + : titleEntity.files; + await this.watchStateService.setWatched( + fileEntities.map((fileEntity) => fileEntity.id), + watched, + ); + return this.koiApiResponseFactory.createNotificationResponse( + watched ? 'Marked as watched' : 'Marked as unwatched', + ); + } + + async hideFromContinueWatching( + fileId: string, + ): Promise { + await this.watchStateService.hide(await this.getFileId(fileId)); + return this.koiApiResponseFactory.createNotificationResponse( + 'Hidden from Continue watching', + ); + } + + private async getFileId(fileId: string): Promise { + const id = parseInt(fileId); + if (isNaN(id) || (await this.videoFilesFacade.getFile(id)) === null) { + throw new NotFoundException(`id #${fileId} not found`); + } + + return id; + } + async deleteFile(fileId: string) { const id = parseInt(fileId); const result = await this.videoFilesFacade.deleteFile(id); diff --git a/src/KodiApi/Dto/response-item.dto.ts b/src/KodiApi/Dto/response-item.dto.ts index 3c4b2bb..b195c59 100644 --- a/src/KodiApi/Dto/response-item.dto.ts +++ b/src/KodiApi/Dto/response-item.dto.ts @@ -1,4 +1,5 @@ import ContextMenu from './context-menu.dto'; +import { WatchStateEntity } from '../../Shared/Entity/watch-state.entity'; export default class ResponseItem { isFolder = false; @@ -15,6 +16,16 @@ export default class ResponseItem { contextMenus: Array; date: string; searchFor: string; + /** files: watch state like Kodi's, resume point in seconds */ + playcount: number; + resume: { position: number; total: number }; + lastplayed: string; + /** folders: watched episodes out of the total */ + watchedCount: number; + totalCount: number; + /** files listed outside their folder (Continue watching): where they are */ + folder: string; + folderTitle: string; constructor() { this.setToPlayable(); @@ -100,6 +111,40 @@ export default class ResponseItem { return this; } + /** call after setDuration, it's the fallback for the resume total */ + setWatchState(state: WatchStateEntity | undefined) { + this.playcount = state?.playCount ?? 0; + if (state?.position > 0) { + this.resume = { + position: state.position, + total: state.duration ?? this.duration ?? 0, + }; + } + if (state?.lastPlayed) { + // Kodi's format + this.lastplayed = new Date(state.lastPlayed) + .toISOString() + .replace('T', ' ') + .slice(0, 19); + } + + return this; + } + + setWatchedCount(watched: number, total: number) { + this.watchedCount = watched; + this.totalCount = total; + + return this; + } + + setFolder(path: string, title: string) { + this.folder = path; + this.folderTitle = title; + + return this; + } + addContextMenu(name, path) { if (!this.contextMenus) { this.contextMenus = []; diff --git a/src/KodiApi/kodi-api.service.ts b/src/KodiApi/kodi-api.service.ts index 9e8a7ad..903bb20 100644 --- a/src/KodiApi/kodi-api.service.ts +++ b/src/KodiApi/kodi-api.service.ts @@ -13,11 +13,18 @@ export class KodiApiService { .createApiResponse() .setTitle('Menu') .addNavigationItems([ - ['All Files', 'all'], - ['Torr', 'torr'], - ['LRT.lt', 'lrt'], + ['Continue watching', 'all/continue'], + // the "All Files" menu, inlined + ['Movies', 'all/movie'], + ['Shows', 'all/show'], + ['Scan for new Files', 'all/scan'], + ['Re-Scan Files', 'all/scan-all'], + // not used at the moment, still reachable by path + //['Torr', 'torr'], + //['LRT.lt', 'lrt'], //['Test', 'test'], - ]); + ]) + .setNoSort(); } getCurrentTest() { diff --git a/src/Shared/Entity/watch-state.entity.ts b/src/Shared/Entity/watch-state.entity.ts new file mode 100644 index 0000000..6ff62eb --- /dev/null +++ b/src/Shared/Entity/watch-state.entity.ts @@ -0,0 +1,38 @@ +import { Column, Entity, PrimaryColumn } from 'typeorm'; + +/** + * Playback state of a file, shared by all clients (Kodi, SKLK TV, ...). + * Positions are in seconds, timestamps in ms since epoch. + */ +@Entity('watch_state') +export class WatchStateEntity { + @PrimaryColumn({ name: 'file_id' }) + fileId: number; + + /** resume point, 0 if there is none */ + @Column({ type: 'float' }) + position: number; + + @Column({ type: 'float' }) + duration: number | null; + + @Column() + watched: boolean; + + @Column({ name: 'play_count' }) + playCount: number; + + /** last played or marked watched; null if never (e.g. only hidden) */ + @Column({ name: 'last_played' }) + lastPlayed: number | null; + + @Column({ name: 'updated_at' }) + updatedAt: number; + + @Column() + client: string | null; + + /** left out of Continue watching until it's played again */ + @Column() + hidden: boolean; +} diff --git a/src/Shared/Util/file-order.ts b/src/Shared/Util/file-order.ts new file mode 100644 index 0000000..b68c6c8 --- /dev/null +++ b/src/Shared/Util/file-order.ts @@ -0,0 +1,48 @@ +import { FileEntity } from '../Entity/file.entity'; + +const collator = new Intl.Collator(undefined, { + numeric: true, + sensitivity: 'base', +}); + +/** episode number parsed from the file name, if there is one */ +export function getEpisode(fileEntity: FileEntity): number | undefined { + try { + const episode = JSON.parse(fileEntity.info)?.episode; + return typeof episode === 'number' ? episode : undefined; + } catch (e) { + return undefined; + } +} + +export function isSample(fileEntity: FileEntity): boolean { + return /\bsample\b/i.test(fileEntity.fileName); +} + +/** + * Files that are the same episode (e.g. two releases of S01E01) share a key, + * files without an episode number are each their own. + */ +export function getEpisodeKey(fileEntity: FileEntity): string { + const episode = getEpisode(fileEntity); + return episode === undefined + ? 'file ' + fileEntity.id + : `${fileEntity.season ?? 0}x${episode}`; +} + +/** by season and episode, then natural file name order (episode 2 before 10) */ +export function compareFiles(a: FileEntity, b: FileEntity): number { + const seasonA = a.season ?? 0; + const seasonB = b.season ?? 0; + if (seasonA !== seasonB) { + return seasonA - seasonB; + } + + const episodeA = getEpisode(a) ?? Number.MAX_SAFE_INTEGER; + const episodeB = getEpisode(b) ?? Number.MAX_SAFE_INTEGER; + if (episodeA !== episodeB) { + return episodeA - episodeB; + } + + return collator.compare(a.fileName, b.fileName); +} diff --git a/src/WatchState/Dto/playback-report.dto.ts b/src/WatchState/Dto/playback-report.dto.ts new file mode 100644 index 0000000..beccff0 --- /dev/null +++ b/src/WatchState/Dto/playback-report.dto.ts @@ -0,0 +1,37 @@ +import { BadRequestException } from '@nestjs/common'; + +export type PlaybackEvent = 'start' | 'tick' | 'pause' | 'stop' | 'end'; + +const EVENTS: PlaybackEvent[] = ['start', 'tick', 'pause', 'stop', 'end']; + +/** a player's progress report, positions in seconds */ +export class PlaybackReport { + constructor( + readonly position: number, + readonly duration: number | null, + readonly event: PlaybackEvent, + readonly client: string | null, + /** + * when it happened (ms since epoch), only for reports sent late (offline, + * imported history); live reports leave it out and get the server's time + */ + readonly at: number | null, + ) {} + + static fromBody(body: Record): PlaybackReport { + const position = Number(body?.position); + if (!Number.isFinite(position) || position < 0) { + throw new BadRequestException('position (seconds) is required'); + } + const duration = Number(body.duration); + const at = Number(body.at); + + return new PlaybackReport( + position, + Number.isFinite(duration) && duration > 0 ? duration : null, + EVENTS.find((event) => event === body.event) ?? 'tick', + typeof body.client === 'string' ? body.client.slice(0, 32) : null, + Number.isFinite(at) && at > 0 ? at : null, + ); + } +} diff --git a/src/WatchState/Dto/playback-state.dto.ts b/src/WatchState/Dto/playback-state.dto.ts new file mode 100644 index 0000000..a261a76 --- /dev/null +++ b/src/WatchState/Dto/playback-state.dto.ts @@ -0,0 +1,24 @@ +import { WatchStateEntity } from '../../Shared/Entity/watch-state.entity'; + +/** what players get back: resume point and watched state, positions in seconds */ +export class PlaybackStateDto { + fileId: number; + position: number; + duration: number | null; + watched: boolean; + playcount: number; + /** ms since epoch */ + lastPlayed: number | null; + + static fromEntity(state: WatchStateEntity): PlaybackStateDto { + const dto = new PlaybackStateDto(); + dto.fileId = state.fileId; + dto.position = state.position; + dto.duration = state.duration; + dto.watched = state.watched; + dto.playcount = state.playCount; + dto.lastPlayed = state.lastPlayed; + + return dto; + } +} diff --git a/src/WatchState/watch-state.controller.ts b/src/WatchState/watch-state.controller.ts new file mode 100644 index 0000000..de534bd --- /dev/null +++ b/src/WatchState/watch-state.controller.ts @@ -0,0 +1,38 @@ +import { Body, Controller, Get, HttpCode, Param, Post } from '@nestjs/common'; +import { WatchStateService } from './watch-state.service'; +import { PlaybackReport } from './Dto/playback-report.dto'; +import { PlaybackStateDto } from './Dto/playback-state.dto'; + +/** + * Plain JSON for players (not the Kodi menu protocol). Listings carry the + * same state, see AllFilesService. + */ +@Controller('api/state') +export class WatchStateController { + constructor(private readonly watchStateService: WatchStateService) {} + + @Get(':fileId') + async get(@Param('fileId') fileId: string): Promise { + const state = await this.watchStateService.get(parseInt(fileId)); + return PlaybackStateDto.fromEntity(state); + } + + /** + * Progress of a playing file, `{position, duration?, event?, client?, at?}` + * in seconds; event is start | tick | pause | stop | end (default tick). + * `at` (ms since epoch) only for reports sent late: older than the stored + * state, they're ignored. + */ + @Post(':fileId') + @HttpCode(200) + async report( + @Param('fileId') fileId: string, + @Body() body: Record, + ): Promise { + const state = await this.watchStateService.report( + parseInt(fileId), + PlaybackReport.fromBody(body), + ); + return PlaybackStateDto.fromEntity(state); + } +} diff --git a/src/WatchState/watch-state.module.ts b/src/WatchState/watch-state.module.ts new file mode 100644 index 0000000..1cdddf6 --- /dev/null +++ b/src/WatchState/watch-state.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { WatchStateEntity } from '../Shared/Entity/watch-state.entity'; +import { FileEntity } from '../Shared/Entity/file.entity'; +import { WatchStateService } from './watch-state.service'; +import { WatchStateController } from './watch-state.controller'; + +@Module({ + imports: [TypeOrmModule.forFeature([WatchStateEntity, FileEntity])], + controllers: [WatchStateController], + providers: [WatchStateService], + exports: [WatchStateService], +}) +export class WatchStateModule {} diff --git a/src/WatchState/watch-state.service.ts b/src/WatchState/watch-state.service.ts new file mode 100644 index 0000000..4980dae --- /dev/null +++ b/src/WatchState/watch-state.service.ts @@ -0,0 +1,275 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { In, Repository } from 'typeorm'; +import { WatchStateEntity } from '../Shared/Entity/watch-state.entity'; +import { FileEntity } from '../Shared/Entity/file.entity'; +import { TitleTypeEnum } from '../Shared/Enum/title-type.enum'; +import { + compareFiles, + getEpisodeKey, + isSample, +} from '../Shared/Util/file-order'; +import { PlaybackReport } from './Dto/playback-report.dto'; + +/** share of the duration after which a file counts as watched (Kodi's default) */ +const WATCHED_RATIO = 0.9; +/** earlier positions don't move the resume point, so a quick open/close keeps it */ +const MIN_POSITION = 10; + +export type WatchStates = Map; + +/** + * Watched state and resume points of files, the one source of truth for all + * clients: players report progress, listings show it. + */ +@Injectable() +export class WatchStateService { + constructor( + @InjectRepository(WatchStateEntity) + private readonly watchStateRepository: Repository, + @InjectRepository(FileEntity) + private readonly fileRepository: Repository, + ) {} + + /** by file id; there are rows only for files that were played or marked */ + async getAll(): Promise { + const states = await this.watchStateRepository.find(); + return new Map(states.map((state) => [state.fileId, state])); + } + + async get(fileId: number): Promise { + await this.getFile(fileId); + return ( + (await this.watchStateRepository.findOneBy({ fileId })) ?? + this.create(fileId) + ); + } + + async report( + fileId: number, + report: PlaybackReport, + ): Promise { + const fileEntity = await this.getFile(fileId); + const existing = await this.watchStateRepository.findOneBy({ fileId }); + if (existing && report.at !== null && report.at < existing.updatedAt) { + // a late resend; something newer is already in + return existing; + } + + const state = existing ?? this.create(fileId); + const duration = report.duration ?? state.duration ?? fileEntity.duration; + + if ( + report.event === 'end' || + (duration > 0 && report.position >= duration * WATCHED_RATIO) + ) { + // the end credits keep reporting, but it's one play-through + if (!state.watched || state.position > 0) { + state.playCount++; + } + state.watched = true; + state.position = 0; + } else if (report.position >= MIN_POSITION) { + state.position = report.position; + } + + if (duration > 0) { + state.duration = duration; + } + state.lastPlayed = Math.min(report.at ?? Date.now(), Date.now()); + state.updatedAt = state.lastPlayed; + state.client = report.client; + state.hidden = false; + + return this.watchStateRepository.save(state); + } + + /** + * Also clears resume points. Marked watched counts as played (Continue + * watching moves on to the next episode), unwatched as never played. + */ + async setWatched(fileIds: number[], watched: boolean): Promise { + const existing = await this.watchStateRepository.findBy({ + fileId: In(fileIds), + }); + const byId = new Map(existing.map((state) => [state.fileId, state])); + const now = Date.now(); + + const states = fileIds.map((fileId) => { + const state = byId.get(fileId) ?? this.create(fileId); + state.watched = watched; + state.playCount = watched ? Math.max(state.playCount, 1) : 0; + state.position = 0; + state.hidden = false; + state.updatedAt = now; + state.lastPlayed = watched ? now : null; + return state; + }); + + await this.watchStateRepository.save(states); + } + + /** leave out of Continue watching until it's played again */ + async hide(fileId: number): Promise { + await this.getFile(fileId); + const state = + (await this.watchStateRepository.findOneBy({ fileId })) ?? + this.create(fileId); + state.hidden = true; + state.updatedAt = Date.now(); + + await this.watchStateRepository.save(state); + } + + /** episodes (releases of the same episode count once), samples left out */ + countWatched( + fileEntities: FileEntity[], + states: WatchStates, + ): { watched: number; total: number } { + const episodes = new Map(); + for (const fileEntity of fileEntities) { + if (isSample(fileEntity)) { + continue; + } + const key = getEpisodeKey(fileEntity); + episodes.set( + key, + episodes.get(key) || !!states.get(fileEntity.id)?.watched, + ); + } + + let watched = 0; + episodes.forEach((isWatched) => (watched += isWatched ? 1 : 0)); + + return { watched, total: episodes.size }; + } + + /** + * What to watch next, one file per title, latest activity first: the title's + * last played file while it's unfinished, or for shows the next unwatched + * episode after it. Finished movies and shows are left out, and so are files + * hidden from Continue watching. + * Files come with their title and torrent. + */ + async getContinueWatching( + states: WatchStates, + limit = 20, + ): Promise { + const fileEntities = await this.fileRepository + .createQueryBuilder('file') + .leftJoinAndSelect('file.title', 'title') + .leftJoinAndSelect('file.torrent', 'torrent') + .where('file.deleted = :deleted', { deleted: false }) + .andWhere( + `file.title_id IN ( + SELECT f.title_id FROM files f + INNER JOIN watch_state s ON s.file_id = f.id + WHERE s.last_played IS NOT NULL + )`, + ) + .getMany(); + + const byTitle = new Map(); + for (const fileEntity of fileEntities) { + const titleFiles = byTitle.get(fileEntity.titleId) ?? []; + titleFiles.push(fileEntity); + byTitle.set(fileEntity.titleId, titleFiles); + } + + const lastPlayed = (fileEntity: FileEntity) => + states.get(fileEntity.id)?.lastPlayed ?? 0; + const found: FileEntity[] = []; + const activity = new Map(); + + byTitle.forEach((titleFiles) => { + titleFiles.sort(compareFiles); + // the later episode wins a tie, e.g. a whole season marked watched + const last = titleFiles.reduce((latest, fileEntity) => + lastPlayed(fileEntity) >= lastPlayed(latest) ? fileEntity : latest, + ); + if (lastPlayed(last) === 0) { + return; + } + + let next: FileEntity | null = last; + if (states.get(last.id).watched) { + next = + last.title.type === TitleTypeEnum.show + ? this.findNextEpisode(titleFiles, last, states) + : null; + } + + if (next && !states.get(next.id)?.hidden) { + found.push(next); + activity.set(next, lastPlayed(last)); + } + }); + + return found + .sort((a, b) => activity.get(b) - activity.get(a)) + .slice(0, limit); + } + + /** the first unwatched episode after [after]; [sortedFiles] are the title's files in order */ + private findNextEpisode( + sortedFiles: FileEntity[], + after: FileEntity, + states: WatchStates, + ): FileEntity | null { + const episodes = new Map(); + for (const fileEntity of sortedFiles) { + if (isSample(fileEntity)) { + continue; + } + const key = getEpisodeKey(fileEntity); + episodes.set(key, [...(episodes.get(key) ?? []), fileEntity]); + } + + const keys = [...episodes.keys()]; + const index = keys.indexOf(getEpisodeKey(after)); + if (index === -1) { + return null; + } + + for (const key of keys.slice(index + 1)) { + const releases = episodes.get(key); + if (releases.some((fileEntity) => states.get(fileEntity.id)?.watched)) { + continue; + } + // the release that was started, else the biggest one + return ( + releases.find( + (fileEntity) => states.get(fileEntity.id)?.position > 0, + ) ?? releases.reduce((a, b) => (b.size > a.size ? b : a)) + ); + } + + return null; + } + + private async getFile(fileId: number): Promise { + const fileEntity = isNaN(fileId) + ? null + : await this.fileRepository.findOneBy({ id: fileId }); + if (fileEntity === null) { + throw new NotFoundException(`id #${fileId} not found`); + } + + return fileEntity; + } + + private create(fileId: number): WatchStateEntity { + const state = new WatchStateEntity(); + state.fileId = fileId; + state.position = 0; + state.duration = null; + state.watched = false; + state.playCount = 0; + state.lastPlayed = null; + state.updatedAt = Date.now(); + state.client = null; + state.hidden = false; + + return state; + } +} diff --git a/src/config/paths.config.ts b/src/config/paths.config.ts index 52ff921..35129db 100644 --- a/src/config/paths.config.ts +++ b/src/config/paths.config.ts @@ -8,13 +8,19 @@ export class PathsConfig { public getStaticRequestsLogPath(): string { return join( - '/srv/data/requests-log', + this.getDataRoot(), + 'requests-log', this.configService.getEnv('LOG_FILE_REQUESTS_NAME'), ); } public getStaticFolder(): string { - return '/srv/data/static'; + return join(this.getDataRoot(), 'static'); + } + + /** the docker volumes; DATA_ROOT overrides it, e.g. for a local run outside docker */ + public getDataRoot(): string { + return this.configService.getEnv('DATA_ROOT') || '/srv/data'; } public getPathByEnvKey(key: string) { @@ -24,7 +30,7 @@ export class PathsConfig { } public getDbFolderPath(): string { - return '/srv/data/db'; + return join(this.getDataRoot(), 'db'); } private getPath(relPath: string) { diff --git a/src/config/recent-searches.config.ts b/src/config/recent-searches.config.ts index 9130b44..5615a0d 100644 --- a/src/config/recent-searches.config.ts +++ b/src/config/recent-searches.config.ts @@ -1,4 +1,5 @@ import { ConfigService } from './config.service'; +import { join } from 'path'; export class RecentSearchesConfig { constructor(private readonly configService: ConfigService) { @@ -6,7 +7,7 @@ export class RecentSearchesConfig { } public getRecentSearchesFolder(): string { - return '/srv/data/recent-searches'; + return join(this.configService.getPaths().getDataRoot(), 'recent-searches'); } public getRecentSearchesLimit(): number { diff --git a/src/config/type-orm.config.ts b/src/config/type-orm.config.ts index 0eded03..c419cdd 100644 --- a/src/config/type-orm.config.ts +++ b/src/config/type-orm.config.ts @@ -4,6 +4,7 @@ import { EntityClassOrSchema } from '@nestjs/typeorm/dist/interfaces/entity-clas import { TitleEntity } from '../Shared/Entity/title.entity'; import { FileEntity } from '../Shared/Entity/file.entity'; import { TorrentEntity } from '../Shared/Entity/torrent.entity'; +import { WatchStateEntity } from '../Shared/Entity/watch-state.entity'; import { join } from 'path'; export class TypeOrmConfig { @@ -13,7 +14,13 @@ export class TypeOrmConfig { configService.getPaths().getDbFolderPath(), configService.getEnv('DB_FILENAME'), ); - this.entities = [LrtCategory, TitleEntity, FileEntity, TorrentEntity]; + this.entities = [ + LrtCategory, + TitleEntity, + FileEntity, + TorrentEntity, + WatchStateEntity, + ]; } type: 'sqlite'; database: string;