feat: save program media versions to DB (#1379)

This commit is contained in:
Christian Benincasa
2025-09-19 11:53:33 -04:00
committed by GitHub
parent 4a53eba59d
commit b7b9d914c2
57 changed files with 5373 additions and 1138 deletions

File diff suppressed because one or more lines are too long

View File

@@ -6,6 +6,6 @@ export default defineConfig({
out: './src/migration/db/sql',
casing: 'snake_case',
dbCredentials: {
url: process.env.TUNARR_DATABASE_PATH,
url: process.env.TUNARR_DATABASE_PATH!,
},
});

View File

@@ -395,7 +395,6 @@ export const debugApi: RouterPluginAsyncCallback = async (fastify) => {
const pool = container.get(TunarrWorkerPool);
const response = await pool.queueTask({ type: 'status' });
console.log(response);
return res.send(response);
},
);
@@ -414,7 +413,6 @@ export const debugApi: RouterPluginAsyncCallback = async (fastify) => {
pool.queueTask({ type: 'restart', code: 1 }),
new Promise((_, rej) => setTimeout(() => rej(new Error('')), 5_000)),
]);
console.log(response);
return res.send(response);
},
);

View File

@@ -543,7 +543,6 @@ export const mediaSourceRouter: RouterPluginAsyncCallback = async (
}, 60000);
}),
]);
console.log(status);
return res.send(status);
},

View File

