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 <noreply@anthropic.com>
This commit is contained in:
Jurgis SakalauskasandClaude Opus 5.5 committed 2026-10-12 10:01:39 +03:00
1 parent 02522bbd04
commit be7d0bc535
16 files changed
+819 -35

No files matched your search

@@ -0,0 +1,29 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class CreateWatchStateTable1791786266498 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
await queryRunner.query(`DROP TABLE watch_state`);
}
}
@@ -40,6 +40,52 @@ export class AllFilesController {
return this.allFilesService.deleteFile(fileId); return this.allFilesService.deleteFile(fileId);
} }
@Get('continue')
getContinueWatching(): Promise<ApiResponse> {
return this.allFilesService.getContinueWatching();
}
@Get('watched/:fileId')
markWatched(@Param('fileId') fileId: string): Promise<KodiApiResponse> {
return this.allFilesService.setWatched(fileId, true);
}
@Get('unwatched/:fileId')
markUnwatched(@Param('fileId') fileId: string): Promise<KodiApiResponse> {
return this.allFilesService.setWatched(fileId, false);
}
@Get('watched-title/:titleId/:seasonId?')
markTitleWatched(
@Param('titleId') titleId: string,
@Param('seasonId') seasonId: string,
): Promise<KodiApiResponse> {
return this.allFilesService.setTitleWatched(
parseInt(titleId),
parseInt(seasonId),
true,
);
}
@Get('unwatched-title/:titleId/:seasonId?')
markTitleUnwatched(
@Param('titleId') titleId: string,
@Param('seasonId') seasonId: string,
): Promise<KodiApiResponse> {
return this.allFilesService.setTitleWatched(
parseInt(titleId),
parseInt(seasonId),
false,
);
}
@Get('hide/:fileId')
hideFromContinueWatching(
@Param('fileId') fileId: string,
): Promise<KodiApiResponse> {
return this.allFilesService.hideFromContinueWatching(fileId);
}
@Get('play/:fileId') @Get('play/:fileId')
@Head('play/:fileId') @Head('play/:fileId')
play( play(
+2 -1
View File
@@ -4,9 +4,10 @@ import { AllFilesService } from './all-files.service';
import { KodiApiResponseFactory } from '../kodi-api-response.factory'; import { KodiApiResponseFactory } from '../kodi-api-response.factory';
import { StreamerModule } from '../../Streamer/streamer.module'; import { StreamerModule } from '../../Streamer/streamer.module';
import { VideoFilesModule } from '../../VideoFiles/video-files.module'; import { VideoFilesModule } from '../../VideoFiles/video-files.module';
import { WatchStateModule } from '../../WatchState/watch-state.module';
@Module({ @Module({
imports: [StreamerModule, VideoFilesModule], imports: [StreamerModule, VideoFilesModule, WatchStateModule],
controllers: [AllFilesController], controllers: [AllFilesController],
providers: [AllFilesService, KodiApiResponseFactory], providers: [AllFilesService, KodiApiResponseFactory],
exports: [AllFilesService], exports: [AllFilesService],
+193 -25
View File
@@ -9,6 +9,13 @@ import { FileEntity } from '../../Shared/Entity/file.entity';
import { TitleEntity } from '../../Shared/Entity/title.entity'; import { TitleEntity } from '../../Shared/Entity/title.entity';
import { KodiApiResponse } from '../Dto/kodi-api-response.type'; import { KodiApiResponse } from '../Dto/kodi-api-response.type';
import NotificationResponse from '../Dto/notification-response.dto'; 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() @Injectable()
export class AllFilesService { export class AllFilesService {
@@ -16,6 +23,7 @@ export class AllFilesService {
private readonly koiApiResponseFactory: KodiApiResponseFactory, private readonly koiApiResponseFactory: KodiApiResponseFactory,
private readonly streamerFacade: StreamerFacade, private readonly streamerFacade: StreamerFacade,
private readonly videoFilesFacade: VideoFilesFacade, private readonly videoFilesFacade: VideoFilesFacade,
private readonly watchStateService: WatchStateService,
) {} ) {}
getMenu(): ApiResponse { getMenu(): ApiResponse {
@@ -35,10 +43,11 @@ export class AllFilesService {
async getListOfTitles(type: TitleTypeEnum): Promise<ApiResponse> { async getListOfTitles(type: TitleTypeEnum): Promise<ApiResponse> {
const entities = await this.videoFilesFacade.getListOfTitles(type); const entities = await this.videoFilesFacade.getListOfTitles(type);
const states = await this.watchStateService.getAll();
const response = this.koiApiResponseFactory.createApiResponse(); const response = this.koiApiResponseFactory.createApiResponse();
response.setTitle('All ' + type + 's'); response.setTitle('All ' + type + 's');
entities.forEach((entity) => { entities.forEach((entity) => {
response const item = response
.createItem() .createItem()
.setLabel(entity.title) .setLabel(entity.title)
.setToFolder() .setToFolder()
@@ -46,11 +55,47 @@ export class AllFilesService {
entity.files.length + (entity.files.length > 1 ? ' files' : ' file'), entity.files.length + (entity.files.length > 1 ? ' files' : ' file'),
) )
.setPath('all/' + entity.id); .setPath('all/' + entity.id);
this.addFolderWatchState(item, entity.files, states, '' + entity.id);
}); });
return response; 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<ApiResponse> {
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<KodiApiResponse> { async loadTitle(titleId: number, seasonId: number): Promise<KodiApiResponse> {
const titleEntity = titleId const titleEntity = titleId
? await this.videoFilesFacade.getTitleWithFiles(titleId) ? await this.videoFilesFacade.getTitleWithFiles(titleId)
@@ -63,8 +108,15 @@ export class AllFilesService {
); );
} }
const states = await this.watchStateService.getAll();
if (titleEntity.type === TitleTypeEnum.movie) { if (titleEntity.type === TitleTypeEnum.movie) {
return this.buildFilesResponse(titleEntity.title, titleEntity.files); return this.buildFilesResponse(
titleEntity.title,
titleEntity.files,
false,
states,
);
} }
if (seasonId) { if (seasonId) {
@@ -73,30 +125,29 @@ export class AllFilesService {
titleEntity.files.filter( titleEntity.files.filter(
(fileEntity) => fileEntity.season === seasonId, (fileEntity) => fileEntity.season === seasonId,
), ),
false,
states,
); );
} }
return this.buildSeasonsResponse(titleEntity); return this.buildSeasonsResponse(titleEntity, states);
} }
/** in episode order */
buildFilesResponse( buildFilesResponse(
title: string, title: string,
fileEntities: Array<FileEntity>, fileEntities: Array<FileEntity>,
torrentContext = false, torrentContext = false,
states: WatchStates = new Map(),
): ApiResponse { ): ApiResponse {
const apiResponse = this.koiApiResponseFactory.createApiResponse(); const apiResponse = this.koiApiResponseFactory.createApiResponse();
apiResponse.setTitle(title); apiResponse.setTitle(title).setNoSort();
for (const fileEntity of fileEntities) { for (const fileEntity of [...fileEntities].sort(compareFiles)) {
const item = apiResponse const state = states.get(fileEntity.id);
.createItem() const item = this.addFileItem(apiResponse, fileEntity, state);
.setLabel(fileEntity.fileName)
.setPath('/api?path=all/play/' + fileEntity.id)
.setToPlayable()
.setPlot(this.buildPlot(fileEntity))
.setSize(fileEntity.size)
.setDuration(fileEntity.duration);
if (!torrentContext) { if (!torrentContext) {
this.addWatchedMenus(item, fileEntity, state);
if (fileEntity.torrentId) { if (fileEntity.torrentId) {
item.addContextMenu( item.addContextMenu(
'Show torrent', 'Show torrent',
@@ -111,29 +162,90 @@ export class AllFilesService {
return apiResponse; return apiResponse;
} }
private buildSeasonsResponse(titleEntity: TitleEntity): ApiResponse { private addFileItem(
const apiResponse = this.koiApiResponseFactory.createApiResponse(); apiResponse: ApiResponse,
apiResponse.setTitle(titleEntity.title); 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 = []; /** "Mark unwatched" also clears a resume point */
const filesCount = {}; 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 `<titleId>[/<season>]` */
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<number, FileEntity[]>();
for (const fileEntity of titleEntity.files) { for (const fileEntity of titleEntity.files) {
if (seasons.indexOf(fileEntity.season) === -1) { const seasonFiles = filesBySeason.get(fileEntity.season) ?? [];
seasons.push(fileEntity.season); seasonFiles.push(fileEntity);
filesCount[fileEntity.season] = 0; filesBySeason.set(fileEntity.season, seasonFiles);
}
filesCount[fileEntity.season]++;
} }
const seasons = [...filesBySeason.keys()].sort((a, b) => a - b);
for (const season of seasons) { for (const season of seasons) {
apiResponse const seasonFiles = filesBySeason.get(season);
const item = apiResponse
.createItem() .createItem()
.setLabel(season + ' Sezonas') .setLabel(season + ' Sezonas')
.setToFolder() .setToFolder()
.setPlot( .setPlot(
filesCount[season] + (filesCount[season] > 1 ? ' files' : ' file'), seasonFiles.length + (seasonFiles.length > 1 ? ' files' : ' file'),
) )
.setPath(`all/${titleEntity.id}/${season}`); .setPath(`all/${titleEntity.id}/${season}`);
this.addFolderWatchState(
item,
seasonFiles,
states,
`${titleEntity.id}/${season}`,
);
} }
return apiResponse; return apiResponse;
@@ -212,6 +324,62 @@ export class AllFilesService {
); );
} }
async setWatched(
fileId: string,
watched: boolean,
): Promise<NotificationResponse> {
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<NotificationResponse> {
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<NotificationResponse> {
await this.watchStateService.hide(await this.getFileId(fileId));
return this.koiApiResponseFactory.createNotificationResponse(
'Hidden from Continue watching',
);
}
private async getFileId(fileId: string): Promise<number> {
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) { async deleteFile(fileId: string) {
const id = parseInt(fileId); const id = parseInt(fileId);
const result = await this.videoFilesFacade.deleteFile(id); const result = await this.videoFilesFacade.deleteFile(id);
+45
View File
@@ -1,4 +1,5 @@
import ContextMenu from './context-menu.dto'; import ContextMenu from './context-menu.dto';
import { WatchStateEntity } from '../../Shared/Entity/watch-state.entity';
export default class ResponseItem { export default class ResponseItem {
isFolder = false; isFolder = false;
@@ -15,6 +16,16 @@ export default class ResponseItem {
contextMenus: Array<ContextMenu>; contextMenus: Array<ContextMenu>;
date: string; date: string;
searchFor: 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() { constructor() {
this.setToPlayable(); this.setToPlayable();
@@ -100,6 +111,40 @@ export default class ResponseItem {
return this; 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) { addContextMenu(name, path) {
if (!this.contextMenus) { if (!this.contextMenus) {
this.contextMenus = []; this.contextMenus = [];
+11 -4
View File
@@ -13,11 +13,18 @@ export class KodiApiService {
.createApiResponse() .createApiResponse()
.setTitle('Menu') .setTitle('Menu')
.addNavigationItems([ .addNavigationItems([
['All Files', 'all'], ['Continue watching', 'all/continue'],
['Torr', 'torr'], // the "All Files" menu, inlined
['LRT.lt', 'lrt'], ['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'], //['Test', 'test'],
]); ])
.setNoSort();
} }
getCurrentTest() { getCurrentTest() {
+38
View File
@@ -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;
}
+48
View File
@@ -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);
}
+37
View File
@@ -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<string, unknown>): 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,
);
}
}
+24
View File
@@ -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;
}
}
+38
View File
@@ -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<PlaybackStateDto> {
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<string, unknown>,
): Promise<PlaybackStateDto> {
const state = await this.watchStateService.report(
parseInt(fileId),
PlaybackReport.fromBody(body),
);
return PlaybackStateDto.fromEntity(state);
}
}
+14
View File
@@ -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 {}
+275
View File
@@ -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<number, WatchStateEntity>;
/**
* 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<WatchStateEntity>,
@InjectRepository(FileEntity)
private readonly fileRepository: Repository<FileEntity>,
) {}
/** by file id; there are rows only for files that were played or marked */
async getAll(): Promise<WatchStates> {
const states = await this.watchStateRepository.find();
return new Map(states.map((state) => [state.fileId, state]));
}
async get(fileId: number): Promise<WatchStateEntity> {
await this.getFile(fileId);
return (
(await this.watchStateRepository.findOneBy({ fileId })) ??
this.create(fileId)
);
}
async report(
fileId: number,
report: PlaybackReport,
): Promise<WatchStateEntity> {
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<void> {
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<void> {
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<string, boolean>();
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<FileEntity[]> {
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<number, FileEntity[]>();
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<FileEntity, number>();
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<string, FileEntity[]>();
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<FileEntity> {
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;
}
}
+9 -3
View File
@@ -8,13 +8,19 @@ export class PathsConfig {
public getStaticRequestsLogPath(): string { public getStaticRequestsLogPath(): string {
return join( return join(
'/srv/data/requests-log', this.getDataRoot(),
'requests-log',
this.configService.getEnv('LOG_FILE_REQUESTS_NAME'), this.configService.getEnv('LOG_FILE_REQUESTS_NAME'),
); );
} }
public getStaticFolder(): string { 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) { public getPathByEnvKey(key: string) {
@@ -24,7 +30,7 @@ export class PathsConfig {
} }
public getDbFolderPath(): string { public getDbFolderPath(): string {
return '/srv/data/db'; return join(this.getDataRoot(), 'db');
} }
private getPath(relPath: string) { private getPath(relPath: string) {
+2 -1
View File
@@ -1,4 +1,5 @@
import { ConfigService } from './config.service'; import { ConfigService } from './config.service';
import { join } from 'path';
export class RecentSearchesConfig { export class RecentSearchesConfig {
constructor(private readonly configService: ConfigService) { constructor(private readonly configService: ConfigService) {
@@ -6,7 +7,7 @@ export class RecentSearchesConfig {
} }
public getRecentSearchesFolder(): string { public getRecentSearchesFolder(): string {
return '/srv/data/recent-searches'; return join(this.configService.getPaths().getDataRoot(), 'recent-searches');
} }
public getRecentSearchesLimit(): number { public getRecentSearchesLimit(): number {
+8 -1
View File
@@ -4,6 +4,7 @@ import { EntityClassOrSchema } from '@nestjs/typeorm/dist/interfaces/entity-clas
import { TitleEntity } from '../Shared/Entity/title.entity'; import { TitleEntity } from '../Shared/Entity/title.entity';
import { FileEntity } from '../Shared/Entity/file.entity'; import { FileEntity } from '../Shared/Entity/file.entity';
import { TorrentEntity } from '../Shared/Entity/torrent.entity'; import { TorrentEntity } from '../Shared/Entity/torrent.entity';
import { WatchStateEntity } from '../Shared/Entity/watch-state.entity';
import { join } from 'path'; import { join } from 'path';
export class TypeOrmConfig { export class TypeOrmConfig {
@@ -13,7 +14,13 @@ export class TypeOrmConfig {
configService.getPaths().getDbFolderPath(), configService.getPaths().getDbFolderPath(),
configService.getEnv('DB_FILENAME'), configService.getEnv('DB_FILENAME'),
); );
this.entities = [LrtCategory, TitleEntity, FileEntity, TorrentEntity]; this.entities = [
LrtCategory,
TitleEntity,
FileEntity,
TorrentEntity,
WatchStateEntity,
];
} }
type: 'sqlite'; type: 'sqlite';
database: string; database: string;