mirror of
https://github.com/chrisbenincasa/tunarr.git
synced 2026-04-18 09:03:35 -04:00
feat: save program media versions to DB (#1379)
This commit is contained in:
committed by
GitHub
parent
4a53eba59d
commit
b7b9d914c2
1
docs/generated/tunarr-v0.23.0-alpha.6-openapi.json
Normal file
1
docs/generated/tunarr-v0.23.0-alpha.6-openapi.json
Normal file
File diff suppressed because one or more lines are too long
@@ -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!,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -543,7 +543,6 @@ export const mediaSourceRouter: RouterPluginAsyncCallback = async (
|
||||
}, 60000);
|
||||
}),
|
||||
]);
|
||||
console.log(status);
|
||||
|
||||
return res.send(status);
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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[],
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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[];
|
||||
};
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
|
||||
|
||||
@@ -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>,
|
||||
|
||||
36
server/src/db/schema/ProgramChapter.ts
Normal file
36
server/src/db/schema/ProgramChapter.ts
Normal 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>;
|
||||
@@ -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>;
|
||||
|
||||
@@ -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<
|
||||
|
||||
5
server/src/db/schema/ProgramMediaFile.ts
Normal file
5
server/src/db/schema/ProgramMediaFile.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
// import { sqliteTable, text } from 'drizzle-orm/sqlite-core';
|
||||
|
||||
// export const ProgramMediaFile = sqliteTable('program_media_file', {
|
||||
// uuid: text().primaryKey(),
|
||||
// });
|
||||
61
server/src/db/schema/ProgramMediaStream.ts
Normal file
61
server/src/db/schema/ProgramMediaStream.ts
Normal 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>;
|
||||
51
server/src/db/schema/ProgramVersion.ts
Normal file
51
server/src/db/schema/ProgramVersion.ts
Normal 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>;
|
||||
@@ -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;
|
||||
|
||||
@@ -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[];
|
||||
45
server/src/db/schema/index.ts
Normal file
45
server/src/db/schema/index.ts
Normal 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>;
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
64
server/src/external/plex/PlexApiClient.ts
vendored
64
server/src/external/plex/PlexApiClient.ts
vendored
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { makeKyselyMigrationFromSqlFile } from './util.ts';
|
||||
|
||||
export default makeKyselyMigrationFromSqlFile(
|
||||
'./sql/0012_yielding_ben_grimm.sql',
|
||||
);
|
||||
49
server/src/migration/db/sql/0012_yielding_ben_grimm.sql
Normal file
49
server/src/migration/db/sql/0012_yielding_ben_grimm.sql
Normal 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`);
|
||||
2427
server/src/migration/db/sql/meta/0012_snapshot.json
Normal file
2427
server/src/migration/db/sql/meta/0012_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
);
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -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]),
|
||||
|
||||
@@ -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]),
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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
@@ -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'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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>>;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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={
|
||||
|
||||
386
web/src/components/ProgramMetadataDialogContent.tsx
Normal file
386
web/src/components/ProgramMetadataDialogContent.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -50,6 +50,7 @@ export interface GridItemProps<ItemType> {
|
||||
depth: number;
|
||||
ref: ForwardedRef<HTMLDivElement>;
|
||||
disableSelection?: boolean;
|
||||
persisted?: boolean; // Temp hack
|
||||
}
|
||||
|
||||
export interface ListItemProps<ItemType> {
|
||||
|
||||
@@ -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} />
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
|
||||
97
web/src/components/programs/NewProgramDetailsDialog.tsx
Normal file
97
web/src/components/programs/NewProgramDetailsDialog.tsx
Normal 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
@@ -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 =
|
||||
|
||||
Reference in New Issue
Block a user