mirror of
https://github.com/sakaljurgis/kodi-api-server.git
synced 2026-11-06 18:58:01 +00:00
Compare commits
2
Commits
02522bbd04
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
82cb2ea760 | ||
|
|
be7d0bc535 |
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);
|
||||
}
|
||||
|
||||
@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')
|
||||
@Head('play/:fileId')
|
||||
play(
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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<ApiResponse> {
|
||||
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<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> {
|
||||
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<FileEntity>,
|
||||
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 {
|
||||
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);
|
||||
}
|
||||
|
||||
/** "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 `<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);
|
||||
apiResponse.setTitle(titleEntity.title).setNoSort();
|
||||
|
||||
const seasons = [];
|
||||
const filesCount = {};
|
||||
const filesBySeason = new Map<number, FileEntity[]>();
|
||||
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<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) {
|
||||
const id = parseInt(fileId);
|
||||
const result = await this.videoFilesFacade.deleteFile(id);
|
||||
|
||||
@@ -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<ContextMenu>;
|
||||
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 = [];
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -0,0 +1,283 @@
|
||||
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;
|
||||
/** Continue watching only goes this far back, older activity doesn't crowd it */
|
||||
const CONTINUE_WATCHING_MONTHS = 3;
|
||||
|
||||
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. Only titles played in the last CONTINUE_WATCHING_MONTHS;
|
||||
* 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 since = new Date();
|
||||
since.setMonth(since.getMonth() - CONTINUE_WATCHING_MONTHS);
|
||||
|
||||
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 >= :since
|
||||
)`,
|
||||
{ since: since.getTime() },
|
||||
)
|
||||
.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,
|
||||
);
|
||||
// (the recent activity may have been on a file deleted since)
|
||||
if (lastPlayed(last) < since.getTime()) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user