Merge remote-tracking branch 'origin/main' into dev

This commit is contained in:
Christian Benincasa
2026-01-23 11:40:30 -05:00
28 changed files with 266 additions and 292 deletions

View File

@@ -10,19 +10,20 @@ Tunarr is available on [Docker Hub](https://hub.docker.com/r/chrisbenincasa/tuna
Since Tunarr is currently pre-release. There are a few tags to choose from which have different releae cadences:
- `x.x.x` (versioned): These are release cuts. Because we are pre-1.0.0, breaking changes cause major version bumps and bug fixes are patch version bumps. Once we achieve 1.0.0, we will use proper semver.
- `latest`: The latest tag points at the most recent release version.
- `edge`: Pushed every 2 hours off of the "dev" branch. This build can be unstable. **NOTE**: If switching from a versioned/latest build to an edge build, it's recommended to take a backup of your entire Tunarr data directory. Downgrading from "edge" to a previous version is not supported; edge builds can contain non-backwards compatible changes, like database schema changes.
- `x.x.x` (versioned): These are stable release builds. In general, once a `x.x.0` release is cut (e.g. `1.1.0`), only bug fixes (patch releases) will be made to the release line. Major version bumps happen when backwards incompatible changes are made, particularly to the database.
- `latest`: The latest tag points at the most recent, stable release build.
- `x.x.x-dev.x`: These are pre-release build. Most new features are added on pre-releases before merged back to `latest` for a stable release.
- `dev`: Points to the latest pre-release build. This build can be unstable. **NOTE**: If switching from a versioned/latest build to an dev build, it's recommended to take a backup of your entire Tunarr data directory. Downgrading from "dev" to a previous version is not supported; dev builds can contain non-backwards compatible changes, like database schema changes.
[Docker Hub](https://hub.docker.com/r/chrisbenincasa/tunarr/):
- `chrisbenincasa/tunarr:latest`
- `chrisbenincasa/tunarr:edge`
- `chrisbenincasa/tunarr:dev`
[GHCR](https://github.com/chrisbenincasa/tunarr/pkgs/container/tunarr):
- `ghcr.io/chrisbenincasa/tunarr:latest`
- `ghcr.io/chrisbenincasa/tunarr:edge`
- `ghcr.io/chrisbenincasa/tunarr:dev`
Currently ARM builds are published under separate tags. Take any of the tags above and append `-arm64` to get a Docker image for ARM-based hosts.
@@ -40,10 +41,8 @@ TODO! If you run Tunarr on Synology and would like to contribute a guide for Tun
Tunarr is released in pre-built binaries for Linux (x64/ARM), Windows (x64), and macOS (x64/ARM). Tunarr currently does not provide a version of FFmpeg along with these binaries, so you must have your own build ready to go. We recommend using the pre-built FFmpeg 7.1.1 binaries provided by [ErsatzTV](https://github.com/ErsatzTV/ErsatzTV-ffmpeg/releases/tag/7.1.1). If these don't work, builds from [BtbN/FFmpegBuilds](https://github.com/BtbN/FFmpeg-Builds) or [gyan.dev](https://www.gyan.dev/ffmpeg/builds/) should _generally_ work as well. If you are planning on using hardware acceleration, ensure that the build of FFmpeg you use includes the proper libraries built-in.
Like Docker images, binaries are released with versions as well as a singular 'edge' build which is released bihourly.
* [Latest release](http://github.com/chrisbenincasa/tunarr/releases/latest)
* [Edge release](https://github.com/chrisbenincasa/tunarr/releases/tag/edge)
* [Pre-releases](https://github.com/chrisbenincasa/tunarr/releases?q=-dev&expanded=true)
* [All releases](https://github.com/chrisbenincasa/tunarr/releases)
## Proxmox

View File

@@ -216,8 +216,8 @@ After=network-online.target
Type=simple
WorkingDirectory=/opt/tunarr
ExecStart=bash /opt/tunarr/tunarr-linux-x64
ExecReload=pkill tunarr-linux-x64
ExecStop=pkill tunarr-linux-x64
ExecReload=pkill -INT tunarr-linux-x64
ExecStop=pkill -INT tunarr-linux-x64
KillMode=process
Restart=always
RestartSec=15

View File

@@ -11,3 +11,5 @@ catalog:
random-js: 2.1.0
typescript: 5.7.3
zod: ^4.1.5
enablePrePostScripts: true

3
server/.gitignore vendored
View File

@@ -10,6 +10,5 @@ streams/
bin/
src/generated/**
!src/generated/web-imports.d.ts
!src/generated/.gitkeep
!src/generated/env.ts
emby.ts

View File

@@ -15,7 +15,9 @@
"make-bin": "dotenvx run -- tsx scripts/make-bin.ts",
"clean": "rimraf --glob ./build/ ./dist/ ./bin/*",
"debug": "cross-env NODE_ENV=development dotenvx run -f .env.development -- tsx watch --trace-warnings --tsconfig ./tsconfig.build.json --ignore 'src/streams' --inspect-wait ./src",
"predev": "tsx scripts/generateEnvModule.ts",
"dev": "cross-env NODE_ENV=development dotenvx run -f .env.development -- tsx watch --heap-snapshot-on-oom --trace-warnings --tsconfig ./tsconfig.build.json --ignore 'build' --ignore 'src/streams' --ignore 'src/**/*.test.ts' ./src/index.ts",
"generate-env": "tsx scripts/generateEnvModule.ts",
"generate-openapi": "tsx src/index.ts generate-openapi",
"install-meilisearch": "tsx scripts/download-meilisearch.ts",
"lint": "eslint . --report-unused-disable-directives --max-warnings 0",
@@ -136,10 +138,5 @@
},
"meilisearch": {
"version": "1.30.0"
},
"pnpm": {
"onlyBuiltDependencies": [
"better-sqlite3"
]
}
}

View File

@@ -1,96 +0,0 @@
import 'dotenv/config';
import esbuild from 'esbuild';
import esbuildPluginPino from 'esbuild-plugin-pino';
import fg from 'fast-glob';
import fs from 'node:fs';
import { basename } from 'node:path';
import { rimraf } from 'rimraf';
import { nativeNodeModulesPlugin } from '../esbuild/native-node-module.js';
import { nodeProtocolPlugin } from '../esbuild/node-protocol.js';
const DIST_DIR = 'dist';
if (fs.existsSync(DIST_DIR)) {
console.log('Deleting old build...');
await rimraf(DIST_DIR);
}
fs.mkdirSync(DIST_DIR);
console.log('Copying images...');
fs.cpSync('src/resources/images', `${DIST_DIR}/resources/images`, {
recursive: true,
});
const isEdgeBuild = process.env.TUNARR_EDGE_BUILD === 'true';
console.log('Bundling app...');
const result = await esbuild.build({
entryPoints: {
bundle: 'src/index.ts',
},
outExtension: {
'.js': '.cjs',
},
bundle: true,
minify: true,
outdir: DIST_DIR,
logLevel: 'info',
format: 'cjs',
platform: 'node',
target: 'node22',
inject: [
'./esbuild/bundlerPathsOverrideShim.ts',
'./esbuild/importMetaUrlShim.ts',
],
tsconfig: './tsconfig.build.json',
external: [
'mysql',
'mysql2',
'sqlite3',
'pg',
'tedious',
'pg-query-stream',
'oracledb',
'mariadb',
'libsql',
],
mainFields: ['module', 'main'],
plugins: [
nativeNodeModulesPlugin(),
nodeProtocolPlugin(),
// copy({
// resolveFrom: 'cwd',
// assets: {
// from: ['node_modules/@fastify/swagger-ui/static/*'],
// to: ['build/static'],
// },
// }),
esbuildPluginPino({
transports: ['pino-pretty', 'pino-roll'],
}),
],
keepNames: true, // This is to ensure that Entity class names remain the same
metafile: true,
define: {
'process.env.NODE_ENV': '"production"',
'process.env.TUNARR_VERSION': `"${process.env.TUNARR_VERSION}"`,
'process.env.TUNARR_BUILD': `"${process.env.TUNARR_BUILD}"`,
'process.env.TUNARR_EDGE_BUILD': `"${isEdgeBuild}"`,
'import.meta.url': '__import_meta_url',
'import.meta.dirname': '__import_meta_dirname',
},
});
fs.writeFileSync(`${DIST_DIR}/meta.json`, JSON.stringify(result.metafile));
fs.cpSync('package.json', `${DIST_DIR}/package.json`);
const nativeBindings = await fg('node_modules/better-sqlite3/**/*.node');
for (const binding of nativeBindings) {
console.log(`Copying ${binding} to out dir`);
fs.cpSync(binding, `${DIST_DIR}/build/${basename(binding)}`);
}
console.log('Done bundling!');
process.exit(0);

View File

@@ -12,6 +12,7 @@ import { nodeProtocolPlugin } from '../esbuild/node-protocol.ts';
import { trimStart } from 'lodash-es';
import { createRequire } from 'node:module';
import { generateEnvModule } from './generateEnvModule.ts';
const __require = createRequire(import.meta.url);
const esbuildPluginPino = __require('esbuild-plugin-pino');
@@ -29,17 +30,15 @@ fs.cpSync('src/resources/images', `${DIST_DIR}/resources/images`, {
recursive: true,
});
const tunarrKeyVals: Record<string, string> = {};
for (const [key, val] of Object.entries(process.env)) {
if (key.startsWith('TUNARR_') && val) {
tunarrKeyVals[key] = val;
}
}
console.debug(format('Building with Tunarr env: %O', tunarrKeyVals));
const isEdgeBuild = process.env.TUNARR_EDGE_BUILD === 'true';
// TODO: Do we want to hard-code any TUNARR_ prefixed environment variables at build time?
await generateEnvModule([
'NODE_ENV',
'TUNARR_VERSION',
'TUNARR_BUILD',
'TUNARR_EDGE_BUILD',
]);
const define = {
'process.env.NODE_ENV': '"production"',
'process.env.TUNARR_VERSION': `"${trimStart(process.env.TUNARR_VERSION, 'v')}"`,
@@ -48,7 +47,7 @@ const define = {
'import.meta.url': '__import_meta_url',
};
console.debug('Inlining environment to bundle: ', define);
console.debug(format('Building with Tunarr env: %O', define));
console.log('Bundling app...');
const result = await esbuild.build({

View File

@@ -0,0 +1,31 @@
import fs from 'node:fs/promises';
export async function generateEnvModule(keysToInline: string[]) {
const entries = keysToInline
.map((key) => {
const value = process.env[key];
if (!!value) {
return ` ${key}: ${JSON.stringify(value)},`;
}
return null;
})
.filter(Boolean)
.join('\n');
const moduleContent = `
// AUTO-GENERATED - DO NOT EDIT MANUALLY
// Generated a build time by bundle.ts
export const BUILD_ENV: Record<string, string> = {
${entries}
} as const;
export const BUILD_ENV_KEYS = new Set(Object.keys(BUILD_ENV));
`;
await fs.writeFile('./src/generated/env.ts', moduleContent);
}
if (process.argv[1] === import.meta.filename) {
await generateEnvModule([]);
}

View File

@@ -797,6 +797,12 @@ export const mediaSourceRouter: RouterPluginAsyncCallback = async (
async (req, res) => {
try {
await req.serverCtx.mediaSourceDB.updateMediaSource(req.body);
if (req.body.type === 'local') {
await req.serverCtx.mediaSourceScanCoordinator.addLocal({
mediaSourceId: tag(req.body.id),
forceScan: false,
});
}
req.serverCtx.eventService.push({
type: 'settings-update',
message: `Media source ${req.body.name} updated.`,
@@ -848,6 +854,12 @@ export const mediaSourceRouter: RouterPluginAsyncCallback = async (
const newServerId = await req.serverCtx.mediaSourceDB.addMediaSource(
req.body,
);
if (req.body.type === 'local') {
await req.serverCtx.mediaSourceScanCoordinator.addLocal({
mediaSourceId: newServerId,
forceScan: false,
});
}
req.serverCtx.eventService.push({
type: 'settings-update',
message: `Media source "${req.body.name}" added.`,

View File

@@ -43,6 +43,7 @@ import {
isPodman,
isRunningInContainer,
} from '../util/containerUtil.ts';
import { getEnvVar, TUNARR_ENV_VARS } from '../util/env.ts';
import { streamFileBackwards } from '../util/fsUtil.ts';
import { take } from '../util/streams.ts';
@@ -457,19 +458,20 @@ export const systemApiRouter: RouterPluginAsyncCallback = async (
},
},
async (_, res) => {
const matching = seq.collect(
Object.entries(process.env),
([key, val]) => {
if (key.startsWith('TUNARR_') && val) {
return [key, val] as const;
} else if ((key === 'NODE_ENV' || key === 'LOG_LEVEL') && val) {
return [key, val] as const;
}
return;
},
);
const matches = seq.collect(Object.values(TUNARR_ENV_VARS), (key) => {
const value = getEnvVar(key);
if (!value) return;
return [key, value] as const;
});
const obj = Object.fromEntries(matches);
if (process.env.NODE_ENV) {
obj['NODE_ENV'] = process.env.NODE_ENV;
}
if (process.env.LOG_LEVEL) {
obj['LOG_LEVEL'] = process.env.LOG_LEVEL;
}
return res.send(Object.fromEntries(matching));
return res.send(obj);
},
);

View File

@@ -1122,7 +1122,7 @@ export class ProgramDB implements IProgramDB {
this.logger.debug('Scheduling follow-up program tasks...');
GlobalScheduler.scheduleOneOffTask(
ReconcileProgramDurationsTask.KEY,
autoFactoryKey(ReconcileProgramDurationsTask),
dayjs().add(500, 'ms'),
[],
);

View File

@@ -352,7 +352,9 @@ export class MediaSourceDB {
.executeTakeFirstOrThrow();
}
async addMediaSource(server: InsertMediaSourceRequest): Promise<string> {
async addMediaSource(
server: InsertMediaSourceRequest,
): Promise<MediaSourceId> {
const name = tag<MediaSourceName>(server.name);
const sendGuideUpdates =
server.type === 'plex' ? (server.sendGuideUpdates ?? false) : false;

View File

View File

@@ -0,0 +1,9 @@
// AUTO-GENERATED - DO NOT EDIT MANUALLY
// Generated a build time by bundle.ts
export const BUILD_ENV: Record<string, string> = {
} as const;
export const BUILD_ENV_KEYS = new Set(Object.keys(BUILD_ENV));

View File

@@ -1,3 +1,4 @@
import { BUILD_ENV, BUILD_ENV_KEYS } from '@/generated/env.js';
import { TruthyQueryParam } from '../types/schemas.ts';
import type { Nullable } from '../types/util.ts';
import { isNonEmptyString, parseIntOrNull } from './index.ts';
@@ -55,6 +56,9 @@ export const TUNARR_ENV_VARS = {
type ValidEnvVar = (typeof TUNARR_ENV_VARS)[keyof typeof TUNARR_ENV_VARS];
export function getEnvVar(name: ValidEnvVar): Nullable<string> {
if (BUILD_ENV_KEYS.has(name)) {
return BUILD_ENV[name] ?? null;
}
const val = process.env[name];
return isNonEmptyString(val) ? val : null;
}

View File

@@ -459,7 +459,7 @@ export function flipMap<K extends string | number, V extends string | number>(
export const filename = (path: string) => fileURLToPath(path);
export const currentEnv = once(() => {
const env = process.env['NODE_ENV'];
const env = process.env.NODE_ENV;
return env ?? 'production';
});

View File

@@ -1,9 +1,7 @@
{
"extends": "./tsconfig.json",
"include": [
"./src/**/*.ts",
"mikro-orm.prod.config.ts",
"mikro-orm.base.config.ts"
"./src/**/*",
],
"exclude": [
"./dist/**/*",

View File

@@ -42,8 +42,8 @@
"skipLibCheck": true,
},
"include": [
"./src/**/*.ts",
"./scripts/**/*.ts",
"./src/**/*",
"./scripts/**/*",
],
"exclude": [
"./src/**/*.ignore.ts",

View File

@@ -1,74 +1,45 @@
{
"$schema": "https://turbo.build/schema.json",
"extends": [
"//"
],
"extends": ["//"],
"tasks": {
"build": {},
"build": {
"dependsOn": ["^build", "generate-env"]
},
"typecheck": {
"dependsOn": [
"^build"
]
"dependsOn": ["^build", "generate-env"]
},
"bundle": {
"inputs": [
"./scripts/bundle.ts",
"./src/**"
],
"dependsOn": [
"^build",
"@tunarr/web#bundle"
],
"outputs": [
"dist/**"
]
"inputs": ["./scripts/bundle.ts", "./src/**"],
"dependsOn": ["^build", "@tunarr/web#bundle"],
"outputs": ["dist/**"]
},
"build-dev": {
"dependsOn": [
"^build"
]
"dependsOn": ["^build", "generate-env"]
},
"make-bin": {
"cache": false,
"dependsOn": [
"@tunarr/web#bundle",
"bundle"
],
"outputs": [
"bin/**"
]
"dependsOn": ["@tunarr/web#bundle", "bundle"],
"outputs": ["bin/**"]
},
"lint-staged": {},
"lint": {
"dependsOn": [
"lint-staged"
]
"dependsOn": ["lint-staged"]
},
"install-meilisearch": {
"inputs": [
"./scripts/download-meilisearch.ts"
],
"dependsOn": [
"@tunarr/shared#build"
],
"inputs": ["./scripts/download-meilisearch.ts"],
"dependsOn": ["@tunarr/shared#build"],
"cache": false
},
"dev": {
"dependsOn": [
"install-meilisearch",
"@tunarr/shared#build"
],
"dependsOn": ["install-meilisearch", "@tunarr/shared#build"],
"persistent": true,
"cache": false,
"interruptible": true
},
"generate-openapi": {
"inputs": [
"./src/**"
],
"dependsOn": [
"^build"
]
}
"inputs": ["./src/**"],
"dependsOn": ["^build"]
},
"generate-env": {}
}
}
}

View File

@@ -13,7 +13,7 @@ import type {
import { createToken, EmbeddedActionsParser, Lexer } from 'chevrotain';
import dayjs from 'dayjs';
import customParseFormat from 'dayjs/plugin/customParseFormat.js';
import { identity, invert, isArray, isNumber } from 'lodash-es';
import { head, identity, isArray, isNumber } from 'lodash-es';
import type {
Dictionary,
NonEmptyArray,
@@ -21,6 +21,7 @@ import type {
StrictOmit,
} from 'ts-essentials';
import { match, P } from 'ts-pattern';
import { invert } from './seq.js';
dayjs.extend(customParseFormat);
@@ -88,6 +89,7 @@ const NumericFields = [
'video_width',
'audio_channels',
'release_year',
'year',
] as const;
const NumericField = createToken({
@@ -342,7 +344,7 @@ export const virtualFieldToIndexField: Record<string, string> = {
audio_channels: 'audioChannels',
};
const indexFieldToVirtualField = invert(virtualFieldToIndexField);
const indexFieldToVirtualField = invert(virtualFieldToIndexField, true);
const indexOperatorToSyntax: Dictionary<string> = {
contains: '~',
@@ -1085,28 +1087,29 @@ export function searchFilterToString(
return '';
} else if (input.fieldSpec.value.length === 1) {
const value = input.fieldSpec.value[0];
let repr: string;
if (value.includes(' ')) {
repr = `"${value}"`;
} else {
repr = value;
}
return `${input.fieldSpec.key} ${input.fieldSpec.op} ${repr}`;
const repr = `"${value}"`;
const key =
head(indexFieldToVirtualField[input.fieldSpec.key]) ??
input.fieldSpec.key;
const op =
head(indexOperatorToSyntax[input.fieldSpec.op]) ?? input.fieldSpec.op;
return `${key} ${op} ${repr}`;
} else {
const components: string[] = [];
for (const x of input.fieldSpec.value) {
if (isNumber(x)) {
components.push(x.toString());
} else {
components.push(x.includes(' ') ? `"${x}"` : x);
components.push(`"${x}"`);
}
}
valueString = `[${components.join(', ')}]`;
}
const key =
indexFieldToVirtualField[input.fieldSpec.key] ?? input.fieldSpec.key;
head(indexFieldToVirtualField[input.fieldSpec.key]) ??
input.fieldSpec.key;
const op =
indexOperatorToSyntax[input.fieldSpec.op] ?? input.fieldSpec.op;
head(indexOperatorToSyntax[input.fieldSpec.op]) ?? input.fieldSpec.op;
return `${key} ${op} ${valueString}`;
}
}

View File

@@ -185,3 +185,29 @@ export function inConstArr<Arr extends readonly string[], S extends string>(
return false;
}
export function invert<K extends keyof any, V extends keyof any>(
input: Record<K, V>,
multi: true,
): Record<V, Array<K>>;
export function invert<K extends keyof any, V extends keyof any>(
input: Record<K, V>,
multi: false,
): Record<V, K>;
export function invert<K extends keyof any, V extends keyof any>(
input: Record<K, V>,
multi: boolean,
): Record<V, Array<K>> | Record<V, K> {
const out = {} as Record<V, Array<K>>;
for (const [k, v] of Object.entries<V>(input)) {
out[k] ??= [];
out[k].push(v);
}
if (!multi) {
return Object.fromEntries(
Object.entries<K[]>(out).map(([k, [v]]) => [k, v] as const),
) as Record<V, K>;
}
return out;
}

View File

@@ -140,21 +140,23 @@ const MediaSourceLibraryTableActionCell = ({
return (
<>
<Tooltip placement="top" title="View Library">
{mediaSource.type === 'local' ? (
<RouterIconButtonLink
to="/media_sources/$mediaSourceId"
params={{ mediaSourceId: mediaSource.id }}
>
<VideoLibrary />
</RouterIconButtonLink>
) : (
<RouterIconButtonLink
to={'/media_sources/$mediaSourceId/libraries/$libraryId'}
params={{ mediaSourceId: mediaSource.id, libraryId: library.id }}
>
<VideoLibrary />
</RouterIconButtonLink>
)}
<Box component="span">
{mediaSource.type === 'local' ? (
<RouterIconButtonLink
to="/media_sources/$mediaSourceId"
params={{ mediaSourceId: mediaSource.id }}
>
<VideoLibrary />
</RouterIconButtonLink>
) : (
<RouterIconButtonLink
to={'/media_sources/$mediaSourceId/libraries/$libraryId'}
params={{ mediaSourceId: mediaSource.id, libraryId: library.id }}
>
<VideoLibrary />
</RouterIconButtonLink>
)}
</Box>
</Tooltip>
<Tooltip
placement="top"

View File

@@ -57,6 +57,7 @@ import {
import pluralize from 'pluralize';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { P, match } from 'ts-pattern';
import { formatSlotOrder } from '../../helpers/slots.ts';
import { useSlotName } from '../../hooks/slot_scheduler/useSlotName.ts';
import type { SlotViewModel } from '../../model/SlotModels.ts';
import type { Nullable } from '../../types/util.ts';
@@ -257,18 +258,7 @@ export const RandomSlotTable = () => {
},
{
header: 'Order',
accessorFn(originalRow) {
switch (originalRow.type) {
case 'flex':
case 'redirect':
return null;
case 'movie':
case 'show':
case 'custom-show':
case 'filler':
return originalRow.order.split('_').map(capitalize).join(' ');
}
},
accessorFn: formatSlotOrder,
id: 'programOrder',
Header() {
return (

View File

@@ -17,7 +17,6 @@ import {
Tooltip,
} from '@mui/material';
import { blue, green, orange, pink, purple } from '@mui/material/colors';
import { prettifySnakeCaseString } from '@tunarr/shared/util';
import { type SlotFiller } from '@tunarr/types/api';
import dayjs from 'dayjs';
import localizedFormat from 'dayjs/plugin/localizedFormat';
@@ -45,6 +44,7 @@ import {
import pluralize from 'pluralize';
import { useMemo, useState } from 'react';
import { match, P } from 'ts-pattern';
import { formatSlotOrder } from '../../helpers/slots.ts';
import { useSlotName } from '../../hooks/slot_scheduler/useSlotName.ts';
import { useDayjs } from '../../hooks/useDayjs.ts';
import { useTimeSlotFormContext } from '../../hooks/useTimeSlotFormContext.ts';
@@ -286,19 +286,7 @@ export const TimeSlotTable = () => {
// },
{
header: 'Order',
accessorFn(originalRow) {
switch (originalRow.type) {
case 'flex':
case 'redirect':
return null;
case 'movie':
case 'show':
case 'custom-show':
case 'filler':
case 'smart-collection':
return prettifySnakeCaseString(originalRow.order);
}
},
accessorFn: formatSlotOrder,
id: 'programOrder',
Cell({ cell }) {
const value = cell.getValue<string | null>();

View File

@@ -11,8 +11,8 @@ import {
Stack,
TextField,
} from '@mui/material';
import { isNonEmptyString } from '@tunarr/shared/util';
import { SearchFilter } from '@tunarr/types/schemas';
import { search } from '@tunarr/shared/util';
import type { SearchFilter } from '@tunarr/types/schemas';
import { isEmpty } from 'lodash-es';
import { useSnackbar } from 'notistack';
import { useCallback } from 'react';
@@ -50,6 +50,7 @@ export const CreateSmartCollectionDialog = ({
handleSubmit,
formState: { isValid },
} = useForm<SmartCollectionForm>({
mode: 'all',
defaultValues: {
name: '',
filter: initialQuery?.filter,
@@ -117,48 +118,52 @@ export const CreateSmartCollectionDialog = ({
<Controller
control={control}
name="id"
render={({ field }) => (
<FormControl>
<Select
disabled={existingCollections.length === 0}
{...field}
value={field.value ? field.value : 'new'}
>
{existingCollections.map((coll) => (
<MenuItem key={coll.uuid} value={coll.uuid}>
{coll.name}
render={({ field }) => {
const existingFilter = existingCollections.find(
(coll) => coll.uuid === field.value,
)?.filter;
const filterString = existingFilter
? search.searchFilterToString(existingFilter)
: '';
return (
<FormControl>
<Select
disabled={existingCollections.length === 0}
{...field}
value={field.value ? field.value : 'new'}
>
{existingCollections.map((coll) => (
<MenuItem key={coll.uuid} value={coll.uuid}>
{coll.name}
</MenuItem>
))}
<MenuItem value="new">
Save as new collection&hellip;
</MenuItem>
))}
<MenuItem value="new">
Save as new collection&hellip;
</MenuItem>
</Select>
<FormHelperText>
{field.value === 'new'
? 'Creates a new collection'
: `Existing query: ${existingCollections.find((coll) => coll.uuid === field.value)?.filter ?? ''}`}
</FormHelperText>
</FormControl>
)}
</Select>
<FormHelperText>
{field.value === 'new'
? 'Creates a new collection'
: `Existing query: ${filterString}`}
</FormHelperText>
</FormControl>
);
}}
/>
{isEmpty(existingId) ||
(existingId === 'new' && (
<Controller
name="name"
control={control}
rules={{
validate: {
valid: (v, form) =>
isNonEmptyString(form.id) ? undefined : !isEmpty(v),
},
}}
render={({ field }) => <TextField label="Name" {...field} />}
/>
))}
{(isEmpty(existingId) || existingId === 'new') && (
<Controller
name="name"
control={control}
rules={{
required: true,
minLength: 1,
}}
render={({ field }) => <TextField label="Name" {...field} />}
/>
)}
<Controller
name="keywords"
control={control}
rules={{ required: true, minLength: 1 }}
render={({ field }) => (
<TextField disabled label="Keywords" {...field} />
)}
@@ -166,9 +171,15 @@ export const CreateSmartCollectionDialog = ({
<Controller
name="filter"
control={control}
rules={{ required: true, minLength: 1 }}
render={({ field }) => (
<TextField disabled label="Filter" {...field} />
<TextField
disabled
label="Filter"
{...field}
value={
field.value ? search.searchFilterToString(field.value) : ''
}
/>
)}
/>
</Stack>

21
web/src/helpers/slots.ts Normal file
View File

@@ -0,0 +1,21 @@
import { prettifySnakeCaseString } from '@tunarr/shared/util';
import type {
RandomSlotTableRowType,
TimeSlotTableRowType,
} from '../model/CommonSlotModels.ts';
export function formatSlotOrder(
row: RandomSlotTableRowType | TimeSlotTableRowType,
) {
switch (row.type) {
case 'flex':
case 'redirect':
return null;
case 'movie':
case 'show':
case 'custom-show':
case 'filler':
case 'smart-collection':
return prettifySnakeCaseString(row.order);
}
}

View File

@@ -25,7 +25,7 @@ export const useVersion = (
!query.isLoading &&
trimStart(query.data?.tunarr, 'v') !== trimStart(__TUNARR_VERSION__, 'v');
if (versionMismatch) {
if (versionMismatch && import.meta.env.PROD) {
snackbar.enqueueSnackbar({
key: 'version_mismatch',
preventDuplicate: true,

View File

@@ -16,6 +16,7 @@ import {
useMediaQuery,
useTheme,
} from '@mui/material';
import { useQueryClient } from '@tanstack/react-query';
import { seq } from '@tunarr/shared/util';
import type { RandomSlotSchedule } from '@tunarr/types/api';
import { useToggle } from '@uidotdev/usehooks';
@@ -40,6 +41,7 @@ import ChannelLineupList from '../../components/channel_config/ChannelLineupList
import UnsavedNavigationAlert from '../../components/settings/UnsavedNavigationAlert';
import { SlotProgrammingOptionsProvider } from '../../components/slot_scheduler/SlotProgrammingOptionsProvider.tsx';
import { getProgramGroupingKey } from '../../helpers/programUtil.ts';
import { invalidateTaggedQueries } from '../../helpers/queryUtil.ts';
import { lineupItemAppearsInSchedule } from '../../helpers/slotSchedulerUtil';
import { useChannelSchedule } from '../../hooks/useChannelSchedule.ts';
import { useUpdateLineup } from '../../hooks/useUpdateLineup';
@@ -53,12 +55,14 @@ export default function RandomSlotEditorPage() {
const { currentEntity: channel, programList: newLineup } = useChannelEditor();
const { data: channelSchedule } = useChannelSchedule(channel!.id);
const queryClient = useQueryClient();
const updateLineupMutation = useUpdateLineup({
onSuccess(data) {
reset(data.schedule ?? defaultRandomSlotSchedule, {
keepDefaultValues: false,
keepDirty: false,
});
onSuccess() {
queryClient
.invalidateQueries({
predicate: invalidateTaggedQueries('Channels'),
})
.catch(console.error);
},
});