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

This commit is contained in:
Christian Benincasa
2026-01-10 07:43:21 -05:00
52 changed files with 328 additions and 161 deletions

View File

@@ -2,7 +2,7 @@ name: Publish Docs
on:
push:
branches:
- dev
- main
paths:
- docs/**
- mkdocs.yml

View File

@@ -37,12 +37,12 @@ jobs:
if: github.ref == 'refs/heads/main'
run: if pnpm run should-semantic-release ; then pnpm release-it --ci --verbose ; fi
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_TOKEN: ${{ secrets.RELEASE_PLEASE_TOKEN }}
DOCKER_CHANNEL: latest # Updates the 'latest' tag on Docker Hub
- name: Release from Dev (Pre-release)
if: github.ref == 'refs/heads/dev'
run: if pnpm run should-semantic-release ; then pnpm release-it --verbose --preRelease=dev --ci ; fi
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_TOKEN: ${{ secrets.RELEASE_PLEASE_TOKEN }}
DOCKER_CHANNEL: dev # Updates the 'dev' tag on Docker Hub

View File

@@ -1,5 +1,20 @@
# Changelog
## [1.0.17](https://github.com/chrisbenincasa/tunarr/compare/v1.0.16...v1.0.17) (2026-01-09)
### Bug Fixes
* default experimental search index memory flag to false ([271555c](https://github.com/chrisbenincasa/tunarr/commit/271555c86fc0c9a660c601dc0003690eb3165dc4))
* properly scan Emby episodes ([f1978e8](https://github.com/chrisbenincasa/tunarr/commit/f1978e8060f5559ffd549cf30264c8978f699465))
* restore search index from snapshot when necessary ([4a8fd4e](https://github.com/chrisbenincasa/tunarr/commit/4a8fd4ed4a7f20df64c246c8e31196a178cb74d0))
## [1.0.16](https://github.com/chrisbenincasa/tunarr/compare/v1.0.15...v1.0.16) (2026-01-08)
### Bug Fixes
* do not stop scanning seasons if one is unchanged ([109a030](https://github.com/chrisbenincasa/tunarr/commit/109a0300a1a144ce9b4f3e5a8bd9d8b30fadfa5a))
* strip leading "v" from version strings before building ([6723eee](https://github.com/chrisbenincasa/tunarr/commit/6723eeed201765f2551e2601247512471387427e))
## [1.0.15](https://github.com/chrisbenincasa/tunarr/compare/v1.0.14...v1.0.15) (2026-01-06)
### Bug Fixes

View File

@@ -8,10 +8,21 @@
content="width=device-width, initial-scale=1" />
</head>
<body>
<div id="app"></div>
<!-- Need a Custom Header? Check out this example https://codepen.io/scalarorg/pen/VwOXqam -->
<script
<!-- <script
id="api-reference"
data-url="/generated/tunarr-latest-openapi.json"></script>
data-url="/generated/tunarr-latest-openapi.json"></script> -->
<script src="https://cdn.jsdelivr.net/npm/@scalar/api-reference"></script>
<script id="sources" src="/generated/openapi-specs.js"></script>
<script>
Scalar.createApiReference('#app', {
theme: "default",
favicon: 'assets/favicon.png',
hideClientButton: true,
sources,
})
</script>
</body>
</html>

View File

@@ -0,0 +1,2 @@
const sources = [{"title":"Latest","slug":"latest","url":"/generated/tunarr-latest-openapi.json"},{"title":"1.0.16","slug":"1.0.16","url":"/generated/tunarr-v1.0.16-openapi.json"},{"title":"1.0.8","slug":"1.0.8","url":"/generated/tunarr-v1.0.8-openapi.json"},{"title":"1.0.3","slug":"1.0.3","url":"/generated/tunarr-v1.0.3-openapi.json"},{"title":"0.22.11","slug":"0.22.11","url":"/generated/tunarr-v0.22.11-openapi.json"}]

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,6 +1,6 @@
{
"name": "tunarr",
"version": "1.0.15",
"version": "1.0.17",
"description": "Create LiveTV channels from your Plex media",
"type": "module",
"author": "chrisbenincasa",
@@ -13,7 +13,8 @@
"lint-changed": "eslint --fix $(git diff --name-only HEAD -- './**/*.ts*' | xargs)",
"test": "turbo run test",
"preinstall": "npx only-allow pnpm",
"should-semantic-release": "should-semantic-release --verbose"
"should-semantic-release": "should-semantic-release --verbose",
"generate-docs-script": "tsx scripts/generate-docs-script.ts"
},
"devDependencies": {
"@commitlint/cli": "^19.3.0",
@@ -24,6 +25,8 @@
"@release-it/bumper": "^7.0.5",
"@release-it/conventional-changelog": "^10.0.4",
"@semantic-release/changelog": "^6.0.3",
"@types/node": "22.10.7",
"@types/semver": "^7.7.1",
"@typescript-eslint/eslint-plugin": "^8.21.0",
"@typescript-eslint/parser": "^8.21.0",
"@vitest/coverage-v8": "^3.2.4",
@@ -42,7 +45,9 @@
"release-it": "^19.2.2",
"release-it-pnpm": "^4.6.6",
"semantic-release": "^25.0.2",
"semver": "^7.7.3",
"should-semantic-release": "^0.3.5",
"tsx": "^4.20.5",
"turbo": "^2.5.3",
"typescript": "catalog:",
"vitest": "^3.2.4"

36
pnpm-lock.yaml generated
View File

@@ -58,6 +58,12 @@ importers:
'@semantic-release/changelog':
specifier: ^6.0.3
version: 6.0.3(semantic-release@25.0.2(typescript@5.9.3))
'@types/node':
specifier: 22.10.7
version: 22.10.7
'@types/semver':
specifier: ^7.7.1
version: 7.7.1
'@typescript-eslint/eslint-plugin':
specifier: ^8.21.0
version: 8.21.0(@typescript-eslint/parser@8.21.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
@@ -112,9 +118,15 @@ importers:
semantic-release:
specifier: ^25.0.2
version: 25.0.2(typescript@5.9.3)
semver:
specifier: ^7.7.3
version: 7.7.3
should-semantic-release:
specifier: ^0.3.5
version: 0.3.5
tsx:
specifier: ^4.20.5
version: 4.20.6
turbo:
specifier: ^2.5.3
version: 2.5.3
@@ -3355,6 +3367,9 @@ packages:
'@types/semver@7.5.4':
resolution: {integrity: sha512-MMzuxN3GdFwskAnb6fz0orFvhfqi752yjaXylr0Rp4oDg5H0Zn1IuyRhDVvYOwAXoJirx2xuS16I3WjxnAIHiQ==}
'@types/semver@7.7.1':
resolution: {integrity: sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==}
'@types/split2@4.2.3':
resolution: {integrity: sha512-59OXIlfUsi2k++H6CHgUQKEb2HKRokUA39HY1i1dS8/AIcqVjtAAFdf8u+HxTWK/4FUHMJQlKSZ4I6irCBJ1Zw==}
@@ -8111,11 +8126,6 @@ packages:
engines: {node: '>=10'}
hasBin: true
semver@7.6.3:
resolution: {integrity: sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==}
engines: {node: '>=10'}
hasBin: true
semver@7.7.2:
resolution: {integrity: sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==}
engines: {node: '>=10'}
@@ -11997,6 +12007,8 @@ snapshots:
'@types/semver@7.5.4': {}
'@types/semver@7.7.1': {}
'@types/split2@4.2.3':
dependencies:
'@types/node': 22.10.7
@@ -12041,7 +12053,7 @@ snapshots:
ignore: 5.2.4
natural-compare: 1.4.0
natural-compare-lite: 1.4.0
semver: 7.5.4
semver: 7.7.3
ts-api-utils: 1.0.3(typescript@5.9.3)
optionalDependencies:
typescript: 5.9.3
@@ -12271,7 +12283,7 @@ snapshots:
fast-glob: 3.3.3
is-glob: 4.0.3
minimatch: 9.0.5
semver: 7.6.3
semver: 7.7.3
ts-api-utils: 2.0.0(typescript@5.9.3)
typescript: 5.9.3
transitivePeerDependencies:
@@ -14526,7 +14538,7 @@ snapshots:
process-warning: 5.0.0
rfdc: 1.4.1
secure-json-parse: 4.1.0
semver: 7.7.2
semver: 7.7.3
toad-cache: 3.7.0
fastq@1.17.1:
@@ -16246,7 +16258,7 @@ snapshots:
node-abi@3.78.0:
dependencies:
semver: 7.7.2
semver: 7.7.3
node-cache@5.1.2:
dependencies:
@@ -16296,7 +16308,7 @@ snapshots:
ignore-by-default: 1.0.1
minimatch: 3.1.2
pstree.remy: 1.1.8
semver: 7.5.4
semver: 7.7.3
simple-update-notifier: 2.0.0
supports-color: 5.5.0
touch: 3.1.0
@@ -17163,7 +17175,7 @@ snapshots:
conventional-recommended-bump: 11.2.0
kolorist: 1.8.0
release-it: 19.2.2(@types/node@22.10.7)(magicast@0.3.5)
semver: 7.7.2
semver: 7.7.3
transitivePeerDependencies:
- magicast
@@ -17464,8 +17476,6 @@ snapshots:
dependencies:
lru-cache: 6.0.0
semver@7.6.3: {}
semver@7.7.2: {}
semver@7.7.3: {}

View File

@@ -0,0 +1,43 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import semver from 'semver';
function semverRegex() {
return /((0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?)/gi;
}
const generatedDir = await fs.readdir('docs/generated');
const openApiSpecs = generatedDir
.filter((name) => name.endsWith('openapi.json') && !name.includes('latest'))
.sort((l, r) => {
const sv1 = semverRegex().exec(l.replace('-openapi.json', ''))?.[0] ?? l;
const sv2 = semverRegex().exec(r.replace('-openapi.json', ''))?.[0] ?? r;
return semver.rcompare(sv1, sv2);
});
// Generate the script...
const specs = [
{
title: 'Latest',
slug: 'latest',
url: '/generated/tunarr-latest-openapi.json',
},
...openApiSpecs.map((spec) => {
const v = semverRegex().exec(spec.replace('-openapi.json', ''))?.[0];
return {
title: v,
slug: v,
url: `/generated/${spec}`,
};
}),
];
const script = `
const sources = ${JSON.stringify(specs)}
`;
await fs.writeFile(
path.join(process.cwd(), 'docs', 'generated', 'openapi-specs.js'),
script,
);

View File

@@ -1,5 +1,7 @@
#!/usr/bin/env bash
pnpm run generate-docs-script
docker build -f ./docker/docs.Dockerfile -t chrisbenincasa/tunarr-docs .
docker run --rm -it -p 8088:8000 -v "${PWD}":/docs chrisbenincasa/tunarr-docs

View File

@@ -1,6 +1,6 @@
{
"name": "@tunarr/server",
"version": "1.0.15",
"version": "1.0.17",
"description": "Create LiveTV channels from your Plex media",
"license": "Zlib",
"private": true,

View File

@@ -9,6 +9,7 @@ import { rimraf } from 'rimraf';
import { nativeNodeModulesPlugin } from '../esbuild/native-node-module.ts';
import { nodeProtocolPlugin } from '../esbuild/node-protocol.ts';
import { trimStart } from 'lodash-es';
import { createRequire } from 'node:module';
const __require = createRequire(import.meta.url);
const esbuildPluginPino = __require('esbuild-plugin-pino');
@@ -88,7 +89,7 @@ const result = await esbuild.build({
metafile: true,
define: {
'process.env.NODE_ENV': '"production"',
'process.env.TUNARR_VERSION': `"${process.env.TUNARR_VERSION}"`,
'process.env.TUNARR_VERSION': `"${trimStart(process.env.TUNARR_VERSION, 'v')}"`,
'process.env.TUNARR_BUILD': `"${process.env.TUNARR_BUILD}"`,
'process.env.TUNARR_EDGE_BUILD': `"${isEdgeBuild}"`,
'import.meta.url': '__import_meta_url',

View File

@@ -696,12 +696,13 @@ export class EmbyApiClient extends MediaSourceApiClient<EmbyItemTypes> {
(episode) => this.embyApiEpisodeInjection(episode),
(page) =>
this.doTypeCheckedGet(
`/Shows/${showId}/Seasons`,
`/Shows/${showId}/Episodes`,
EmbyLibraryItemsResponse,
{
params: {
userId: this.options.mediaSource.userId,
fields: 'Path,DateCreated,Etag,Taglines,ProviderIds',
fields:
'Path,DateCreated,Etag,Taglines,ProviderIds,MediaStreams,People',
startIndex: page * (pageSize ?? 50),
limit: pageSize ?? 50,
sortOrder: 'Ascending',

View File

@@ -1,9 +1,9 @@
import { nullToUndefined, seq } from '@tunarr/shared/util';
import {
FindChild,
MediaStream,
tag,
Tag,
MediaStream,
TerminalProgram,
TupleToUnion,
} from '@tunarr/types';
@@ -404,9 +404,11 @@ export class MeilisearchService implements ISearchService {
return;
}
const indexFolderExists = await fileExists(this.dbPath);
// Check for update.
// Only run updates on start of the main tunarr thread
if ((await fileExists(this.dbPath)) && isMainThread) {
if (indexFolderExists && isMainThread) {
const indexVersion = await this.getMeilisearchVersion();
if (indexVersion === serverPackage.meilisearch.version) {
@@ -461,6 +463,20 @@ export class MeilisearchService implements ISearchService {
'--experimental-dumpless-upgrade',
];
// Restore from snapshot if we don't have an index folder already
const snapshotPath = path.join(
this.fileSystemService.getMsSnapshotsPath(),
'data.ms.snapshot',
);
const snapshotExists = await fileExists(snapshotPath);
if (!indexFolderExists && snapshotExists) {
this.logger.debug(
'Restoring search index from snapshot: %s',
snapshotPath,
);
args.push('--import-snapshot', snapshotPath);
}
const indexingRamSetting =
getEnvVar(TUNARR_ENV_VARS.SEARCH_MAX_RAM) ??
this.settingsDB.systemSettings().server.searchSettings
@@ -494,7 +510,7 @@ export class MeilisearchService implements ISearchService {
!isWindows() &&
getBooleanEnvVar(
TUNARR_ENV_VARS.SEARCH_REDUCE_INDEXER_MEMORY_USAGE,
os.platform() === 'linux',
false,
)
) {
args.push('--experimental-reduce-indexing-memory-usage');
@@ -1407,10 +1423,12 @@ export class MeilisearchService implements ISearchService {
}
private getUniqueStreamLanguages(
streams: {
streamType: MediaStream['streamType'];
languageCodeISO6392?: string | null;
}[] | undefined,
streams:
| {
streamType: MediaStream['streamType'];
languageCodeISO6392?: string | null;
}[]
| undefined,
type: 'audio' | 'subtitles',
): string[] {
return uniq(
@@ -1422,8 +1440,6 @@ export class MeilisearchService implements ISearchService {
);
}
private convertProgramToSearchDocument<
ProgramT extends (Movie | Episode | MusicTrack | OtherVideo) &
HasMediaSourceAndLibraryId,
@@ -1594,7 +1610,6 @@ export class MeilisearchService implements ISearchService {
streamType: 'audio',
});
let summary: Nilable<string>;
switch (program.type) {
case 'movie':

View File

@@ -413,7 +413,7 @@ export class LocalTvShowScanner extends FileSystemScanner {
if (!shouldScan) {
this.logger.debug('Skipping unchanged season folder %s', fullPath);
return;
continue;
}
const seasonMetadata = this.seasonForNumber(show, seasonNumber);
@@ -462,7 +462,11 @@ export class LocalTvShowScanner extends FileSystemScanner {
);
if (epScanResult.isFailure()) {
return;
this.logger.warn(
epScanResult.error,
'Failed to scan episodes for season directory %s',
fullPath,
);
}
if (!isNew) {

View File

@@ -1,3 +1,4 @@
import { trimStart } from 'lodash-es';
import tunarrPackage from '../../package.json' with { type: 'json' };
import { getBooleanEnvVar, getEnvVar, TUNARR_ENV_VARS } from './env.ts';
import { isNonEmptyString, isProduction } from './index.js';
@@ -6,8 +7,10 @@ let tunarrVersion: string;
export const getTunarrVersion = () => {
if (!tunarrVersion) {
// Attempt to set for dev. This is relative to the shared package
tunarrVersion =
getEnvVar(TUNARR_ENV_VARS.BUILD_ENV_VAR) ?? tunarrPackage.version ?? '';
tunarrVersion = trimStart(
getEnvVar(TUNARR_ENV_VARS.BUILD_ENV_VAR) ?? tunarrPackage.version ?? '',
'v',
);
const isEdgeBuild = getBooleanEnvVar(
TUNARR_ENV_VARS.IS_EDGE_BUILD_ENV_VAR,

View File

@@ -1,6 +1,6 @@
{
"name": "@tunarr/shared",
"version": "1.0.15",
"version": "1.0.17",
"description": "Utility functions shared between server and web",
"private": true,
"keywords": [],

32
tsconfig.json Normal file
View File

@@ -0,0 +1,32 @@
{
"compilerOptions": {
"module": "nodenext",
"target": "esnext",
"lib": [
"esnext"
],
"types": [
"node"
],
"sourceMap": true,
"declaration": true,
"declarationMap": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noImplicitReturns": true,
"noImplicitOverride": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"strict": true,
"jsx": "react-jsx",
"verbatimModuleSyntax": true,
"isolatedModules": true,
"noUncheckedSideEffectImports": true,
"moduleDetection": "force",
"skipLibCheck": true,
},
"include": [
"scripts/**/*.ts",
]
}

View File

@@ -1,6 +1,6 @@
{
"name": "@tunarr/types",
"version": "1.0.15",
"version": "1.0.17",
"description": "Type definitions and schemas shared between server and web",
"private": true,
"scripts": {

View File

@@ -1,6 +1,6 @@
{
"name": "@tunarr/web",
"version": "1.0.15",
"version": "1.0.17",
"private": true,
"type": "module",
"scripts": {

View File

@@ -1,9 +1,10 @@
import { getProgramSummary } from '@/helpers/programUtil';
import { useSettings } from '@/store/settings/selectors';
import { OpenInNew } from '@mui/icons-material';
import { MoreVert, OpenInNew } from '@mui/icons-material';
import {
Box,
Button,
IconButton,
Stack,
Typography,
useMediaQuery,
@@ -18,7 +19,9 @@ import {
import { capitalize } from 'lodash-es';
import { useMemo, useState } from 'react';
import { useGetArtworkUrl } from '../../hooks/useThumbnailUrl.ts';
import type { Nullable } from '../../types/util.ts';
import ProgramInfoBar from './ProgramInfoBar';
import { ProgramOperationsMenu } from './ProgramOperationsMenu.tsx';
type Props = {
program: TerminalProgram | ProgramGrouping;
@@ -36,6 +39,9 @@ export default function MediaDetailCard({ program }: Props) {
return `${settings.backendUri}/api/programs/${program.uuid}/external-link`;
}, [settings.backendUri, program]);
const [moreMenuAnchorEl, setMoreMenuAnchorEl] =
useState<Nullable<HTMLElement>>(null);
const getProgramDescription = useMemo(() => {
return getProgramSummary(program);
}, [program]);
@@ -190,7 +196,22 @@ export default function MediaDetailCard({ program }: Props) {
</Box>
<Box maxWidth={700}>
<Stack spacing={1}>
{getProgramTitle}
<Stack direction={'row'} sx={{ alignItems: 'center' }}>
<Box flex={1}>{getProgramTitle}</Box>
<IconButton
sx={{ width: 40, height: 40 }}
onClick={(e) => setMoreMenuAnchorEl(e.currentTarget)}
>
<MoreVert />
</IconButton>
</Stack>
<ProgramOperationsMenu
programId={program.uuid}
programType={program.type}
anchorEl={moreMenuAnchorEl}
onClose={() => setMoreMenuAnchorEl(null)}
open={!!moreMenuAnchorEl}
/>
<Stack
direction="row"

View File

@@ -1,7 +1,7 @@
import { isNonEmptyString } from '@/helpers/util.ts';
import { useIsDarkMode } from '@/hooks/useTunarrTheme.ts';
import { useSettings } from '@/store/settings/selectors.ts';
import { Close, MoreVert, Refresh } from '@mui/icons-material';
import { Close, MoreVert } from '@mui/icons-material';
import {
Box,
Dialog,
@@ -9,10 +9,6 @@ import {
DialogTitle,
IconButton,
LinearProgress,
ListItemIcon,
ListItemText,
Menu,
MenuItem,
Skeleton,
Stack,
Tab,
@@ -20,26 +16,24 @@ import {
useMediaQuery,
useTheme,
} from '@mui/material';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useQuery } from '@tanstack/react-query';
import type { ProgramLike, TupleToUnion } from '@tunarr/types';
import { isStructuralItemType, isTerminalItemType } from '@tunarr/types';
import type { Dayjs } from 'dayjs';
import { find, isEqual, merge } from 'lodash-es';
import { Suspense, useCallback, useMemo, useState } from 'react';
import { find, merge } from 'lodash-es';
import { Suspense, useMemo, useState } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
import type { DeepRequired } from 'ts-essentials';
import {
getApiProgramGroupingsByIdOptions,
getApiProgramGroupingsByIdQueryKey,
getApiProgramsByIdOptions,
postApiMoviesByIdScanMutation,
postApiShowsByIdScanMutation,
} from '../../generated/@tanstack/react-query.gen.ts';
import type { Nullable } from '../../types/util.ts';
import { ProgramMetadataDialogContent } from '../ProgramMetadataDialogContent.tsx';
import { ProgramStreamDetails } from '../ProgramStreamDetails.tsx';
import { RawProgramDetails } from '../RawProgramDetails.tsx';
import { TabPanel } from '../TabPanel.tsx';
import { ProgramOperationsMenu } from './ProgramOperationsMenu.tsx';
const Panels = ['metadata', 'stream_details', 'program_details'] as const;
type Panels = TupleToUnion<typeof Panels>;
@@ -77,7 +71,6 @@ export default function ProgramDetailsDialog({
const darkMode = useIsDarkMode();
const [moreMenuAnchorEl, setMoreMenuAnchorEl] =
useState<Nullable<HTMLElement>>(null);
const moreMenuOpen = !!moreMenuAnchorEl;
const visibility = useMemo(
() => merge(DefaultPanelVisibility, panelVisibility),
@@ -136,60 +129,6 @@ export default function ProgramDetailsDialog({
}
}, [programData]);
const queryClient = useQueryClient();
const clearQueryCache = useCallback(() => {
return queryClient.invalidateQueries({
predicate: (key) => {
return (
isEqual(
key,
getApiProgramGroupingsByIdQueryKey({ path: { id: programId } }),
) ||
isEqual(key, getApiProgramsByIdOptions({ path: { id: programId } }))
);
},
});
}, [programId, queryClient]);
const showScanMut = useMutation({
...postApiShowsByIdScanMutation(),
onSuccess: () => {
return clearQueryCache();
},
});
const movieScanMut = useMutation({
...postApiMoviesByIdScanMutation(),
onSuccess: () => {
return clearQueryCache();
},
});
const scanItem = useCallback(() => {
switch (programType) {
case 'movie': {
movieScanMut.mutate({ path: { id: programId } }, {});
break;
}
case 'show': {
showScanMut.mutate({
path: { id: programId },
});
break;
}
case 'season':
case 'episode':
case 'album':
case 'artist':
case 'track':
case 'music_video':
case 'other_video':
break;
}
setMoreMenuAnchorEl(null);
}, [movieScanMut, programId, programType, showScanMut]);
return (
<Dialog
open={open}
@@ -233,20 +172,13 @@ export default function ProgramDetailsDialog({
<MoreVert />
</IconButton>
) : null}
<Menu
<ProgramOperationsMenu
programId={programId}
programType={programType}
anchorEl={moreMenuAnchorEl}
open={moreMenuOpen}
onClose={() => setMoreMenuAnchorEl(null)}
>
{/* <MenuList>
</MenuList> */}
<MenuItem onClick={() => scanItem()}>
<ListItemIcon>
<Refresh fontSize="small" />
</ListItemIcon>
<ListItemText>Scan</ListItemText>
</MenuItem>
</Menu>
open={!!moreMenuAnchorEl}
/>
<IconButton
edge="start"
color="inherit"

View File

@@ -11,7 +11,7 @@ import {
type ProgramGrouping,
type TerminalProgram,
} from '@tunarr/types';
import { capitalize, isUndefined } from 'lodash-es';
import { capitalize, compact, isUndefined } from 'lodash-es';
import pluralize from 'pluralize';
import React, { useMemo } from 'react';
import Genres from './Genres';
@@ -74,6 +74,10 @@ export default function ProgramInfoBar({ program, time }: Props) {
query: queryStringValue,
};
if (!rating) {
return null;
}
return (
<Link
to="/search"
@@ -182,7 +186,7 @@ export default function ProgramInfoBar({ program, time }: Props) {
seasonTitle,
]);
return itemInfoBar.map((chip, index) => (
return compact(itemInfoBar).map((chip, index) => (
<React.Fragment key={index}>
<Box display="inline-block">{chip}</Box>
{index < itemInfoBar.length - 1 && (

View File

@@ -0,0 +1,94 @@
import { Refresh } from '@mui/icons-material';
import type { PopoverProps } from '@mui/material';
import { ListItemIcon, ListItemText, Menu, MenuItem } from '@mui/material';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import type { ProgramLike } from '@tunarr/types';
import { isEqual } from 'lodash-es';
import { useCallback } from 'react';
import {
getApiProgramGroupingsByIdQueryKey,
getApiProgramsByIdOptions,
postApiMoviesByIdScanMutation,
postApiShowsByIdScanMutation,
} from '../../generated/@tanstack/react-query.gen.ts';
type Props = {
programId: string;
programType: ProgramLike['type'];
anchorEl: PopoverProps['anchorEl'];
open: boolean;
onClose: () => void;
};
export const ProgramOperationsMenu = ({
programId,
programType,
anchorEl,
open,
onClose,
}: Props) => {
const queryClient = useQueryClient();
const clearQueryCache = useCallback(() => {
return queryClient.invalidateQueries({
predicate: (key) => {
return (
isEqual(
key,
getApiProgramGroupingsByIdQueryKey({ path: { id: programId } }),
) ||
isEqual(key, getApiProgramsByIdOptions({ path: { id: programId } }))
);
},
});
}, [programId, queryClient]);
const showScanMut = useMutation({
...postApiShowsByIdScanMutation(),
onSuccess: () => {
return clearQueryCache();
},
});
const movieScanMut = useMutation({
...postApiMoviesByIdScanMutation(),
onSuccess: () => {
return clearQueryCache();
},
});
const scanItem = useCallback(() => {
switch (programType) {
case 'movie': {
movieScanMut.mutate({ path: { id: programId } }, {});
break;
}
case 'show': {
showScanMut.mutate({
path: { id: programId },
});
break;
}
case 'season':
case 'episode':
case 'album':
case 'artist':
case 'track':
case 'music_video':
case 'other_video':
break;
}
onClose();
}, [movieScanMut, onClose, programId, programType, showScanMut]);
return (
<Menu anchorEl={anchorEl} open={open} onClose={() => onClose()}>
<MenuItem onClick={() => scanItem()}>
<ListItemIcon>
<Refresh fontSize="small" />
</ListItemIcon>
<ListItemText>Scan</ListItemText>
</MenuItem>
</Menu>
);
};

View File

@@ -28,8 +28,6 @@ export function invalidateTaggedQueries(tagsToMatch: string | string[]) {
return false;
}
console.log(query.queryKey, parseResult.data.tags, tagsToMatch);
return intersection(parseResult.data.tags, tagsToMatch).length > 0;
};
}

View File

@@ -1,6 +1,7 @@
import { Close, Refresh } from '@mui/icons-material';
import { Button, Stack } from '@mui/material';
import { useQuery } from '@tanstack/react-query';
import { trimStart } from 'lodash-es';
import { useSnackbar } from 'notistack';
import type { StrictOmit } from 'ts-essentials';
import { getApiVersionOptions } from '../generated/@tanstack/react-query.gen.ts';
@@ -21,7 +22,8 @@ export const useVersion = (
});
const versionMismatch =
!query.isLoading && query.data?.tunarr !== __TUNARR_VERSION__;
!query.isLoading &&
trimStart(query.data?.tunarr, 'v') !== trimStart(__TUNARR_VERSION__, 'v');
if (versionMismatch) {
snackbar.enqueueSnackbar({
@@ -35,8 +37,8 @@ export const useVersion = (
the browser to get the latest. If this message persists, clear your
browser cache and reload.
<br />
Web version = {__TUNARR_VERSION__}, Server version ={' '}
{query.data?.tunarr}
Web version = {trimStart(__TUNARR_VERSION__, 'v')}, Server version ={' '}
{trimStart(query.data?.tunarr, 'v')}
</span>
),
variant: 'warning',

View File

@@ -14,6 +14,9 @@ const packageVersion = packageDef.version;
const version = (() => {
let tunarrVersion = process.env.TUNARR_VERSION ?? packageVersion;
if (tunarrVersion.startsWith('v')) {
tunarrVersion = tunarrVersion.slice(1);
}
const build = process.env[BUILD_ENV_VAR] ?? '';
const isEdgeBuildValue = process.env[IS_EDGE_BUILD_ENV_VAR];
const isEdgeBuild = isEdgeBuildValue === 'true' || isEdgeBuildValue === '1';