@@ -21,6 +21,7 @@ import {
} from '@/util/index.js';
import { LoggerFactory } from '@/util/logging/LoggerFactory.js';
import { seq } from '@tunarr/shared/util';
import type { ProgramGrouping } from '@tunarr/types';
import {
tag,
type Episode,
@@ -28,7 +29,6 @@ import {
type MusicAlbum,
type MusicArtist,
type MusicTrack,
type ProgramGrouping,
type Season,
type Show,
type TerminalProgram,
@@ -40,7 +40,10 @@ import {
ProgramSearchResponse,
SearchFilterQuerySchema,
} from '@tunarr/types/api';
import { ContentProgramSchema } from '@tunarr/types/schemas';
import {
ContentProgramSchema,
TerminalProgramSchema,
} from '@tunarr/types/schemas';
import axios, { AxiosHeaders, isAxiosError } from 'axios';
import dayjs from 'dayjs';
import type { HttpHeader } from 'fastify/types/utils.js';
@@ -66,15 +69,13 @@ import {
programSourceTypeFromString,
} from '../db/custom_types/ProgramSourceType.ts';
import type { ProgramGroupingChildCounts } from '../db/interfaces/IProgramDB.ts';
import {
AllProgramFields,
selectProgramsBuilder,
} from '../db/programQueryHelpers.ts';
import { AllProgramFields } from '../db/programQueryHelpers.ts';
import type { MediaSourceId } from '../db/schema/base.ts';
import type {
MediaSourceWithLibraries,
ProgramWithRelations,
} from '../db/schema/derivedTypes.js';
import type { DrizzleDBAccess } from '../db/schema/index.ts';
import type {
ProgramGroupingSearchDocument,
ProgramSearchDocument,
@@ -83,6 +84,7 @@ import type {
import { decodeCaseSensitiveId } from '../services/MeilisearchService.ts';
import { FfprobeStreamDetails } from '../stream/FfprobeStreamDetails.ts';
import { ExternalStreamDetailsFetcherFactory } from '../stream/StreamDetailsFetcher.ts';
import { KEYS } from '../types/inject.ts';
import type { Path } from '../types/path.ts';
import type { Maybe } from '../types/util.ts';
@@ -288,6 +290,7 @@ function convertProgramGroupingSearchResult(
({
...season,
...base,
type: 'season',
identifiers,
uuid,
canonicalId: grouping.canonicalId!,
@@ -412,13 +415,11 @@ export const programmingApi: RouterPluginAsyncCallback = async (fastify) => {
const mediaSourceId = decodeCaseSensitiveId(program.mediaSourceId);
const mediaSource = allMediaSourcesById[mediaSourceId];
if (!mediaSource) {
console.log('no media src');
return;
}
const libraryId = decodeCaseSensitiveId(program.libraryId);
const library = allLibrariesById[libraryId];
if (!library) {
console.log('no library');
return;
}
@@ -442,8 +443,6 @@ export const programmingApi: RouterPluginAsyncCallback = async (fastify) => {
);
}
console.log('here');
return;
});
@@ -590,24 +589,47 @@ export const programmingApi: RouterPluginAsyncCallback = async (fastify) => {
schema: {
tags: ['Programs'],
params: BasicIdParamSchema,
response: {
200: TerminalProgramSchema,
404: z.void(),
500: z.void(),
},
},
},
async (req, res) => {
return res.send(
req.serverCtx.programConverter.programDaoToContentProgram(
await selectProgramsBuilder(req.serverCtx.databaseFactory(), {
joins: {
tvSeason: true,
tvShow: true,
trackAlbum: true,
trackArtist: true,
const db = container.get<DrizzleDBAccess>(KEYS.DrizzleDB);
const dbRes = await db.query.program.findFirst({
where: (program, { eq }) => eq(program.uuid, req.params.id),
with: {
season: true,
show: true,
album: true,
artist: true,
externalIds: true,
mediaLibrary: true,
versions: {
with: {
mediaStreams: true,
chapters: true,
},
})
.where('uuid', '=', req.params.id)
.executeTakeFirstOrThrow(),
[],
),
);
},
},
});
if (!dbRes) {
return res.status(404).send();
}
if (dbRes.mediaSourceId && dbRes.libraryId && dbRes.canonicalId) {
const converted =
req.serverCtx.programConverter.programDaoToTerminalProgram(dbRes);
if (!converted) {
return res.status(404).send();
}
return res.send(converted);
}
},
);

View File

@@ -631,13 +631,11 @@ export class ChannelDB implements IChannelDB {
priority: pref.priority,
}) satisfies NewChannelSubtitlePreference,
);
console.log(updateReq.subtitlePreferences);
await tx
.deleteFrom('channelSubtitlePreferences')
.where('channelSubtitlePreferences.channelId', '=', channel.uuid)
.executeTakeFirstOrThrow();
if (subtitlePreferences) {
console.log('inserting subtitle');
await tx
.insertInto('channelSubtitlePreferences')
.values(subtitlePreferences)

View File

@@ -4,10 +4,7 @@ import { LoggerFactory } from '@/util/logging/LoggerFactory.js';
import { Mutex } from 'async-mutex';
import Sqlite from 'better-sqlite3';
import dayjs from 'dayjs';
import {
drizzle,
type BetterSQLite3Database,
} from 'drizzle-orm/better-sqlite3';
import { drizzle } from 'drizzle-orm/better-sqlite3';
import {
CamelCasePlugin,
Kysely,
@@ -24,6 +21,8 @@ import {
import type { Maybe } from '../types/util.ts';
import { getDefaultDatabaseName } from '../util/defaults.ts';
import type { DB } from './schema/db.ts';
import type { DrizzleDBAccess } from './schema/index.ts';
import { schema } from './schema/index.ts';
const lock = new Mutex();
@@ -34,7 +33,7 @@ class Connection {
private logger!: Logger;
readonly kysely!: Kysely<DB>;
readonly drizzle!: BetterSQLite3Database;
readonly drizzle!: DrizzleDBAccess;
readonly sqlite!: Sqlite.Database;
constructor(readonly name: string) {
@@ -90,6 +89,7 @@ class Connection {
this.drizzle = drizzle({
client: this.sqlite,
casing: 'snake_case',
schema,
});
}
@@ -268,6 +268,10 @@ export class DBAccess {
return this.getKyselyDatabase();
}
get drizzle(): Maybe<DrizzleDBAccess> {
return this.getConnection()?.drizzle;
}
getConnection(name: string = getDefaultDatabaseName()): Maybe<Connection> {
return DBAccess.connections.get(name);
}

View File

@@ -10,6 +10,7 @@ import { DBAccess } from './DBAccess.ts';
import { FillerDB } from './FillerListDB.ts';
import { ProgramDaoMinter } from './converters/ProgramMinter.ts';
import type { DB } from './schema/db.ts';
import type { DrizzleDBAccess } from './schema/index.ts';
const DBModule = new ContainerModule((bind) => {
bind<IProgramDB>(KEYS.ProgramDB).to(ProgramDB).inSingletonScope();
@@ -18,6 +19,9 @@ const DBModule = new ContainerModule((bind) => {
bind<Kysely<DB>>(KEYS.Database)
.toDynamicValue((ctx) => ctx.container.get(DBAccess).db!)
.whenTargetIsDefault();
bind<DrizzleDBAccess>(KEYS.DrizzleDB)
.toDynamicValue((ctx) => ctx.container.get(DBAccess).drizzle!)
.whenTargetIsDefault();
bind<interfaces.Factory<Kysely<DB>>>(KEYS.DatabaseFactory).toAutoFactory(
KEYS.Database,
);

View File

@@ -3,6 +3,7 @@ import type {
IProgramDB,
ProgramGroupingChildCounts,
ProgramGroupingExternalIdLookup,
ProgramUpsertRequest,
WithChannelIdFilter,
} from '@/db/interfaces/IProgramDB.js';
import { GlobalScheduler } from '@/services/Scheduler.js';
@@ -83,6 +84,7 @@ import {
mapToObj,
programExternalIdString,
run,
unzip,
} from '../util/index.ts';
import { ProgramGroupingMinter } from './converters/ProgramGroupingMinter.ts';
import { ProgramDaoMinter } from './converters/ProgramMinter.ts';
@@ -112,6 +114,7 @@ import {
ProgramType,
ProgramDao as RawProgram,
} from './schema/Program.ts';
import { NewProgramChapter, ProgramChapter } from './schema/ProgramChapter.ts';
import {
MinimalProgramExternalId,
NewProgramExternalId,
@@ -133,12 +136,17 @@ import {
ProgramGroupingExternalIdFieldsWithAlias,
toInsertableProgramGroupingExternalId,
} from './schema/ProgramGroupingExternalId.ts';
import {
NewProgramMediaStream,
ProgramMediaStream,
} from './schema/ProgramMediaStream.ts';
import { ProgramVersion } from './schema/ProgramVersion.ts';
import { MediaSourceId, MediaSourceName } from './schema/base.ts';
import { DB } from './schema/db.ts';
import type {
MusicAlbumWithExternalIds,
NewProgramGroupingWithExternalIds,
NewProgramWithExternalIds,
NewProgramVersion,
ProgramGroupingWithExternalIds,
ProgramWithExternalIds,
ProgramWithRelations,
@@ -881,27 +889,36 @@ export class ProgramDB implements IProgramDB {
}
async upsertPrograms(
programs: NewProgramWithExternalIds[],
requests: ProgramUpsertRequest[],
programUpsertBatchSize: number = 100,
) {
if (isEmpty(programs)) {
if (isEmpty(requests)) {
return [];
}
const db = this.db;
// Group related items by canonicalId because the UUID we get back
// from the upsert may not be the one we generated (if an existing entry)
// already exists
const externalIdsByProgramCanonicalId = groupByFunc(
programs,
(program) => program.canonicalId ?? programExternalIdString(program),
requests,
({ program }) => program.canonicalId,
(program) => program.externalIds,
);
const programVersionsByCanonicalId = groupByFunc(
requests,
({ program }) => program.canonicalId,
(request) => request.versions,
);
return await Promise.all(
chunk(programs, programUpsertBatchSize).map(async (c) => {
chunk(requests, programUpsertBatchSize).map(async (c) => {
const chunkResult = await db.transaction().execute((tx) =>
tx
.insertInto('program')
.values(c.map((program) => omit(program, 'externalIds')))
.values(c.map(({ program }) => program))
.onConflict((oc) =>
oc
.columns(['sourceType', 'mediaSourceId', 'externalKey'])
@@ -912,13 +929,15 @@ export class ProgramDB implements IProgramDB {
),
)
.returningAll()
.$narrowType<{ mediaSourceId: NotNull }>()
// All new programs must have mediaSourceId and canonicalId. This is enforced
// by the NewProgramDao type
.$narrowType<{ mediaSourceId: NotNull; canonicalId: NotNull }>()
.execute(),
);
const allExternalIds = flatten(c.map((program) => program.externalIds));
for (const program of chunkResult) {
const key = program.canonicalId ?? programExternalIdString(program);
const key = program.canonicalId;
const eids = externalIdsByProgramCanonicalId[key] ?? [];
for (const eid of eids) {
eid.programUuid = program.uuid;
@@ -928,6 +947,17 @@ export class ProgramDB implements IProgramDB {
const externalIdsByProgramId =
await this.upsertProgramExternalIds(allExternalIds);
const versionsToInsert: NewProgramVersion[] = [];
for (const program of chunkResult) {
for (const version of programVersionsByCanonicalId[
program.canonicalId
] ?? []) {
version.programId = program.uuid;
versionsToInsert.push(version);
}
}
await this.upsertProgramVersions(versionsToInsert);
return chunkResult.map(
(upsertedProgram) =>
({
@@ -939,6 +969,102 @@ export class ProgramDB implements IProgramDB {
).then(flatten);
}
private async upsertProgramVersions(versions: NewProgramVersion[]) {
if (versions.length === 0) {
this.logger.warn('No program versions passed for item');
return [];
}
const insertedVersions: ProgramVersion[] = [];
await this.db.transaction().execute(async (tx) => {
const byProgramId = groupByUniq(versions, (version) => version.programId);
for (const batch of chunk(Object.entries(byProgramId), 50)) {
const [programIds, versionBatch] = unzip(batch);
// We probably need to delete here, because we never really delete
// programs on the upsert path.
await tx
.deleteFrom('programVersion')
.where('programId', 'in', programIds)
.executeTakeFirstOrThrow();
const insertResult = await tx
.insertInto('programVersion')
.values(
versionBatch.map((version) =>
omit(version, ['chapters', 'mediaStreams']),
),
)
.returningAll()
.execute();
await Promise.all([
this.upsertProgramMediaStreams(
versionBatch.flatMap(({ mediaStreams }) => mediaStreams),
tx,
),
this.upsertProgramChapters(
versionBatch.flatMap(({ chapters }) => chapters ?? []),
tx,
),
]);
insertedVersions.push(...insertResult);
}
});
return insertedVersions;
}
private async upsertProgramMediaStreams(
streams: NewProgramMediaStream[],
tx: Kysely<DB> = this.db,
) {
if (streams.length === 0) {
this.logger.warn('No media streams passed for version');
return [];
}
const byVersionId = groupBy(streams, (stream) => stream.programVersionId);
const inserted: ProgramMediaStream[] = [];
for (const batch of chunk(Object.entries(byVersionId), 50)) {
const [_, streams] = unzip(batch);
// TODO: Do we need to delete first?
// await tx.deleteFrom('programMediaStream').where('programVersionId', 'in', versionIds).executeTakeFirstOrThrow();
inserted.push(
...(await tx
.insertInto('programMediaStream')
.values(flatten(streams))
.returningAll()
.execute()),
);
}
return inserted;
}
private async upsertProgramChapters(
chapters: NewProgramChapter[],
tx: Kysely<DB> = this.db,
) {
if (chapters.length === 0) {
return [];
}
const byVersionId = groupBy(chapters, (stream) => stream.programVersionId);
const inserted: ProgramChapter[] = [];
for (const batch of chunk(Object.entries(byVersionId), 50)) {
const [_, streams] = unzip(batch);
// TODO: Do we need to delete first?
// await tx.deleteFrom('programMediaStream').where('programVersionId', 'in', versionIds).executeTakeFirstOrThrow();
inserted.push(
...(await tx
.insertInto('programChapter')
.values(flatten(streams))
.returningAll()
.execute()),
);
}
return inserted;
}
async upsertProgramExternalIds(
externalIds: NewSingleOrMultiExternalId[],
chunkSize: number = 100,

View File

@@ -9,23 +9,30 @@ import {
ChannelProgram,
ContentProgram,
ContentProgramParent,
Episode,
ExternalId,
FlexProgram,
Identifier,
MediaStream,
MusicAlbumContentProgram,
MusicArtistContentProgram,
RedirectProgram,
TerminalProgram,
TvSeasonContentProgram,
TvShowContentProgram,
untag,
} from '@tunarr/types';
import {
isValidMultiExternalIdType,
isValidSingleExternalIdType,
} from '@tunarr/types/schemas';
import dayjs from 'dayjs';
import { inject, injectable } from 'inversify';
import { Kysely } from 'kysely';
import { find, isNil, omitBy } from 'lodash-es';
import { find, first, isNil, omitBy, orderBy } from 'lodash-es';
import { isPromise } from 'node:util/types';
import { DeepNullable, DeepPartial, MarkRequired } from 'ts-essentials';
import { match } from 'ts-pattern';
import { MarkNonNullable, Nullable } from '../../types/util.ts';
import {
LineupItem,
@@ -43,6 +50,7 @@ import type {
MusicAlbumWithExternalIds,
MusicArtistWithExternalIds,
ProgramWithRelations,
ProgramWithRelationsOrm,
TvSeasonWithExternalIds,
TvShowWithExternalIds,
} from '../schema/derivedTypes.ts';
@@ -92,7 +100,6 @@ export class ProgramConverter {
}
return this.redirectLineupItemToProgram(item, redirectChannel);
} else if (item.type === 'content') {
console.log(channel.programs);
const program =
preMaterializedProgram && preMaterializedProgram.uuid === item.id
? preMaterializedProgram
@@ -130,6 +137,110 @@ export class ProgramConverter {
return this.programDaoToContentProgram(program);
}
programDaoToTerminalProgram(
program: ProgramWithRelationsOrm,
): TerminalProgram | null {
if (
!program.mediaSourceId ||
!program.externalIds ||
!program.canonicalId ||
!program.libraryId
) {
this.logger.error(
'Program missing critical fields. Aborting: %O',
program,
);
return null;
}
const base = {
...program,
type: program.type,
mediaSourceId: untag(program.mediaSourceId),
canonicalId: program.canonicalId,
libraryId: program.libraryId,
externalId: program.externalKey,
externalLibraryId: program.mediaLibrary?.externalKey ?? '',
identifiers: program.externalIds.map(
(eid) =>
({
id: eid.externalKey,
type: eid.sourceType,
sourceId: eid.externalSourceId ?? undefined,
}) satisfies Identifier,
),
tags: [],
originalTitle: null,
releaseDate: program.originalAirDate
? +dayjs(program.originalAirDate)
: null,
releaseDateString: program.originalAirDate,
};
const typed = match(program)
.returnType<TerminalProgram>()
.with({ type: 'movie' }, (movie) => ({
...base,
type: 'movie',
plot: movie.summary,
tagline: null,
}))
.with(
{ type: 'episode' },
(episode) =>
({
...base,
type: 'episode' as const,
summary: episode.summary,
episodeNumber: episode.episode ?? 0,
season: undefined,
}) satisfies Episode,
)
.with({ type: 'track' }, (track) => ({
...base,
type: 'track',
trackNumber: track.episode ?? 0,
album: undefined, // TODO:
}))
.with({ type: 'music_video' }, () => ({ ...base, type: 'music_video' }))
.with({ type: 'other_video' }, () => ({ ...base, type: 'other_video' }))
.exhaustive();
const version = first(program.versions);
if (version) {
typed.mediaItem = {
...version,
streams: orderBy(
version.mediaStreams?.map(
(stream) =>
({
...stream,
default: stream.default,
streamType: stream.streamKind,
}) satisfies MediaStream,
) ?? [],
'index',
'asc',
),
chapters: orderBy(
version.chapters,
(c) => [
match(c.chapterType)
.with('chapter', () => 0)
.with('intro', () => 1)
.with('outro', () => 2)
.exhaustive(),
c.index,
],
['asc', 'asc'],
),
locations: [],
};
}
return typed;
}
programDaoToContentProgram(
program: MarkNonNullable<ProgramWithRelations, 'mediaSourceId'>,
externalIds?: MinimalProgramExternalId[],

View File

@@ -5,7 +5,13 @@ import type {
NewSingleOrMultiExternalId,
} from '@/db/schema/ProgramExternalId.js';
import { seq } from '@tunarr/shared/util';
import { tag, type ContentProgram } from '@tunarr/types';
import {
isTerminalItemType,
ProgramLike,
tag,
TerminalProgram,
type ContentProgram,
} from '@tunarr/types';
import type { JellyfinItem } from '@tunarr/types/jellyfin';
import type {
PlexMovie as ApiPlexMovie,
@@ -22,7 +28,7 @@ import {
import dayjs from 'dayjs';
import { inject, injectable } from 'inversify';
import { find, first, head, isError } from 'lodash-es';
import { P, match } from 'ts-pattern';
import { match, P } from 'ts-pattern';
import { v4 } from 'uuid';
import { Canonicalizer } from '../../services/Canonicalizer.ts';
import {
@@ -35,18 +41,19 @@ import { Maybe } from '../../types/util.ts';
import { parsePlexGuid } from '../../util/externalIds.ts';
import { isNonEmptyString } from '../../util/index.ts';
import { Logger } from '../../util/logging/LoggerFactory.ts';
import { booleanToNumber } from '../../util/sqliteUtil.ts';
import { MediaSource, MediaSourceLibrary } from '../schema/MediaSource.ts';
import type {
NewProgramDao,
NewProgramDao as NewRawProgram,
} from '../schema/Program.ts';
import type { NewProgramDao } from '../schema/Program.ts';
import { ProgramType } from '../schema/Program.ts';
import { NewProgramMediaStream } from '../schema/ProgramMediaStream.ts';
import { MediaSourceId, MediaSourceName } from '../schema/base.ts';
import {
NewEpisodeProgram,
NewMovieProgram,
NewMusicTrack,
NewProgramVersion,
NewProgramWithExternalIds,
NewProgramWithRelations,
} from '../schema/derivedTypes.js';
// type MovieMintRequest =
@@ -72,7 +79,7 @@ export class ProgramDaoMinter {
private jellyfinCanonicalizer: Canonicalizer<JellyfinItem>,
) {}
contentProgramDtoToDao(program: ContentProgram): Maybe<NewRawProgram> {
contentProgramDtoToDao(program: ContentProgram): Maybe<NewProgramDao> {
if (!isNonEmptyString(program.canonicalId)) {
this.logger.warn('Program missing canonical ID on upsert: %O', program);
return;
@@ -167,11 +174,10 @@ export class ProgramDaoMinter {
mediaSource: MediaSource,
mediaLibrary: MediaSourceLibrary,
movie: MediaSourceMovie,
): NewMovieProgram {
): NewProgramWithRelations<'movie'> {
const programId = v4();
const now = +dayjs();
return {
const newMovie = {
uuid: programId,
sourceType: movie.sourceType,
externalKey: movie.externalKey,
@@ -191,55 +197,133 @@ export class ProgramDaoMinter {
createdAt: now,
updatedAt: now,
canonicalId: movie.canonicalId,
externalIds: seq.collect(movie.identifiers, (id) => {
if (isNonEmptyString(id.id) && isValidSingleExternalIdType(id.type)) {
return {
type: 'single',
externalKey: id.id,
programUuid: programId,
sourceType: id.type,
uuid: v4(),
createdAt: now,
updatedAt: now,
} satisfies NewSingleOrMultiExternalId;
} else if (isValidMultiExternalIdType(id.type)) {
const isMediaSourceId = id.type === mediaSource.type;
// This stinks
const location = isMediaSourceId
? find(movie.mediaItem?.locations, { sourceType: mediaSource.type })
: null;
return {
type: 'multi',
externalKey: id.id,
programUuid: programId,
sourceType: id.type,
uuid: v4(),
createdAt: now,
updatedAt: now,
externalSourceId: mediaSource.name, // legacy
mediaSourceId: mediaSource.uuid, // new
// TODO
directFilePath: location?.path,
externalFilePath:
location?.type === 'remote' ? location.externalKey : null,
// externalFilePath
} satisfies NewSingleOrMultiExternalId;
}
} satisfies NewMovieProgram;
return;
}),
return {
program: newMovie,
externalIds: this.mintExternalIdsNew(programId, movie, mediaSource, now),
versions: this.mintVersions(programId, movie, now),
};
}
mintVersions(
programId: string,
item: TerminalProgram,
now: number = +dayjs(),
) {
const versions: NewProgramVersion[] = [];
if (item.mediaItem) {
const versionId = v4();
const streams = item.mediaItem.streams.map((stream) => {
return {
codec: stream.codec,
index: stream.index,
profile: stream.profile,
programVersionId: versionId,
streamKind: stream.streamType,
uuid: v4(),
bitsPerSample: stream.bitDepth,
channels: stream.channels,
// TODO: color
default: booleanToNumber(stream.default ?? false),
//TODO: forced: stream.forced
language: stream.languageCodeISO6392,
pixelFormat: stream.pixelFormat,
title: stream.title,
} satisfies NewProgramMediaStream;
});
const version: NewProgramVersion = {
uuid: versionId,
createdAt: now,
updatedAt: now,
programId,
displayAspectRatio: item.mediaItem.displayAspectRatio,
duration: item.mediaItem.duration,
frameRate: match(item.mediaItem.frameRate)
.with(P.string, (str) => str)
.with(P.number, (num) => num.toString())
.with(P.nullish, (nil) => nil)
.exhaustive(),
sampleAspectRatio: item.mediaItem.sampleAspectRatio,
height: item.mediaItem.resolution?.heightPx,
width: item.mediaItem.resolution?.widthPx,
mediaStreams: streams,
chapters: item.mediaItem.chapters?.map((chapter) => {
return {
index: chapter.index,
programVersionId: versionId,
chapterType: chapter.chapterType,
uuid: v4(),
title: chapter.title,
startTime: chapter.startTime,
endTime: chapter.endTime,
};
}),
// TODO: scanKind: movie.mediaItem.
};
versions.push(version);
}
return versions;
}
mintExternalIdsNew(
programId: string,
item: ProgramLike,
mediaSource: MediaSource,
now: number = +dayjs(),
) {
return seq.collect(item.identifiers, (id) => {
if (isNonEmptyString(id.id) && isValidSingleExternalIdType(id.type)) {
return {
type: 'single',
externalKey: id.id,
programUuid: programId,
sourceType: id.type,
uuid: v4(),
createdAt: now,
updatedAt: now,
} satisfies NewSingleOrMultiExternalId;
} else if (isValidMultiExternalIdType(id.type)) {
const isMediaSourceId = id.type === mediaSource.type;
// This stinks
const location =
isMediaSourceId && isTerminalItemType(item)
? find(item.mediaItem?.locations, { sourceType: mediaSource.type })
: null;
return {
type: 'multi',
externalKey: id.id,
programUuid: programId,
sourceType: id.type,
uuid: v4(),
createdAt: now,
updatedAt: now,
externalSourceId: mediaSource.name, // legacy
mediaSourceId: mediaSource.uuid, // new
// TODO
directFilePath: location?.path,
externalFilePath:
location?.type === 'remote' ? location.externalKey : null,
// externalFilePath
} satisfies NewSingleOrMultiExternalId;
}
return;
});
}
mintEpisode(
mediaSource: MediaSource,
mediaLibrary: MediaSourceLibrary,
episode: MediaSourceEpisode,
): NewEpisodeProgram {
): NewProgramWithRelations<'episode'> {
const programId = v4();
const now = +dayjs();
return {
const newEpisode = {
uuid: programId,
sourceType: episode.sourceType,
externalKey: episode.externalKey,
@@ -259,45 +343,18 @@ export class ProgramDaoMinter {
createdAt: now,
updatedAt: now,
canonicalId: episode.canonicalId,
externalIds: seq.collect(episode.identifiers, (id) => {
if (isNonEmptyString(id.id) && isValidSingleExternalIdType(id.type)) {
return {
type: 'single',
externalKey: id.id,
programUuid: programId,
sourceType: id.type,
uuid: v4(),
createdAt: now,
updatedAt: now,
} satisfies NewSingleOrMultiExternalId;
} else if (isValidMultiExternalIdType(id.type)) {
const isMediaSourceId = id.type === mediaSource.type;
// This stinks
const location = isMediaSourceId
? find(episode.mediaItem?.locations, {
sourceType: mediaSource.type,
})
: null;
return {
type: 'multi',
externalKey: id.id,
programUuid: programId,
sourceType: id.type,
uuid: v4(),
createdAt: now,
updatedAt: now,
externalSourceId: mediaSource.name, // legacy
mediaSourceId: mediaSource.uuid, // new
// TODO
directFilePath: location?.path,
externalFilePath:
location?.type === 'remote' ? location.externalKey : null,
// externalFilePath
} satisfies NewSingleOrMultiExternalId;
}
episode: episode.episodeNumber,
} satisfies NewEpisodeProgram;
return;
}),
return {
program: newEpisode,
externalIds: this.mintExternalIdsNew(
programId,
episode,
mediaSource,
now,
),
versions: this.mintVersions(programId, episode, now),
};
}
@@ -305,11 +362,11 @@ export class ProgramDaoMinter {
mediaSource: MediaSource,
mediaLibrary: MediaSourceLibrary,
track: MediaSourceMusicTrack,
): NewMusicTrack {
): NewProgramWithRelations<'track'> {
const programId = v4();
const now = +dayjs();
return {
const newTrack = {
uuid: programId,
sourceType: track.sourceType,
externalKey: track.externalKey,
@@ -329,45 +386,12 @@ export class ProgramDaoMinter {
createdAt: now,
updatedAt: now,
canonicalId: track.canonicalId,
externalIds: seq.collect(track.identifiers, (id) => {
if (isNonEmptyString(id.id) && isValidSingleExternalIdType(id.type)) {
return {
type: 'single',
externalKey: id.id,
programUuid: programId,
sourceType: id.type,
uuid: v4(),
createdAt: now,
updatedAt: now,
} satisfies NewSingleOrMultiExternalId;
} else if (isValidMultiExternalIdType(id.type)) {
const isMediaSourceId = id.type === mediaSource.type;
// This stinks
const location = isMediaSourceId
? find(track.mediaItem?.locations, {
sourceType: mediaSource.type,
})
: null;
return {
type: 'multi',
externalKey: id.id,
programUuid: programId,
sourceType: id.type,
uuid: v4(),
createdAt: now,
updatedAt: now,
externalSourceId: mediaSource.name, // legacy
mediaSourceId: mediaSource.uuid, // new
// TODO
directFilePath: location?.path,
externalFilePath:
location?.type === 'remote' ? location.externalKey : null,
// externalFilePath
} satisfies NewSingleOrMultiExternalId;
}
} satisfies NewMusicTrack;
return;
}),
return {
program: newTrack,
externalIds: this.mintExternalIdsNew(programId, track, mediaSource, now),
versions: this.mintVersions(programId, track, now),
};
}
@@ -491,7 +515,7 @@ export class ProgramDaoMinter {
mediaSource: MediaSource,
mediaLibrary: MediaSourceLibrary,
plexTrack: PlexMusicTrack,
): NewRawProgram {
): NewProgramDao {
const file = first(first(plexTrack.Media)?.Part ?? []);
return {
uuid: v4(),

View File

@@ -1,6 +1,10 @@
import type { ProgramExternalIdType } from '@/db/custom_types/ProgramExternalIdType.js';
import type { ProgramSourceType } from '@/db/custom_types/ProgramSourceType.js';
import type { ProgramDao, ProgramType } from '@/db/schema/Program.js';
import type {
NewProgramDao,
ProgramDao,
ProgramType,
} from '@/db/schema/Program.js';
import type {
MinimalProgramExternalId,
NewProgramExternalId,
@@ -11,7 +15,7 @@ import type { ProgramExternalIdSourceType } from '@/db/schema/base.js';
import type {
MusicAlbumWithExternalIds,
NewProgramGroupingWithExternalIds,
NewProgramWithExternalIds,
NewProgramVersion,
ProgramGroupingWithExternalIds,
ProgramWithExternalIds,
ProgramWithRelations,
@@ -132,7 +136,7 @@ export interface IProgramDB {
): Promise<MarkNonNullable<ProgramDao, 'mediaSourceId'>[]>;
upsertPrograms(
programs: NewProgramWithExternalIds[],
programs: ProgramUpsertRequest[],
programUpsertBatchSize?: number,
): Promise<ProgramWithExternalIds[]>;
@@ -222,3 +226,9 @@ export type ProgramGroupingChildCounts = {
childCount?: number;
grandchildCount?: number;
};
export type ProgramUpsertRequest = {
program: NewProgramDao;
externalIds: NewSingleOrMultiExternalId[];
versions: NewProgramVersion[];
};

View File

@@ -249,6 +249,7 @@ export type ProgramJoins = {
tvShow: boolean | ProgramGroupingFields;
tvSeason: boolean | ProgramGroupingFields;
customShows: boolean;
programVersions: boolean;
};
const defaultProgramJoins: ProgramJoins = {
@@ -257,6 +258,7 @@ const defaultProgramJoins: ProgramJoins = {
tvShow: false,
tvSeason: false,
customShows: false,
programVersions: false,
};
export const AllProgramJoins: ProgramJoins = {
@@ -265,6 +267,7 @@ export const AllProgramJoins: ProgramJoins = {
tvSeason: true,
tvShow: true,
customShows: true,
programVersions: true,
};
type ProgramField = `program.${keyof RawProgram}`;
@@ -419,14 +422,17 @@ function baseWithProgramsExpressionBuilder(
),
),
)
.$if(!!opts.joins.tvShow, (qb) =>
qb.select((eb) =>
withTvShow(
eb,
getJoinFields('tvShow'),
opts.includeGroupingExternalIds,
.$if(
!!opts.joins.tvShow,
(qb) =>
qb.select((eb) =>
withTvShow(
eb,
getJoinFields('tvShow'),
opts.includeGroupingExternalIds,
),
),
),
// $if(!!opts.joins.programVersions, qb => qb.select(eb => ))
)
.$if(!!opts.joins.customShows, (qb) => qb.select(withProgramCustomShows));
}

View File

@@ -1,11 +1,13 @@
import type { TupleToUnion } from '@tunarr/types';
import { inArray } from 'drizzle-orm';
import type { InferSelectModel } from 'drizzle-orm';
import { inArray, relations } from 'drizzle-orm';
import { check, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core';
import type { Updateable } from 'kysely';
import { type Insertable, type Selectable } from 'kysely';
import { type KyselifyBetter } from './KyselifyBetter.ts';
import type { MediaSourceName } from './base.ts';
import { type MediaSourceId } from './base.ts';
import { type KyselifyBetter } from './KyselifyBetter.ts';
import { Program } from './Program.ts';
export const MediaSourceTypes = ['plex', 'jellyfin', 'emby'] as const;
@@ -46,6 +48,11 @@ export const MediaSource = sqliteTable(
],
);
export const MediaSourceRelations = relations(MediaSource, ({ many }) => ({
libraries: many(MediaSourceLibrary),
programs: many(Program),
}));
export const MediaSourceFields: (keyof MediaSourceTable)[] = [
'accessToken',
'clientIdentifier',
@@ -99,6 +106,17 @@ export const MediaSourceLibrary = sqliteTable(
],
);
export const MediaSourceLibraryRelations = relations(
MediaSourceLibrary,
({ one, many }) => ({
programs: many(Program),
one: one(MediaSource, {
fields: [MediaSourceLibrary.mediaSourceId],
references: [MediaSource.uuid],
}),
}),
);
export const MediaSourceLibraryColumns: (keyof MediaSourceLibraryTable)[] = [
'enabled',
'externalKey',
@@ -111,6 +129,7 @@ export const MediaSourceLibraryColumns: (keyof MediaSourceLibraryTable)[] = [
export type MediaSourceLibraryTable = KyselifyBetter<typeof MediaSourceLibrary>;
export type MediaSourceLibrary = Selectable<MediaSourceLibraryTable>;
export type MediaSourceLibraryOrm = InferSelectModel<typeof MediaSourceLibrary>;
export type NewMediaSourceLibrary = Insertable<MediaSourceLibraryTable>;
export type MediaSourceLibraryUpdate = Updateable<MediaSourceLibraryTable>;

View File

@@ -1,5 +1,6 @@
import type { TupleToUnion } from '@tunarr/types';
import { inArray } from 'drizzle-orm';
import type { InferSelectModel } from 'drizzle-orm';
import { inArray, relations } from 'drizzle-orm';
import {
check,
index,
@@ -10,15 +11,17 @@ import {
} from 'drizzle-orm/sqlite-core';
import type { Insertable, Selectable, Updateable } from 'kysely';
import type { MarkNotNilable } from '../../types/util.ts';
import type { MediaSourceName } from './base.ts';
import { type MediaSourceId } from './base.ts';
import { type KyselifyBetter } from './KyselifyBetter.ts';
import {
MediaSource,
MediaSourceLibrary,
MediaSourceTypes,
} from './MediaSource.ts';
import { ProgramExternalId } from './ProgramExternalId.ts';
import { ProgramGrouping } from './ProgramGrouping.ts';
import type { MediaSourceName } from './base.ts';
import { type MediaSourceId } from './base.ts';
import { ProgramVersion } from './ProgramVersion.ts';
export const ProgramTypes = [
'movie',
@@ -103,8 +106,42 @@ export const Program = sqliteTable(
],
);
export const ProgramRelations = relations(Program, ({ many, one }) => ({
versions: many(ProgramVersion, { relationName: 'versions' }),
artist: one(ProgramGrouping, {
fields: [Program.artistUuid],
references: [ProgramGrouping.uuid],
relationName: 'children',
}),
album: one(ProgramGrouping, {
fields: [Program.albumUuid],
references: [ProgramGrouping.uuid],
relationName: 'children',
}),
season: one(ProgramGrouping, {
fields: [Program.seasonUuid],
references: [ProgramGrouping.uuid],
relationName: 'children',
}),
show: one(ProgramGrouping, {
fields: [Program.tvShowUuid],
references: [ProgramGrouping.uuid],
relationName: 'children',
}),
mediaSource: one(MediaSource, {
fields: [Program.mediaSourceId],
references: [MediaSource.uuid],
}),
mediaLibrary: one(MediaSourceLibrary, {
fields: [Program.libraryId],
references: [MediaSourceLibrary.uuid],
}),
externalIds: many(ProgramExternalId),
}));
export type ProgramTable = KyselifyBetter<typeof Program>;
export type ProgramDao = Selectable<ProgramTable>;
export type ProgramOrm = InferSelectModel<typeof Program>;
// Make canonicalId required on insert.
export type NewProgramDao = MarkNotNilable<
Insertable<ProgramTable>,

View File

@@ -0,0 +1,36 @@
import type { TupleToUnion } from '@tunarr/types';
import type { InferSelectModel } from 'drizzle-orm';
import { relations } from 'drizzle-orm';
import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core';
import type { Insertable, Selectable } from 'kysely';
import type { KyselifyBetter } from './KyselifyBetter.ts';
import { ProgramVersion } from './ProgramVersion.ts';
export const ProgramChapterType = ['chapter', 'intro', 'outro'] as const;
export type ProgramChapterType = TupleToUnion<typeof ProgramChapterType>;
export const ProgramChapter = sqliteTable('program_chapter', {
uuid: text().primaryKey(),
index: integer().notNull(),
startTime: integer().notNull(),
endTime: integer().notNull(),
title: text(),
chapterType: text({ enum: ProgramChapterType }).notNull().default('chapter'),
// Join
programVersionId: text()
.notNull()
.references(() => ProgramVersion.uuid, { onDelete: 'cascade' }),
});
export const ProgramChapterRelations = relations(ProgramChapter, ({ one }) => ({
version: one(ProgramVersion, {
fields: [ProgramChapter.programVersionId],
references: [ProgramVersion.uuid],
}),
}));
export type ProgramChapterTable = KyselifyBetter<typeof ProgramChapter>;
export type ProgramChapter = Selectable<ProgramChapterTable>;
export type ProgramChapterOrm = InferSelectModel<typeof ProgramChapter>;
export type NewProgramChapter = Insertable<ProgramChapterTable>;

View File

@@ -1,4 +1,4 @@
import { inArray, sql } from 'drizzle-orm';
import { inArray, relations, sql } from 'drizzle-orm';
import {
check,
index,
@@ -58,6 +58,20 @@ export const ProgramExternalId = sqliteTable(
],
);
export const ProgramExternalIdRelations = relations(
ProgramExternalId,
({ one }) => ({
program: one(Program, {
fields: [ProgramExternalId.programUuid],
references: [Program.uuid],
}),
mediaSource: one(MediaSource, {
fields: [ProgramExternalId.mediaSourceId],
references: [MediaSource.uuid],
}),
}),
);
export type ProgramExternalIdTable = KyselifyBetter<typeof ProgramExternalId>;
export type ProgramExternalId = Selectable<ProgramExternalIdTable>;
export type NewProgramExternalId = Insertable<ProgramExternalIdTable>;

View File

@@ -1,5 +1,5 @@
import { type TupleToUnion } from '@tunarr/types';
import { inArray } from 'drizzle-orm';
import { inArray, relations } from 'drizzle-orm';
import {
type AnySQLiteColumn,
check,
@@ -12,6 +12,7 @@ import type { Insertable, Selectable, Updateable } from 'kysely';
import type { MarkRequiredNotNull } from '../../types/util.ts';
import { type KyselifyBetter } from './KyselifyBetter.ts';
import { MediaSourceLibrary } from './MediaSource.ts';
import { Program } from './Program.ts';
import type { ProgramGroupingTable as RawProgramGrouping } from './ProgramGrouping.ts';
export const ProgramGroupingType = {
@@ -57,6 +58,23 @@ export const ProgramGrouping = sqliteTable(
],
);
export const ProgramGroupingRelations = relations(
ProgramGrouping,
({ many, one }) => ({
artist: one(ProgramGrouping, {
fields: [ProgramGrouping.artistUuid],
references: [ProgramGrouping.uuid],
relationName: 'artist',
}),
show: one(ProgramGrouping, {
fields: [ProgramGrouping.showUuid],
references: [ProgramGrouping.uuid],
relationName: 'show',
}),
children: many(Program),
}),
);
export type ProgramGroupingTable = KyselifyBetter<typeof ProgramGrouping>;
export type ProgramGrouping = Selectable<ProgramGroupingTable>;
export type NewProgramGrouping = MarkRequiredNotNull<

View File

@@ -0,0 +1,5 @@
// import { sqliteTable, text } from 'drizzle-orm/sqlite-core';
// export const ProgramMediaFile = sqliteTable('program_media_file', {
// uuid: text().primaryKey(),
// });

View File

@@ -0,0 +1,61 @@
import type { InferSelectModel } from 'drizzle-orm';
import { relations } from 'drizzle-orm';
import { index, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core';
import type { Insertable, Selectable } from 'kysely';
import type { KyselifyBetter } from './KyselifyBetter.ts';
import { ProgramVersion } from './ProgramVersion.ts';
export const MediaStreamKind = [
'video',
'audio',
'subtitles',
'external_subtitles',
'attachment',
] as const;
export const ProgramMediaStream = sqliteTable(
'program_media_stream',
{
uuid: text().primaryKey(),
index: integer().notNull(),
codec: text().notNull(),
profile: text().notNull(),
streamKind: text({ enum: MediaStreamKind }).notNull(),
title: text(),
// Audio
language: text(), // Required?
channels: integer(),
default: integer({ mode: 'boolean' }).notNull().default(false),
forced: integer({ mode: 'boolean' }).notNull().default(false),
// Video
pixelFormat: text(),
colorRange: text(),
colorSpace: text(),
colorTransfer: text(),
colorPrimaries: text(),
bitsPerSample: integer(),
// Join
programVersionId: text()
.notNull()
.references(() => ProgramVersion.uuid, { onDelete: 'cascade' }),
},
(table) => [index('index_program_version_id').on(table.programVersionId)],
);
export const ProgramMediaStreamRelations = relations(
ProgramMediaStream,
({ one }) => ({
version: one(ProgramVersion, {
fields: [ProgramMediaStream.programVersionId],
references: [ProgramVersion.uuid],
}),
}),
);
export type ProgramMediaStreamTable = KyselifyBetter<typeof ProgramMediaStream>;
export type ProgramMediaStream = Selectable<ProgramMediaStreamTable>;
export type ProgramMediaStreamOrm = InferSelectModel<typeof ProgramMediaStream>;
export type NewProgramMediaStream = Insertable<ProgramMediaStreamTable>;

View File

@@ -0,0 +1,51 @@
import type { InferSelectModel } from 'drizzle-orm';
import { relations } from 'drizzle-orm';
import { index, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core';
import type { Insertable, Selectable, Updateable } from 'kysely';
import type { KyselifyBetter } from './KyselifyBetter.ts';
import { Program } from './Program.ts';
import { ProgramChapter } from './ProgramChapter.ts';
import { ProgramMediaStream } from './ProgramMediaStream.ts';
export const VideoScanKind = ['unknown', 'progressive', 'interlaced'] as const;
export const ProgramVersion = sqliteTable(
'program_version',
{
uuid: text().primaryKey(),
createdAt: integer().notNull(),
updatedAt: integer().notNull(),
duration: integer().notNull(),
sampleAspectRatio: text().notNull(),
displayAspectRatio: text().notNull(),
frameRate: text(),
scanKind: text({ enum: VideoScanKind }),
width: integer(),
height: integer(),
// Join
programId: text()
.notNull()
.references(() => Program.uuid, { onDelete: 'cascade' }),
},
(table) => [index('index_program_version_program_id').on(table.programId)],
);
export const ProgramVersionRelations = relations(
ProgramVersion,
({ one, many }) => ({
program: one(Program, {
fields: [ProgramVersion.programId],
references: [Program.uuid],
relationName: 'versions',
}),
mediaStreams: many(ProgramMediaStream),
chapters: many(ProgramChapter),
}),
);
export type ProgramVersionTable = KyselifyBetter<typeof ProgramVersion>;
export type ProgramVersion = Selectable<ProgramVersionTable>;
export type ProgramVersionOrm = InferSelectModel<typeof ProgramVersion>;
export type NewProgramVersionDao = Insertable<ProgramVersionTable>;
export type ProgramVersionUpdate = Updateable<ProgramVersionTable>;

View File

@@ -14,9 +14,12 @@ import type {
} from './MediaSource.ts';
import type { MikroOrmMigrationsTable } from './MikroOrmMigrations.js';
import type { ProgramTable } from './Program.ts';
import type { ProgramChapterTable } from './ProgramChapter.ts';
import type { ProgramExternalIdTable } from './ProgramExternalId.ts';
import type { ProgramGroupingTable } from './ProgramGrouping.ts';
import type { ProgramGroupingExternalIdTable } from './ProgramGroupingExternalId.ts';
import type { ProgramMediaStreamTable } from './ProgramMediaStream.ts';
import type { ProgramVersionTable } from './ProgramVersion.ts';
import type {
ChannelSubtitlePreferencesTable,
CustomShowSubtitlePreferencesTable,
@@ -39,7 +42,10 @@ export interface DB {
mediaSource: MediaSourceTable;
mediaSourceLibrary: MediaSourceLibraryTable;
program: ProgramTable;
programChapter: ProgramChapterTable;
programExternalId: ProgramExternalIdTable;
programMediaStream: ProgramMediaStreamTable;
programVersion: ProgramVersionTable;
programGrouping: ProgramGroupingTable;
programGroupingExternalId: ProgramGroupingExternalIdTable;
transcodeConfig: TranscodeConfigTable;

View File

@@ -1,14 +1,26 @@
import type { TranscodeConfig } from '@/db/schema/TranscodeConfig.js';
import type { MarkNonNullable } from '@/types/util.js';
import type { MarkNonNullable, Nullable } from '@/types/util.js';
import type { Insertable } from 'kysely';
import type { DeepNullable, MarkRequired, StrictOmit } from 'ts-essentials';
import type { Channel, ChannelFillerShow } from './Channel.ts';
import type { FillerShow } from './FillerShow.ts';
import type {
MediaSource,
MediaSourceLibrary,
MediaSourceLibraryOrm,
MediaSourceType,
} from './MediaSource.ts';
import type { NewProgramDao, ProgramDao, ProgramType } from './Program.ts';
import type {
NewProgramDao,
ProgramDao,
ProgramOrm,
ProgramType,
} from './Program.ts';
import type {
ProgramChapter,
ProgramChapterOrm,
ProgramChapterTable,
} from './ProgramChapter.ts';
import type {
MinimalProgramExternalId,
NewSingleOrMultiExternalId,
@@ -22,8 +34,28 @@ import type {
NewSingleOrMultiProgramGroupingExternalId,
ProgramGroupingExternalId,
} from './ProgramGroupingExternalId.ts';
import type {
NewProgramMediaStream,
ProgramMediaStream,
ProgramMediaStreamOrm,
} from './ProgramMediaStream.ts';
import type {
NewProgramVersionDao,
ProgramVersion,
ProgramVersionOrm,
} from './ProgramVersion.ts';
import type { ChannelSubtitlePreferences } from './SubtitlePreferences.ts';
export type ProgramVersionWithRelations = ProgramVersion & {
mediaStreams?: ProgramMediaStream[];
chapters?: ProgramChapter[];
};
export type ProgramVersionOrmWithRelations = ProgramVersionOrm & {
mediaStreams?: ProgramMediaStreamOrm[];
chapters?: ProgramChapterOrm[];
};
export type ProgramWithRelations = ProgramDao & {
tvShow?: DeepNullable<Partial<ProgramGroupingWithExternalIds>> | null;
tvSeason?: DeepNullable<Partial<ProgramGroupingWithExternalIds>> | null;
@@ -31,11 +63,24 @@ export type ProgramWithRelations = ProgramDao & {
trackAlbum?: DeepNullable<Partial<ProgramGroupingWithExternalIds>> | null;
// Require minimum data from externalId
externalIds?: MinimalProgramExternalId[];
versions?: ProgramVersionWithRelations[];
mediaLibrary?: Nullable<MediaSourceLibrary>;
};
export type ProgramWithRelationsOrm = ProgramOrm & {
show?: DeepNullable<Partial<ProgramGroupingWithExternalIds>> | null;
season?: DeepNullable<Partial<ProgramGroupingWithExternalIds>> | null;
artist?: DeepNullable<Partial<ProgramGroupingWithExternalIds>> | null;
album?: DeepNullable<Partial<ProgramGroupingWithExternalIds>> | null;
// Require minimum data from externalId
externalIds?: MinimalProgramExternalId[];
versions?: ProgramVersionOrmWithRelations[];
mediaLibrary?: Nullable<MediaSourceLibraryOrm>;
};
export type SpecificProgramGroupingType<
Typ extends ProgramGroupingType,
ProgramGroupingT = ProgramGrouping,
ProgramGroupingT extends { type: ProgramGroupingType } = ProgramGrouping,
> = StrictOmit<ProgramGroupingT, 'type'> & { type: Typ };
export type SpecificProgramType<
@@ -106,29 +151,33 @@ export type ProgramWithExternalIds = ProgramDao & {
externalIds: MinimalProgramExternalId[];
};
export type NewProgramVersion = NewProgramVersionDao & {
mediaStreams: NewProgramMediaStream[];
chapters?: Insertable<ProgramChapterTable>[];
};
export type NewProgramWithRelations<Type extends ProgramType = ProgramType> = {
program: SpecificProgramType<Type, NewProgramDao>;
externalIds: NewSingleOrMultiExternalId[];
versions: NewProgramVersion[];
};
export type NewProgramWithExternalIds = NewProgramDao & {
externalIds: NewSingleOrMultiExternalId[];
};
export type NewMovieProgram = SpecificProgramType<'movie', NewProgramDao> & {
externalIds: NewSingleOrMultiExternalId[];
};
export type NewMovieProgram = SpecificProgramType<'movie', NewProgramDao>;
export type NewEpisodeProgram = SpecificProgramType<
'episode',
NewProgramDao
> & {
externalIds: NewSingleOrMultiExternalId[];
};
export type NewEpisodeProgram = SpecificProgramType<'episode', NewProgramDao>;
export type ProgramGroupingWithExternalIds = ProgramGrouping & {
externalIds: ProgramGroupingExternalId[];
};
type SpecificSubtype<BaseType, Value extends BaseType['type']> = StrictOmit<
BaseType,
'type'
> & { type: Value };
type SpecificSubtype<
BaseType extends { type: string },
Value extends BaseType['type'],
> = StrictOmit<BaseType, 'type'> & { type: Value };
export type TvSeasonWithExternalIds = SpecificSubtype<
ProgramGroupingWithExternalIds,
@@ -188,9 +237,8 @@ export type NewMusicAlbum = SpecificProgramGroupingType<
NewProgramGrouping
> &
WithNewGroupingExternalIds;
export type NewMusicTrack = SpecificProgramType<'track', NewProgramDao> & {
externalIds: NewSingleOrMultiExternalId[];
};
export type NewMusicTrack = SpecificProgramType<'track', NewProgramDao>;
export type MediaSourceWithLibraries = MediaSource & {
libraries: MediaSourceLibrary[];

View File

@@ -0,0 +1,45 @@
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
import { Channel } from './Channel.ts';
import {
MediaSource,
MediaSourceLibrary,
MediaSourceLibraryRelations,
} from './MediaSource.ts';
import { Program, ProgramRelations } from './Program.ts';
import { ProgramChapter, ProgramChapterRelations } from './ProgramChapter.ts';
import {
ProgramExternalId,
ProgramExternalIdRelations,
} from './ProgramExternalId.ts';
import {
ProgramGrouping,
ProgramGroupingRelations,
} from './ProgramGrouping.ts';
import {
ProgramMediaStream,
ProgramMediaStreamRelations,
} from './ProgramMediaStream.ts';
import { ProgramVersion, ProgramVersionRelations } from './ProgramVersion.ts';
// export { Program } from './Program.ts';
export const schema = {
channels: Channel,
program: Program,
programVersion: ProgramVersion,
programRelations: ProgramRelations,
programVersionRelations: ProgramVersionRelations,
programGrouping: ProgramGrouping,
programGroupingRelations: ProgramGroupingRelations,
programExternalId: ProgramExternalId,
programExternalIdRelations: ProgramExternalIdRelations,
programMediaStream: ProgramMediaStream,
programMediaStreamRelations: ProgramMediaStreamRelations,
programChapter: ProgramChapter,
programChapterRelations: ProgramChapterRelations,
mediaSource: MediaSource,
mediaSourceLibrary: MediaSourceLibrary,
mediaSourceLibraryRelations: MediaSourceLibraryRelations,
};
export type DrizzleDBAccess = BetterSQLite3Database<typeof schema>;

View File

@@ -1,30 +1,5 @@
import type {
EpisodeProgram,
MovieProgram,
NewEpisodeProgram,
NewMovieProgram,
NewProgramWithExternalIds,
ProgramWithExternalIds,
} from './derivedTypes.js';
import type { MovieProgram, ProgramWithExternalIds } from './derivedTypes.js';
export function isMovieProgram(p: ProgramWithExternalIds): p is MovieProgram {
return p.type === 'movie' && !!p.externalIds;
}
export function isNewMovieProgram(
p: NewProgramWithExternalIds,
): p is NewMovieProgram {
return p.type === 'movie';
}
export function isEpisodeProgram(
p: ProgramWithExternalIds,
): p is EpisodeProgram {
return p.type === 'movie' && !!p.externalIds;
}
export function isNewEpisodeProgram(
p: NewProgramWithExternalIds,
): p is NewEpisodeProgram {
return p.type === 'movie';
}

View File

@@ -3,6 +3,7 @@ import type { Maybe, Nilable, Nullable } from '@/types/util.js';
import {
attemptSync,
caughtErrorToError,
isDefined,
isNonEmptyString,
nullToUndefined,
parseIntOrNull,
@@ -10,7 +11,7 @@ import {
import { LoggerFactory } from '@/util/logging/LoggerFactory.js';
import { getTunarrVersion } from '@/util/version.js';
import { seq } from '@tunarr/shared/util';
import type { Folder, Library } from '@tunarr/types';
import type { Folder, Library, MediaChapter } from '@tunarr/types';
import type { MediaSourceStatus, PagedResult } from '@tunarr/types/api';
import type {
JellyfinItem as ApiJellyfinItem,
@@ -30,7 +31,9 @@ import type { AxiosRequestConfig } from 'axios';
import axios, { isAxiosError } from 'axios';
import dayjs from 'dayjs';
import {
every,
find,
floor,
forEach,
groupBy,
isBoolean,
@@ -1060,6 +1063,31 @@ export class JellyfinApiClient extends MediaSourceApiClient<JellyfinItemTypes> {
},
) ?? [];
const chapters: MediaChapter[] = [];
const apiChapters = item.Chapters ?? [];
// We can't get end times unless they all have starts...
if (
(item.RunTimeTicks ?? 0) > 0 &&
every(apiChapters, (c) => isDefined(c.StartPositionTicks))
) {
for (let i = 0; i < apiChapters.length; i++) {
const chapter = apiChapters[i];
const isLast = i === apiChapters.length - 1;
const end = floor(
isLast
? item.RunTimeTicks! / 10_000
: apiChapters[i + 1].StartPositionTicks! / 10_000,
);
chapters.push({
chapterType: 'chapter',
endTime: end,
index: i,
startTime: chapter.StartPositionTicks!,
title: chapter.Name,
});
}
}
return {
displayAspectRatio: videoStream.AspectRatio ?? '',
sampleAspectRatio: isAnamorphic ? '0:0' : '1:1',
@@ -1078,6 +1106,7 @@ export class JellyfinApiClient extends MediaSourceApiClient<JellyfinItemTypes> {
widthPx: width,
heightPx: height,
},
chapters,
};
}

View File

@@ -6,6 +6,7 @@ import {
inConstArr,
isDefined,
isNonEmptyString,
zipWithIndex,
} from '@/util/index.js';
import { getTunarrVersion } from '@/util/version.js';
import { PlexClientIdentifier } from '@tunarr/shared/constants';
@@ -13,6 +14,7 @@ import { seq } from '@tunarr/shared/util';
import type {
Collection,
Library,
MediaChapter,
Playlist,
ProgramOrFolder,
} from '@tunarr/types';
@@ -29,7 +31,6 @@ import type {
PlexMediaAudioStream,
PlexMediaContainerMetadata,
PlexMediaContainerResponse,
PlexMediaDescription,
PlexMediaNoCollectionOrPlaylist,
PlexMediaNoCollectionPlaylist,
PlexMediaVideoStream,
@@ -80,6 +81,7 @@ import {
isUndefined,
map,
maxBy,
orderBy,
reject,
sortBy,
} from 'lodash-es';
@@ -1249,7 +1251,7 @@ export class PlexApiClient extends MediaSourceApiClient<PlexTypes> {
episodeNumber: plexEpisode.index ?? 0,
mediaItem: plexMediaStreamsInject(
plexEpisode.ratingKey,
plexEpisode.Media,
plexEpisode,
).getOrElse(() => emptyMediaItem(plexEpisode)),
genres: [],
releaseDate: plexEpisode.originallyAvailableAt
@@ -1408,7 +1410,7 @@ export class PlexApiClient extends MediaSourceApiClient<PlexTypes> {
releaseDateString: plexMovie.originallyAvailableAt ?? null,
mediaItem: plexMediaStreamsInject(
plexMovie.ratingKey,
plexMovie.Media,
plexMovie,
).getOrElse(() => emptyMediaItem(plexMovie)),
duration: plexMovie.duration,
actors,
@@ -1577,7 +1579,7 @@ export class PlexApiClient extends MediaSourceApiClient<PlexTypes> {
trackNumber: plexTrack.index ?? 0,
mediaItem: plexMediaStreamsInject(
plexTrack.ratingKey,
plexTrack.Media,
plexTrack,
).getOrElse(() => emptyMediaItem(plexTrack)),
// TODO:
// genres: plexJoinItemInject(plexTrack.Genre),
@@ -1721,9 +1723,10 @@ function emptyMediaItem(item: PlexTerminalMedia): Maybe<MediaItem> {
function plexMediaStreamsInject(
itemId: string,
plexMedia: Maybe<PlexMediaDescription[]>,
plexItem: PlexTerminalMedia,
requireVideoStream: boolean = true,
): Result<MediaItem> {
const plexMedia = plexItem.Media;
if (isNil(plexMedia) || isEmpty(plexMedia)) {
return Result.forError(
new Error(`Plex item ID = ${itemId} has no Media streams`),
@@ -1835,6 +1838,56 @@ function plexMediaStreamsInject(
),
);
const chapters: MediaChapter[] =
plexItem.type === 'movie' || plexItem.type === 'episode'
? (plexItem.Chapter?.map((chapter) => {
return {
index: chapter.index,
endTime: chapter.endTimeOffset,
startTime: chapter.startTimeOffset,
chapterType: 'chapter',
title: chapter.tag,
} satisfies MediaChapter;
}) ?? [])
: [];
const markers =
plexItem.type === 'movie' || plexItem.type === 'episode'
? (plexItem.Marker ?? [])
: [];
const intros = zipWithIndex(
orderBy(
markers.filter((marker) => marker.type === 'intro'),
(marker) => marker.startTimeOffset,
'asc',
),
).map(
([marker, index]) =>
({
chapterType: 'intro',
endTime: marker.endTimeOffset,
startTime: marker.startTimeOffset,
index,
}) satisfies MediaChapter,
);
const outros = zipWithIndex(
orderBy(
markers.filter((marker) => marker.type === 'credits'),
(marker) => marker.startTimeOffset,
'asc',
),
).map(
([marker, index]) =>
({
chapterType: 'intro',
endTime: marker.endTimeOffset,
startTime: marker.startTimeOffset,
index,
}) satisfies MediaChapter,
);
chapters.push(...intros, ...outros);
return Result.success({
// Handle if this is not present...
duration: relevantMedia.duration!,
@@ -1859,5 +1912,6 @@ function plexMediaStreamsInject(
sourceType: MediaSourceType.Plex,
},
],
chapters,
});
}

View File

@@ -163,7 +163,7 @@ export class FfmpegProcess extends (events.EventEmitter as new () => TypedEventE
);
const bufferedBytes = bufferedOut.getLastN().toString('utf-8');
console.log(bufferedBytes);
console.error(bufferedBytes);
this.#logger.error(bufferedBytes);
fs.writeFile(
outPath,

View File

@@ -35,6 +35,7 @@ import Migration1748345299_AddMoreProgramTypes from './db/Migration1748345299_Ad
import Migration1756312561_InitialAdvancedTranscodeConfig from './db/Migration1756312561_InitialAdvancedTranscodeConfig.ts';
import Migration1756381281_AddLibraries from './db/Migration1756381281_AddLibraries.ts';
import Migration1757704591_AddProgramMediaSourceIndex from './db/Migration1757704591_AddProgramMediaSourceIndex.ts';
import Migration1758203109_AddProgramMedia from './db/Migration1758203109_AddProgramMedia.ts';
export const LegacyMigrationNameToNewMigrationName = [
['Migration20240124115044', '_Legacy_Migration00'],
@@ -117,6 +118,7 @@ export class DirectMigrationProvider implements MigrationProvider {
Migration1756312561_InitialAdvancedTranscodeConfig,
migration1756381281: Migration1756381281_AddLibraries,
migration1757704591: Migration1757704591_AddProgramMediaSourceIndex,
migration1758203109: Migration1758203109_AddProgramMedia,
},
wrapWithTransaction,
),

View File

@@ -0,0 +1,5 @@
import { makeKyselyMigrationFromSqlFile } from './util.ts';
export default makeKyselyMigrationFromSqlFile(
'./sql/0012_yielding_ben_grimm.sql',
);

View File

@@ -0,0 +1,49 @@
CREATE TABLE `program_chapter` (
`uuid` text PRIMARY KEY NOT NULL,
`index` integer NOT NULL,
`start_time` integer NOT NULL,
`end_time` integer NOT NULL,
`title` text,
`chapter_type` text DEFAULT 'chapter' NOT NULL,
`program_version_id` text NOT NULL,
FOREIGN KEY (`program_version_id`) REFERENCES `program_version`(`uuid`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `program_media_stream` (
`uuid` text PRIMARY KEY NOT NULL,
`index` integer NOT NULL,
`codec` text NOT NULL,
`profile` text NOT NULL,
`stream_kind` text NOT NULL,
`title` text,
`language` text,
`channels` integer,
`default` integer DEFAULT false NOT NULL,
`forced` integer DEFAULT false NOT NULL,
`pixel_format` text,
`color_range` text,
`color_space` text,
`color_transfer` text,
`color_primaries` text,
`bits_per_sample` integer,
`program_version_id` text NOT NULL,
FOREIGN KEY (`program_version_id`) REFERENCES `program_version`(`uuid`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `index_program_version_id` ON `program_media_stream` (`program_version_id`);--> statement-breakpoint
CREATE TABLE `program_version` (
`uuid` text PRIMARY KEY NOT NULL,
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL,
`duration` integer NOT NULL,
`sample_aspect_ratio` text NOT NULL,
`display_aspect_ratio` text NOT NULL,
`frame_rate` text,
`scan_kind` text,
`width` integer,
`height` integer,
`program_id` text NOT NULL,
FOREIGN KEY (`program_id`) REFERENCES `program`(`uuid`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `index_program_version_program_id` ON `program_version` (`program_id`);

File diff suppressed because it is too large Load Diff

View File

@@ -85,6 +85,13 @@
"when": 1757704575202,
"tag": "0011_stormy_stark_industries",
"breakpoints": true
},
{
"idx": 12,
"version": "6",
"when": 1758202985051,
"tag": "0012_yielding_ben_grimm",
"breakpoints": true
}
]
}

View File

@@ -1,4 +1,4 @@
import { seq } from '@tunarr/shared/util';
import { nullToUndefined, seq } from '@tunarr/shared/util';
import { FindChild, tag, Tag, TupleToUnion } from '@tunarr/types';
import { SearchFilter } from '@tunarr/types/api';
import {
@@ -1081,8 +1081,6 @@ export class MeilisearchService implements ISearchService {
this.logger.warn('No external ids for item id %s', program.uuid);
}
console.log(validEids);
const mergedExternalIds = validEids.map(
(eid) =>
`${eid.source}|${eid.sourceId ?? ''}|${eid.id}` satisfies MergedExternalId,
@@ -1148,9 +1146,9 @@ export class MeilisearchService implements ISearchService {
videoWidth: width,
videoHeight: height,
videoCodec: videoStream?.codec,
videoBitDepth: videoStream?.bitDepth,
videoBitDepth: nullToUndefined(videoStream?.bitDepth),
audioCodec: audioStream?.codec,
audioChannels: audioStream?.channels,
audioChannels: nullToUndefined(audioStream?.channels),
} satisfies TerminalProgramSearchDocument<typeof program.type>;
}

View File

@@ -112,20 +112,20 @@ export abstract class MediaSourceMovieLibraryScanner<
const result = await this.scanMovie(context, movie).then((result) =>
result.flatMapAsync((fullApiMovie) => {
return Result.attemptAsync(() =>
this.programDB
.upsertPrograms([
this.programMinter.mintMovie(
mediaSource,
library,
fullApiMovie,
),
])
return Result.attemptAsync(() => {
const minted = this.programMinter.mintMovie(
mediaSource,
library,
fullApiMovie,
);
return this.programDB
.upsertPrograms([minted])
.then((_) => _.filter(isMovieProgram))
.then(
(upsertedMovies) => [fullApiMovie, upsertedMovies] as const,
),
);
);
});
}),
);

View File

@@ -249,8 +249,8 @@ export abstract class MediaSourceMusicArtistScanner<
trackWithJoins,
);
dao.tvShowUuid = artist.uuid;
dao.seasonUuid = album.uuid;
dao.program.tvShowUuid = artist.uuid;
dao.program.seasonUuid = album.uuid;
return Result.attemptAsync(() =>
this.programDB.upsertPrograms([dao]),

View File

@@ -248,8 +248,8 @@ export abstract class MediaSourceTvShowLibraryScanner<
episodeWithJoins,
);
dao.tvShowUuid = show.uuid;
dao.seasonUuid = season.uuid;
dao.program.tvShowUuid = show.uuid;
dao.program.seasonUuid = season.uuid;
return Result.attemptAsync(() =>
this.programDB.upsertPrograms([dao]),

View File

@@ -14,6 +14,7 @@ const KEYS = {
MutexMap: Symbol.for('MutexMap'),
Database: Symbol.for('Database'),
DrizzleDB: Symbol.for('DrizzleDB'),
DatabaseFactory: Symbol.for('DatabaseFactory'),
ChannelDB: Symbol.for('ChannelDB'),
ProgramDB: Symbol.for('ProgramDB'),

View File

@@ -609,3 +609,13 @@ export function programExternalIdString(
) {
return createExternalId(p.sourceType, p.mediaSourceId, p.externalKey);
}
export function unzip<T, U>(tups: [T, U][]): [T[], U[]] {
const left: T[] = [];
const right: U[] = [];
for (const [t, u] of tups) {
left.push(t);
right.push(u);
}
return [left, right];
}

File diff suppressed because one or more lines are too long

View File

@@ -9,6 +9,9 @@ import type {
ItemOrFolder,
ItemSchema,
Library,
MediaChapter,
MediaItem,
MediaStream,
Movie,
MusicAlbum,
MusicAlbumContentProgramSchema,
@@ -155,6 +158,10 @@ export type Collection = z.infer<typeof Collection>;
export type Playlist = z.infer<typeof Playlist>;
export type Library = z.infer<typeof Library>;
export type MediaStream = z.infer<typeof MediaStream>;
export type MediaItem = z.infer<typeof MediaItem>;
export type MediaChapter = z.infer<typeof MediaChapter>;
export function isEpisodeWithHierarchy(
f: TerminalProgram,
): f is EpisodeWithHierarchy {
@@ -210,3 +217,14 @@ export function isTerminalItemType(program: ProgramOrFolder | Library) {
program.type === 'other_video'
);
}
export function isStructuralItemType(
program: ProgramOrFolder | Library,
): program is StructuralProgramGrouping {
return (
program.type === 'folder' ||
program.type === 'collection' ||
program.type === 'playlist' ||
program.type === 'library'
);
}

View File

@@ -206,10 +206,10 @@ export const JellyfinSortOrder = z.enum(['Ascending', 'Descending']);
const ChapterInfo = z
.object({
StartPositionTicks: z.number().int(),
Name: z.string().nullable().optional(),
ImagePath: z.string().nullable().optional(),
ImageDateModified: z.string().datetime({ offset: true }),
ImageTag: z.string().nullable().optional(),
Name: z.string().nullish(),
ImagePath: z.string().nullish(),
ImageDateModified: z.iso.datetime({ offset: true }),
ImageTag: z.string().nullish(),
})
.partial();

View File

@@ -22,11 +22,32 @@ export const PlexJoinItemSchema = z.object({
tag: z.string(),
});
export type PlexJoinItem = z.infer<typeof PlexJoinItemSchema>;
export const PlexActorSchema = PlexJoinItemSchema.extend({
role: z.string().optional(),
});
export type PlexJoinItem = z.infer<typeof PlexJoinItemSchema>;
export const PlexChapterSchema = z.object({
id: z.number(),
filter: z.string().optional(),
index: z.number(),
startTimeOffset: z.number(),
endTimeOffset: z.number(),
thumb: z.string().optional(),
tag: z.string().optional(),
});
export type PlexChapter = z.infer<typeof PlexChapterSchema>;
export const PlexMarkerSchema = z.object({
startTimeOffset: z.number(),
endTimeOffset: z.number(),
final: z.boolean().optional(), // If there are multiple, this indicates the last
type: z.string(), // Loose for now. Generally intro or credits
});
export type PlexMarker = z.infer<typeof PlexMarkerSchema>;
export const PlexMediaContainerMetadataSchema = z.object({
size: z.number(),
@@ -378,6 +399,8 @@ export const PlexMovieSchema = BasePlexMediaSchema.extend({
Director: z.array(PlexJoinItemSchema).optional(),
Writer: z.array(PlexJoinItemSchema).optional(),
Role: z.array(PlexActorSchema).optional(),
Marker: z.array(PlexMarkerSchema).optional(),
Chapter: z.array(PlexChapterSchema).optional(),
}).extend(neverDirectory.shape);
export type PlexMovie = z.infer<typeof PlexMovieSchema>;
@@ -606,6 +629,8 @@ export const PlexEpisodeSchema = BasePlexMediaSchema.extend({
Director: z.array(PlexJoinItemSchema).optional(),
Writer: z.array(PlexJoinItemSchema).optional(),
Role: z.array(PlexActorSchema).optional(),
Marker: z.array(PlexMarkerSchema).optional(),
Chapter: z.array(PlexChapterSchema).optional(),
}).merge(neverDirectory);
export type PlexEpisode = Alias<z.infer<typeof PlexEpisodeSchema>>;

View File

@@ -371,6 +371,14 @@ export const MediaSourceMediaLocation = BaseMediaLocation.extend({
externalKey: z.string(),
});
export const MediaChapter = z.object({
index: z.number().nonnegative(),
startTime: z.number().nonnegative(),
endTime: z.number().nonnegative(),
title: z.string().nullish(),
chapterType: z.enum(['chapter', 'intro', 'outro']).default('chapter'),
});
export const MediaLocation = LocalMediaLocation.or(MediaSourceMediaLocation);
export const MediaStreamType = z.enum([
@@ -386,18 +394,18 @@ export const MediaStream = z.object({
codec: z.string(),
profile: z.string(),
streamType: MediaStreamType,
languageCodeISO6392: z.string().optional(),
languageCodeISO6392: z.string().nullish(),
// TODO: consider breaking stream out to a union for each subtype
channels: z.number().optional(),
title: z.string().optional(),
default: z.boolean().optional(),
hasAttachedPicture: z.boolean().optional(),
pixelFormat: z.string().optional(),
bitDepth: z.number().optional(),
fileName: z.string().optional(),
mimeType: z.string().optional(),
selected: z.boolean().optional(),
frameRate: z.string().or(z.number()).optional(),
channels: z.number().nullish(),
title: z.string().nullish(),
default: z.boolean().nullish(),
hasAttachedPicture: z.boolean().nullish(),
pixelFormat: z.string().nullish(),
bitDepth: z.number().nullish(),
fileName: z.string().nullish(),
mimeType: z.string().nullish(),
selected: z.boolean().nullish(),
frameRate: z.string().or(z.number()).nullish(),
});
export const MediaItem = z.object({
@@ -405,9 +413,10 @@ export const MediaItem = z.object({
duration: z.number().nonnegative(),
sampleAspectRatio: z.string(),
displayAspectRatio: z.string(),
frameRate: z.number().or(z.string()).optional(),
resolution: ResolutionSchema.optional(),
frameRate: z.number().or(z.string()).nullish(),
resolution: ResolutionSchema.nullish(),
locations: z.array(MediaLocation),
chapters: z.array(MediaChapter).nullish(),
});
const BaseProgram = BaseItem.extend({

View File

@@ -1,56 +1,42 @@
import EmbyIcon from '@/assets/emby.svg?react';
import JellyfinIcon from '@/assets/jellyfin.svg?react';
import PlexIcon from '@/assets/plex.svg?react';
import { ProgramDebugDetailsMenu } from '@/dev/ProgramDebugDetailsMenu.tsx';
import type { Maybe } from '@/types/util.ts';
import { Close as CloseIcon, OpenInNew } from '@mui/icons-material';
import { Close as CloseIcon } from '@mui/icons-material';
import {
Box,
Button,
Chip,
Dialog,
DialogContent,
DialogTitle,
IconButton,
LinearProgress,
Skeleton,
Stack,
SvgIcon,
Tab,
Tabs,
Typography,
useMediaQuery,
useTheme,
} from '@mui/material';
import { createExternalId } from '@tunarr/shared';
import { forProgramType } from '@tunarr/shared/util';
import type { ChannelProgram } from '@tunarr/types';
import { isContentProgram, tag } from '@tunarr/types';
import type { ChannelProgram, TupleToUnion } from '@tunarr/types';
import type { Dayjs } from 'dayjs';
import dayjs from 'dayjs';
import { capitalize, compact, find, isUndefined } from 'lodash-es';
import type { ReactEventHandler } from 'react';
import {
Suspense,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { find, isUndefined, merge } from 'lodash-es';
import { Suspense, useCallback, useMemo, useState } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
import { P, match } from 'ts-pattern';
import { isNonEmptyString, prettyItemDuration } from '../helpers/util';
import { useSettings } from '../store/settings/selectors';
import type { DeepRequired } from 'ts-essentials';
import { isNonEmptyString } from '../helpers/util';
import { ProgramMetadataDialogContent } from './ProgramMetadataDialogContent.tsx';
import { ProgramStreamDetails } from './ProgramStreamDetails.tsx';
import { TabPanel } from './TabPanel.tsx';
const Panels = ['metadata', 'stream_details'] as const;
type Panels = TupleToUnion<typeof Panels>;
type PanelVisibility = {
[K in Panels]?: boolean;
};
type Props = {
open: boolean;
onClose: () => void;
program: ChannelProgram | undefined;
start?: Dayjs;
stop?: Dayjs;
panelVisibility?: PanelVisibility;
};
const formattedTitle = forProgramType({
@@ -63,7 +49,10 @@ const formattedTitle = forProgramType({
flex: 'Flex',
});
type ThumbLoadState = 'loading' | 'error' | 'success';
const DefaultPanelVisibility: DeepRequired<PanelVisibility> = {
metadata: true,
stream_details: true,
};
export default function ProgramDetailsDialog({
open,
@@ -71,268 +60,22 @@ export default function ProgramDetailsDialog({
start,
stop,
program,
panelVisibility = DefaultPanelVisibility,
}: Props) {
const settings = useSettings();
const [thumbLoadState, setThumbLoadState] =
useState<ThumbLoadState>('loading');
const imageRef = useRef<HTMLImageElement>(null);
const visibility = useMemo(
() => merge(DefaultPanelVisibility, panelVisibility),
[panelVisibility],
);
const theme = useTheme();
const smallViewport = useMediaQuery(theme.breakpoints.down('sm'));
const [tab, setTab] = useState(0);
const defaultPanel = (find(Object.entries(visibility), (v) => !!v)?.[0] ??
'metadata') as Panels;
const [tab, setTab] = useState<Panels>(defaultPanel);
const handleClose = useCallback(() => {
setTab(0);
setTab(defaultPanel);
onClose();
}, [onClose]);
const rating = useMemo(
() =>
forProgramType({
custom: (p) => p.program?.rating ?? '',
content: (p) => p.rating,
}),
[],
);
const summary = useMemo(
() =>
forProgramType({
custom: (p) => p.program?.summary ?? '',
content: (p) => p.summary,
default: '',
}),
[],
);
const programTitle = useMemo(
() =>
forProgramType({
custom: (p) => p.program?.title ?? '',
content: (p) => p.title,
default: '',
}),
[],
);
const durationChip = useMemo(
() =>
forProgramType({
content: (program) => (
<Chip
key="duration"
color="primary"
label={prettyItemDuration(program.duration)}
sx={{ mt: 1, mr: 1 }}
/>
),
}),
[],
);
const ratingChip = useCallback(
(program: ChannelProgram) => {
const ratingString = rating(program);
return ratingString ? (
<Chip
key="rating"
color="primary"
label={ratingString}
sx={{ mr: 1, mt: 1 }}
/>
) : null;
},
[rating],
);
const dateChip = useCallback((program: ChannelProgram) => {
const date = match(program)
.with({ type: 'content', date: P.not(P.nullish) }, (p) => dayjs(p.date))
.otherwise(() => undefined);
return date ? (
<Chip
key="release-date"
color="primary"
label={date.year()}
sx={{ mr: 1, mt: 1 }}
/>
) : null;
}, []);
const sourceChip = useCallback((program: ChannelProgram) => {
if (isContentProgram(program)) {
const id = find(
program.externalIds,
(eid) =>
eid.type === 'multi' &&
(eid.source === 'jellyfin' || eid.source === 'plex'),
);
if (!id) {
return null;
}
let icon: Maybe<JSX.Element> = undefined;
switch (id.source) {
case 'jellyfin':
icon = <JellyfinIcon />;
break;
case 'plex':
icon = <PlexIcon />;
break;
case 'emby':
icon = <EmbyIcon />;
break;
case 'plex-guid':
case 'imdb':
case 'tmdb':
case 'tvdb':
default:
break;
}
if (icon) {
return (
<Chip
key="source"
color="primary"
icon={<SvgIcon>{icon}</SvgIcon>}
label={capitalize(id.source)}
sx={{ mr: 1, mt: 1 }}
/>
);
}
}
return null;
}, []);
const timeChip = () => {
if (start && stop) {
return (
<Chip
key="time"
label={`${dayjs(start).format('LT')} - ${dayjs(stop).format('LT')}`}
sx={{ mt: 1, mr: 1 }}
color="primary"
/>
);
}
return null;
};
const chips = (program: ChannelProgram) => {
return compact([
durationChip(program),
ratingChip(program),
timeChip(),
sourceChip(program),
dateChip(program),
]);
};
const thumbnailImage: (m: ChannelProgram) => string | null = useMemo(
() =>
forProgramType({
content: (p) => {
let url: string | undefined;
if (p.persisted) {
let id: string | undefined = p.id;
if (p.subtype === 'track' && isNonEmptyString(p.albumId)) {
id = p.albumId;
}
url = `${settings.backendUri}/api/programs/${id}/thumb?proxy=true`;
}
if (isNonEmptyString(url)) {
return url;
}
let key = p.uniqueId;
if (p.subtype === 'track') {
if (isNonEmptyString(p.parent?.externalKey)) {
key = createExternalId(
p.externalSourceType,
tag(p.externalSourceId),
p.parent?.externalKey,
);
}
}
return `${settings.backendUri}/api/metadata/external?id=${key}&mode=proxy&asset=thumb`;
},
custom: (p) => (p.program ? thumbnailImage(p.program) : null),
}),
[settings.backendUri],
);
const externalLink = useMemo(
() =>
forProgramType({
content: (p) =>
p.id && p.persisted
? `${settings.backendUri}/api/programs/${p.id}/external-link`
: null,
}),
[settings.backendUri],
);
const thumbUrl = program ? thumbnailImage(program) : null;
const externalUrl = program ? externalLink(program) : null;
const programSummary = program ? summary(program) : null;
const programEpisodeTitle = program ? programTitle(program) : null;
useEffect(() => {
setThumbLoadState('loading');
}, [thumbUrl]);
const onLoad = useCallback(() => {
setThumbLoadState('success');
}, [setThumbLoadState]);
const onError: ReactEventHandler<HTMLImageElement> = useCallback((e) => {
console.error(e);
setThumbLoadState('error');
}, []);
const isEpisode =
program && program.type === 'content' && program.subtype === 'episode';
const imageWidth = smallViewport ? (isEpisode ? '100%' : '55%') : 240;
let externalSourceName: string = '';
if (program) {
switch (program.type) {
case 'content': {
const eid = find(
program.externalIds,
(eid) =>
eid.type === 'multi' &&
(eid.source === 'plex' || eid.source === 'jellyfin'),
);
if (eid) {
switch (eid.source) {
case 'plex':
externalSourceName = 'Plex';
break;
case 'jellyfin':
externalSourceName = 'Jellyfin';
break;
case 'plex-guid':
case 'imdb':
case 'tmdb':
case 'tvdb':
case 'emby':
break;
}
}
break;
}
case 'custom':
case 'redirect':
case 'flex':
case 'filler':
break;
}
}
}, [defaultPanel, onClose]);
return (
program && (
@@ -361,90 +104,26 @@ export default function ProgramDetailsDialog({
</IconButton>
</DialogTitle>
<DialogContent>
<Tabs value={tab} onChange={(_, v) => setTab(v as number)}>
<Tab label="Overview" />
<Tab
label="Stream Info"
disabled={program.type === 'redirect' || program.type === 'flex'}
/>
<Tabs value={tab} onChange={(_, v) => setTab(v as Panels)}>
{visibility.metadata && <Tab value={'metadata'} label="Overview" />}
{visibility.stream_details && (
<Tab
value="stream_details"
label="Stream Info"
disabled={
program.type === 'redirect' || program.type === 'flex'
}
/>
)}
</Tabs>
<TabPanel index={0} value={tab}>
<Stack spacing={2}>
<Box>{chips(program)}</Box>
<Stack
direction="row"
spacing={smallViewport ? 0 : 2}
flexDirection={smallViewport ? 'column' : 'row'}
>
<Box sx={{ textAlign: 'center' }}>
<Box
component="img"
width={imageWidth}
src={thumbUrl ?? ''}
alt={formattedTitle(program)}
onLoad={onLoad}
ref={imageRef}
sx={{
display:
thumbLoadState !== 'success' ? 'none' : undefined,
borderRadius: '10px',
}}
onError={onError}
/>
{thumbLoadState !== 'success' && (
<Skeleton
variant="rectangular"
width={smallViewport ? '100%' : imageWidth}
height={
program.type === 'content' &&
program.subtype === 'movie'
? 360
: smallViewport
? undefined
: 140
}
animation={thumbLoadState === 'loading' ? 'pulse' : false}
></Skeleton>
)}
</Box>
<Box>
{programEpisodeTitle ? (
<Typography variant="h5" sx={{ mb: 1 }}>
{programEpisodeTitle}
</Typography>
) : null}
{programSummary ? (
<Typography id="modal-modal-description" sx={{ mb: 1 }}>
{programSummary}
</Typography>
) : (
<Skeleton
animation={false}
variant="rectangular"
sx={{
backgroundColor: (theme) =>
theme.palette.background.default,
}}
width={imageWidth}
/>
)}
{externalUrl && isNonEmptyString(externalSourceName) && (
<Button
component="a"
target="_blank"
href={externalUrl}
size="small"
endIcon={<OpenInNew />}
variant="contained"
>
View in {externalSourceName}
</Button>
)}
</Box>
</Stack>
</Stack>
<TabPanel index={'metadata'} value={tab}>
<ProgramMetadataDialogContent
program={program}
start={start}
stop={stop}
/>
</TabPanel>
<TabPanel index={1} value={tab}>
<TabPanel index={'stream_details'} value={tab}>
{program.type === 'content' && isNonEmptyString(program.id) ? (
<ErrorBoundary
fallback={

View File

@@ -0,0 +1,386 @@
import EmbyIcon from '@/assets/emby.svg?react';
import JellyfinIcon from '@/assets/jellyfin.svg?react';
import PlexIcon from '@/assets/plex.svg?react';
import type { Maybe } from '@/types/util.ts';
import { OpenInNew } from '@mui/icons-material';
import {
Box,
Button,
Chip,
Skeleton,
Stack,
SvgIcon,
Typography,
useMediaQuery,
useTheme,
} from '@mui/material';
import { createExternalId } from '@tunarr/shared';
import { forProgramType } from '@tunarr/shared/util';
import type { ChannelProgram } from '@tunarr/types';
import { isContentProgram, tag } from '@tunarr/types';
import type { Dayjs } from 'dayjs';
import dayjs from 'dayjs';
import { capitalize, compact, find } from 'lodash-es';
import type { ReactEventHandler } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { P, match } from 'ts-pattern';
import { isNonEmptyString, prettyItemDuration } from '../helpers/util';
import { useSettings } from '../store/settings/selectors.ts';
type Props = {
program: ChannelProgram;
start?: Dayjs;
stop?: Dayjs;
};
type ThumbLoadState = 'loading' | 'error' | 'success';
const formattedTitle = forProgramType({
content: (p) => p.grandparent?.title ?? p.title,
custom: (p) =>
p.program?.grandparent?.title ?? p.program?.title ?? 'Custom Program',
filler: (p) =>
p.program?.grandparent?.title ?? p.program?.title ?? 'Filler Program',
redirect: (p) => `Redirect to Channel ${p.channel}`,
flex: 'Flex',
});
export const ProgramMetadataDialogContent = ({
program,
start,
stop,
}: Props) => {
const settings = useSettings();
const [thumbLoadState, setThumbLoadState] =
useState<ThumbLoadState>('loading');
const imageRef = useRef<HTMLImageElement>(null);
const theme = useTheme();
const smallViewport = useMediaQuery(theme.breakpoints.down('sm'));
const isEpisode =
program && program.type === 'content' && program.subtype === 'episode';
const imageWidth = smallViewport ? (isEpisode ? '100%' : '55%') : 240;
const externalSourceName = useMemo(() => {
let externalSourceName: string = '';
if (program) {
switch (program.type) {
case 'content': {
const eid = find(
program.externalIds,
(eid) =>
eid.type === 'multi' &&
(eid.source === 'plex' || eid.source === 'jellyfin'),
);
if (eid) {
switch (eid.source) {
case 'plex':
externalSourceName = 'Plex';
break;
case 'jellyfin':
externalSourceName = 'Jellyfin';
break;
case 'plex-guid':
case 'imdb':
case 'tmdb':
case 'tvdb':
case 'emby':
break;
}
}
break;
}
case 'custom':
case 'redirect':
case 'flex':
case 'filler':
break;
}
}
return externalSourceName;
}, []);
const rating = useMemo(
() =>
forProgramType({
custom: (p) => p.program?.rating ?? '',
content: (p) => p.rating,
}),
[],
);
const summary = useMemo(
() =>
forProgramType({
custom: (p) => p.program?.summary ?? '',
content: (p) => p.summary,
default: '',
}),
[],
);
const programTitle = useMemo(
() =>
forProgramType({
custom: (p) => p.program?.title ?? '',
content: (p) => p.title,
default: '',
}),
[],
);
const durationChip = useMemo(
() =>
forProgramType({
content: (program) => (
<Chip
key="duration"
color="primary"
label={prettyItemDuration(program.duration)}
sx={{ mt: 1, mr: 1 }}
/>
),
}),
[],
);
const ratingChip = useCallback(
(program: ChannelProgram) => {
const ratingString = rating(program);
return ratingString ? (
<Chip
key="rating"
color="primary"
label={ratingString}
sx={{ mr: 1, mt: 1 }}
/>
) : null;
},
[rating],
);
const dateChip = useCallback((program: ChannelProgram) => {
const date = match(program)
.with({ type: 'content', date: P.not(P.nullish) }, (p) => dayjs(p.date))
.otherwise(() => undefined);
return date ? (
<Chip
key="release-date"
color="primary"
label={date.year()}
sx={{ mr: 1, mt: 1 }}
/>
) : null;
}, []);
const sourceChip = useCallback((program: ChannelProgram) => {
if (isContentProgram(program)) {
const id = find(
program.externalIds,
(eid) =>
eid.type === 'multi' &&
(eid.source === 'jellyfin' || eid.source === 'plex'),
);
if (!id) {
return null;
}
let icon: Maybe<JSX.Element> = undefined;
switch (id.source) {
case 'jellyfin':
icon = <JellyfinIcon />;
break;
case 'plex':
icon = <PlexIcon />;
break;
case 'emby':
icon = <EmbyIcon />;
break;
case 'plex-guid':
case 'imdb':
case 'tmdb':
case 'tvdb':
default:
break;
}
if (icon) {
return (
<Chip
key="source"
color="primary"
icon={<SvgIcon>{icon}</SvgIcon>}
label={capitalize(id.source)}
sx={{ mr: 1, mt: 1 }}
/>
);
}
}
return null;
}, []);
const timeChip = () => {
if (start && stop) {
return (
<Chip
key="time"
label={`${dayjs(start).format('LT')} - ${dayjs(stop).format('LT')}`}
sx={{ mt: 1, mr: 1 }}
color="primary"
/>
);
}
return null;
};
const chips = (program: ChannelProgram) => {
return compact([
durationChip(program),
ratingChip(program),
timeChip(),
sourceChip(program),
dateChip(program),
]);
};
const thumbnailImage: (m: ChannelProgram) => string | null = useMemo(
() =>
forProgramType({
content: (p) => {
let url: string | undefined;
if (p.persisted) {
let id: string | undefined = p.id;
if (p.subtype === 'track' && isNonEmptyString(p.albumId)) {
id = p.albumId;
}
url = `${settings.backendUri}/api/programs/${id}/thumb?proxy=true`;
}
if (isNonEmptyString(url)) {
return url;
}
let key = p.uniqueId;
if (p.subtype === 'track') {
if (isNonEmptyString(p.parent?.externalKey)) {
key = createExternalId(
p.externalSourceType,
tag(p.externalSourceId),
p.parent?.externalKey,
);
}
}
return `${settings.backendUri}/api/metadata/external?id=${key}&mode=proxy&asset=thumb`;
},
custom: (p) => (p.program ? thumbnailImage(p.program) : null),
}),
[settings.backendUri],
);
const externalLink = useMemo(
() =>
forProgramType({
content: (p) =>
p.id && p.persisted
? `${settings.backendUri}/api/programs/${p.id}/external-link`
: null,
}),
[settings.backendUri],
);
const thumbUrl = program ? thumbnailImage(program) : null;
const externalUrl = program ? externalLink(program) : null;
const programSummary = program ? summary(program) : null;
const programEpisodeTitle = program ? programTitle(program) : null;
useEffect(() => {
setThumbLoadState('loading');
}, [thumbUrl]);
const onLoad = useCallback(() => {
setThumbLoadState('success');
}, [setThumbLoadState]);
const onError: ReactEventHandler<HTMLImageElement> = useCallback((e) => {
console.error(e);
setThumbLoadState('error');
}, []);
return (
<Stack spacing={2}>
<Box>{chips(program)}</Box>
<Stack
direction="row"
spacing={smallViewport ? 0 : 2}
flexDirection={smallViewport ? 'column' : 'row'}
>
<Box sx={{ textAlign: 'center' }}>
<Box
component="img"
width={imageWidth}
src={thumbUrl ?? ''}
alt={formattedTitle(program)}
onLoad={onLoad}
ref={imageRef}
sx={{
display: thumbLoadState !== 'success' ? 'none' : undefined,
borderRadius: '10px',
}}
onError={onError}
/>
{thumbLoadState !== 'success' && (
<Skeleton
variant="rectangular"
width={smallViewport ? '100%' : imageWidth}
height={
program.type === 'content' && program.subtype === 'movie'
? 360
: smallViewport
? undefined
: 140
}
animation={thumbLoadState === 'loading' ? 'pulse' : false}
></Skeleton>
)}
</Box>
<Box>
{programEpisodeTitle ? (
<Typography variant="h5" sx={{ mb: 1 }}>
{programEpisodeTitle}
</Typography>
) : null}
{programSummary ? (
<Typography id="modal-modal-description" sx={{ mb: 1 }}>
{programSummary}
</Typography>
) : (
<Skeleton
animation={false}
variant="rectangular"
sx={{
backgroundColor: (theme) => theme.palette.background.default,
}}
width={imageWidth}
/>
)}
{externalUrl && isNonEmptyString(externalSourceName) && (
<Button
component="a"
target="_blank"
href={externalUrl}
size="small"
endIcon={<OpenInNew />}
variant="contained"
>
View in {externalSourceName}
</Button>
)}
</Box>
</Stack>
</Stack>
);
};

View File

@@ -1,21 +1,14 @@
interface TabPanelProps {
interface TabPanelProps<Type = number> {
children?: React.ReactNode;
index: number;
value: number;
index: Type;
value: Type;
}
export function TabPanel(props: TabPanelProps) {
export function TabPanel<Type = number>(props: TabPanelProps<Type>) {
const { children, value, index, ...other } = props;
return (
<div
role="tabpanel"
hidden={value !== index}
id={`simple-tabpanel-${index}`}
aria-labelledby={`simple-tab-${index}`}
{...other}
>
{/* {value === index && <Box sx={{ p: 3 }}>{children}</Box>} */}
<div role="tabpanel" hidden={value !== index} {...other}>
{value === index && children}
</div>
);

View File

@@ -2,7 +2,12 @@ import {
addSelectedMedia,
removeSelectedMedia,
} from '@/store/programmingSelector/actions.ts';
import { CheckCircle, Folder, RadioButtonUnchecked } from '@mui/icons-material';
import {
CheckCircle,
Folder,
InfoSharp,
RadioButtonUnchecked,
} from '@mui/icons-material';
import type { Theme } from '@mui/material';
import {
Box,
@@ -30,6 +35,7 @@ import type {
PlexSelectedMedia,
SelectedMedia,
} from '../../store/programmingSelector/store.ts';
import { NewProgramDetailsDialog } from '../programs/NewProgramDetailsDialog.tsx';
export type GridItemMetadata = {
itemId: string;
@@ -43,25 +49,25 @@ export type GridItemMetadata = {
thumbnailUrl: string;
selectedMedia?: SelectedMedia;
isFolder?: boolean;
persisted: boolean;
};
type Props<T> = {
item: T;
type Props<ItemTypeT> = {
item: ItemTypeT;
itemSource: SelectedMedia['type'];
// extractors: GridItemMetadataExtractors<T>;
metadata: GridItemMetadata;
style?: React.CSSProperties;
index: number;
isModalOpen: boolean;
onClick: (item: T) => void;
onSelect: (item: T) => void;
onClick: (item: ItemTypeT) => void;
onSelect: (item: ItemTypeT) => void;
depth: number;
enableSelection?: boolean;
disablePadding?: boolean;
};
const MediaGridItemInner = <T,>(
props: Props<T>,
const MediaGridItemInner = <ItemTypeT,>(
props: Props<ItemTypeT>,
ref: ForwardedRef<HTMLDivElement>,
) => {
const theme = useTheme();
@@ -70,6 +76,8 @@ const MediaGridItemInner = <T,>(
theme.palette.mode === 'light' ? 0.11 : 0.13,
);
const [dialogOpen, setDialogOpen] = useState(false);
const darkMode = useIsDarkMode();
const {
@@ -85,6 +93,7 @@ const MediaGridItemInner = <T,>(
childCount,
mayHaveChildren = false,
isFolder = false,
persisted,
},
style,
isModalOpen,
@@ -173,140 +182,178 @@ const MediaGridItemInner = <T,>(
[darkMode, depth, isModalOpen],
);
const showInfo = useCallback((e: React.SyntheticEvent) => {
e.preventDefault();
e.stopPropagation();
setDialogOpen(true);
}, []);
const hasChildren = (childCount ?? 0) > 0 || mayHaveChildren;
return (
<Fade
in={
isInViewport &&
!isUndefined(item) &&
(isFolder ||
(hasThumbnail &&
(imageLoaded === 'success' || imageLoaded === 'error')) ||
!hasThumbnail)
}
timeout={400}
ref={imageContainerRef}
>
<div>
<ImageListItem
component={Grid}
key={itemId}
sx={{
cursor: 'pointer',
display: 'flex',
flexDirection: 'column',
flexGrow: 1,
paddingLeft: disablePadding ? undefined : '8px !important',
paddingRight: disablePadding ? undefined : '8px',
paddingTop: disablePadding ? undefined : '8px',
height: 'auto',
backgroundColor: backgroundColor,
...style,
}}
onClick={(e) =>
(childCount ?? 0) > 0 || mayHaveChildren
? handleClick()
: toggleItemSelect(e)
}
ref={ref}
>
{isInViewport && // TODO: Eventually turn this into isNearViewport so images load before they hit the viewport
(isFolder ? (
<Box
<>
<Fade
in={
isInViewport &&
!isUndefined(item) &&
(isFolder ||
(hasThumbnail &&
(imageLoaded === 'success' || imageLoaded === 'error')) ||
!hasThumbnail)
}
timeout={400}
ref={imageContainerRef}
>
<div>
<ImageListItem
component={Grid}
key={itemId}
sx={{
cursor: enableSelection || hasChildren ? 'pointer' : 'default',
display: 'flex',
flexDirection: 'column',
flexGrow: 1,
paddingLeft: disablePadding ? undefined : '8px !important',
paddingRight: disablePadding ? undefined : '8px',
paddingTop: disablePadding ? undefined : '8px',
height: 'auto',
backgroundColor: backgroundColor,
...style,
}}
onClick={(e) => (hasChildren ? handleClick() : toggleItemSelect(e))}
ref={ref}
>
{persisted && !hasChildren && (
<InfoSharp
inheritViewBox
onClick={(e) => showInfo(e)}
sx={{
position: 'relative',
minHeight,
maxHeight: '100%',
textAlign: 'center',
position: 'absolute',
zIndex: 2,
top: disablePadding ? 8 : 16,
right: disablePadding ? 8 : 16,
cursor: 'pointer',
backgroundColor: (theme) =>
theme.palette.mode === 'dark'
? 'rgba(0, 0, 0, 0.7)'
: 'rgba(255, 255, 255, 0.7)',
borderRadius: '50%',
'&:hover': {
backgroundColor: (theme) =>
theme.palette.mode === 'dark'
? 'rgba(0, 0, 0, 1)'
: 'rgba(255, 255, 255, 1)',
},
}}
>
<Folder
sx={{
display: 'inline-block',
margin: '0 auto',
fontSize: '8em',
}}
/>
</Box>
) : hasThumbnail ? (
<Box
sx={{
position: 'relative',
minHeight,
maxHeight: '100%',
}}
>
<img
src={thumbnailUrl}
style={{
borderRadius: '5%',
height: 'auto',
width: '100%',
visibility:
imageLoaded === 'success' ? 'visible' : 'hidden',
zIndex: 2,
display: imageLoaded === 'error' ? 'none' : undefined,
}}
onLoad={() => setImageLoaded('success')}
onError={() => setImageLoaded('error')}
/>
<Box
component="div"
sx={{
background: skeletonBgColor,
borderRadius: '5%',
position:
imageLoaded === 'success' ? 'absolute' : 'relative',
top: 0,
left: 0,
aspectRatio:
aspectRatio === 'square'
? '1/1'
: aspectRatio === 'landscape'
? '1.77/1'
: '2/3',
width: '100%',
height: 'auto',
zIndex: 1,
opacity: imageLoaded === 'success' ? 0 : 1,
visibility:
imageLoaded === 'success' ? 'hidden' : 'visible',
minHeight,
}}
></Box>
</Box>
) : (
<Skeleton
animation={false}
variant="rounded"
sx={{ borderRadius: '5%' }}
height={minHeight}
/>
))}
<ImageListItemBar
title={title}
subtitle={subtitle}
position="below"
actionIcon={
enableSelection ? (
<IconButton
aria-label={`star ${title}`}
onClick={(event: MouseEvent<HTMLButtonElement>) =>
toggleItemSelect(event)
}
)}
{isInViewport && // TODO: Eventually turn this into isNearViewport so images load before they hit the viewport
(isFolder ? (
<Box
sx={{
position: 'relative',
minHeight,
maxHeight: '100%',
textAlign: 'center',
}}
>
{isSelected ? <CheckCircle /> : <RadioButtonUnchecked />}
</IconButton>
) : null
}
actionPosition="right"
/>
</ImageListItem>
</div>
</Fade>
<Folder
sx={{
display: 'inline-block',
margin: '0 auto',
fontSize: '8em',
}}
/>
</Box>
) : hasThumbnail ? (
<Box
sx={{
position: 'relative',
minHeight,
maxHeight: '100%',
}}
>
<img
src={thumbnailUrl}
style={{
borderRadius: '5%',
height: 'auto',
width: '100%',
visibility:
imageLoaded === 'success' ? 'visible' : 'hidden',
zIndex: 2,
display: imageLoaded === 'error' ? 'none' : undefined,
}}
onLoad={() => setImageLoaded('success')}
onError={() => setImageLoaded('error')}
/>
<Box
component="div"
sx={{
background: skeletonBgColor,
borderRadius: '5%',
position:
imageLoaded === 'success' ? 'absolute' : 'relative',
top: 0,
left: 0,
aspectRatio:
aspectRatio === 'square'
? '1/1'
: aspectRatio === 'landscape'
? '1.77/1'
: '2/3',
width: '100%',
height: 'auto',
zIndex: 1,
opacity: imageLoaded === 'success' ? 0 : 1,
visibility:
imageLoaded === 'success' ? 'hidden' : 'visible',
minHeight,
}}
></Box>
</Box>
) : (
<Skeleton
animation={false}
variant="rounded"
sx={{ borderRadius: '5%' }}
height={minHeight}
/>
))}
<ImageListItemBar
title={title}
subtitle={subtitle}
position="below"
actionIcon={
enableSelection ? (
<IconButton
aria-label={`star ${title}`}
onClick={(event: MouseEvent<HTMLButtonElement>) =>
toggleItemSelect(event)
}
>
{isSelected ? <CheckCircle /> : <RadioButtonUnchecked />}
</IconButton>
) : null
}
actionPosition="right"
/>
</ImageListItem>
</div>
</Fade>
{!hasChildren && persisted && (
<NewProgramDetailsDialog
open={dialogOpen}
onClose={() => setDialogOpen(false)}
programId={itemId}
/>
)}
</>
);
};
// );
export const MediaGridItem = forwardRef(MediaGridItemInner) as <T>(
props: Props<T> & { ref?: ForwardedRef<HTMLDivElement> },
export const MediaGridItem = forwardRef(MediaGridItemInner) as <ItemTypeT>(
props: Props<ItemTypeT> & { ref?: ForwardedRef<HTMLDivElement> },
) => ReturnType<typeof MediaGridItemInner>;

View File

@@ -50,6 +50,7 @@ export interface GridItemProps<ItemType> {
depth: number;
ref: ForwardedRef<HTMLDivElement>;
disableSelection?: boolean;
persisted?: boolean; // Temp hack
}
export interface ListItemProps<ItemType> {

View File

@@ -181,7 +181,6 @@ export const JellyfinProgramGrid = ({
const renderGridItem = useCallback(
(props: GridItemProps<ProgramOrFolder>) => (
<ProgramGridItem key={props.item.uuid} {...props} />
// <JellyfinGridItem key={props.item.Id} {...props} />
),
[],
);

View File

@@ -68,6 +68,7 @@ const GridItemImpl = forwardRef(
subtitle: `${prettyItemDuration(program.duration)}${year ? ` (${year})` : ''}`,
thumbnailUrl: `${backendUri}/api/programs/${program.id}/thumb`,
title: program.title,
persisted: true,
} satisfies GridItemMetadata;
}, [
backendUri,
@@ -123,6 +124,8 @@ const ParentGridItemImpl = forwardRef(
subtitle: year ? (program.year?.toString() ?? null) : null,
thumbnailUrl: `${backendUri}/api/programs/${program.id}/thumb`,
title: program.title ?? '',
mayHaveChildren: true,
persisted: true,
} satisfies GridItemMetadata;
}, [backendUri, program.id, program.title, program.type, program.year]);

View File

@@ -13,8 +13,7 @@ import {
addKnownMediaForServer,
setSearchRequest,
} from '../../store/programmingSelector/actions.ts';
import type {
RenderNestedGrid} from '../channel_config/MediaItemGrid.tsx';
import type { RenderNestedGrid } from '../channel_config/MediaItemGrid.tsx';
import {
MediaItemGrid,
type GridItemProps,
@@ -150,6 +149,7 @@ export const LibraryProgramGrid = ({
<ProgramGridItem
key={gridItemProps.item.uuid}
disableSelection={disableProgramSelection}
persisted
{...gridItemProps}
/>
);

View File

@@ -1,11 +1,12 @@
import { useSettings } from '@/store/settings/selectors.ts';
import { createExternalId } from '@tunarr/shared';
import { getChildItemType, Library, ProgramOrFolder, tag } from '@tunarr/types';
import type { Library, ProgramOrFolder } from '@tunarr/types';
import { getChildItemType, tag } from '@tunarr/types';
import { isEqual } from 'lodash-es';
import pluralize from 'pluralize';
import type { JSX } from 'react';
import {
forwardRef,
JSX,
memo,
useCallback,
useMemo,
@@ -63,7 +64,13 @@ export const ProgramGridItem = memo(
props: GridItemProps<T>,
ref: ForwardedRef<HTMLDivElement>,
) => {
const { item, index, moveModal, disableSelection } = props;
const {
item,
index,
moveModal,
disableSelection,
persisted = false,
} = props;
const settings = useSettings();
const currentServer = useCurrentMediaSource();
@@ -142,8 +149,17 @@ export const ProgramGridItem = memo(
: 'portrait',
isPlaylist: false,
isFolder: item.type === 'folder',
persisted,
}) satisfies GridItemMetadata,
[isEpisode, isMusicItem, item, thumbnailUrlFunc],
[
currentServer,
isEpisode,
isMusicItem,
item,
persisted,
props.item.sourceType,
thumbnailUrl,
],
);
return (

View File

@@ -0,0 +1,97 @@
import { Close, CopyAll } from '@mui/icons-material';
import {
Box,
Button,
Dialog,
DialogContent,
DialogTitle,
IconButton,
LinearProgress,
Skeleton,
useMediaQuery,
useTheme,
} from '@mui/material';
import { useQuery } from '@tanstack/react-query';
import { result } from 'lodash-es';
import { getApiProgramsByIdOptions } from '../../generated/@tanstack/react-query.gen.ts';
import { useCopyToClipboard } from '../../hooks/useCopyToClipboard.ts';
type Props = {
open: boolean;
onClose: () => void;
programId: string;
};
export const NewProgramDetailsDialog = ({
onClose,
open,
programId,
}: Props) => {
const theme = useTheme();
const smallViewport = useMediaQuery(theme.breakpoints.down('sm'));
const copy = useCopyToClipboard();
const query = useQuery({
...getApiProgramsByIdOptions({
path: {
id: programId,
},
}),
enabled: open,
});
return (
<Dialog
open={open}
fullScreen={smallViewport}
maxWidth="md"
fullWidth
onClose={() => onClose()}
>
{query.isLoading ? (
<Skeleton>
<DialogTitle />
</Skeleton>
) : (
<DialogTitle
variant="h4"
sx={{ display: 'flex', alignItems: 'center' }}
>
<Box sx={{ flex: 1 }}>{query.data?.title} </Box>
<IconButton
edge="start"
color="inherit"
onClick={() => onClose()}
aria-label="close"
// sx={{ position: 'absolute', top: 10, right: 10 }}
size="large"
>
<Close />
</IconButton>
</DialogTitle>
)}
<DialogContent>
{/* <ProgramStreamDetails programId={item.uuid} /> */}
{query.isLoading && <LinearProgress />}
<Box sx={{ maxHeight: '70vh' }}>
{query.data && (
<>
<Button
onClick={() =>
copy(JSON.stringify(result, undefined, 2)).catch((e) =>
console.error(e),
)
}
startIcon={<CopyAll />}
>
Copy to Clipboard
</Button>
<pre>{JSON.stringify(query.data, undefined, 4)}</pre>
</>
)}
</Box>
</DialogContent>
</Dialog>
);
};

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@@ -5,6 +5,7 @@ import { seq } from '@tunarr/shared/util';
import type { PlexServerSettings } from '@tunarr/types';
import { flatten, isNil, reject, sumBy } from 'lodash-es';
import { useCallback, useMemo } from 'react';
import { getApiPlexByMediaSourceIdLibrariesByLibraryIdCollectionsInfiniteQueryKey } from '../../generated/@tanstack/react-query.gen.ts';
import { getApiPlexByMediaSourceIdLibrariesByLibraryIdCollections } from '../../generated/sdk.gen.ts';
import { addKnownMediaForServer } from '../../store/programmingSelector/actions.ts';
import { useQueryObserver } from '../useQueryObserver.ts';
@@ -17,13 +18,19 @@ export const usePlexCollectionsInfinite = (
) => {
const queryOpts = useMemo(() => {
return infiniteQueryOptions({
queryKey: [
'plex',
plexServer?.id,
currentLibrary?.library.externalId,
'collections',
'infinite',
],
queryKey:
getApiPlexByMediaSourceIdLibrariesByLibraryIdCollectionsInfiniteQueryKey(
{
path: {
mediaSourceId: plexServer!.id,
libraryId: currentLibrary!.library.externalId,
},
query: {
// offset: pageParam,
limit: pageSize,
},
},
),
queryFn: async (ctx) => {
const { pageParam } = ctx;
const result =