mirror of
https://github.com/chrisbenincasa/tunarr.git
synced 2026-04-18 09:03:35 -04:00
Merge remote-tracking branch 'origin/main' into dev
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -11,3 +11,5 @@ catalog:
|
||||
random-js: 2.1.0
|
||||
typescript: 5.7.3
|
||||
zod: ^4.1.5
|
||||
|
||||
enablePrePostScripts: true
|
||||
|
||||
3
server/.gitignore
vendored
3
server/.gitignore
vendored
@@ -10,6 +10,5 @@ streams/
|
||||
bin/
|
||||
|
||||
src/generated/**
|
||||
!src/generated/web-imports.d.ts
|
||||
!src/generated/.gitkeep
|
||||
!src/generated/env.ts
|
||||
emby.ts
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
@@ -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({
|
||||
|
||||
31
server/scripts/generateEnvModule.ts
Normal file
31
server/scripts/generateEnvModule.ts
Normal 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([]);
|
||||
}
|
||||
@@ -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.`,
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -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'),
|
||||
[],
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
0
server/src/generated/.gitkeep
Normal file
0
server/src/generated/.gitkeep
Normal file
9
server/src/generated/env.ts
Normal file
9
server/src/generated/env.ts
Normal 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));
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
});
|
||||
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"include": [
|
||||
"./src/**/*.ts",
|
||||
"mikro-orm.prod.config.ts",
|
||||
"mikro-orm.base.config.ts"
|
||||
"./src/**/*",
|
||||
],
|
||||
"exclude": [
|
||||
"./dist/**/*",
|
||||
|
||||
@@ -42,8 +42,8 @@
|
||||
"skipLibCheck": true,
|
||||
},
|
||||
"include": [
|
||||
"./src/**/*.ts",
|
||||
"./scripts/**/*.ts",
|
||||
"./src/**/*",
|
||||
"./scripts/**/*",
|
||||
],
|
||||
"exclude": [
|
||||
"./src/**/*.ignore.ts",
|
||||
|
||||
@@ -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": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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…
|
||||
</MenuItem>
|
||||
))}
|
||||
<MenuItem value="new">
|
||||
Save as new collection…
|
||||
</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
21
web/src/helpers/slots.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user