Compare commits

...

2 Commits

Author SHA1 Message Date
Gauvino
cf2bab57bb Merge remote-tracking branch 'origin/develop' into refactor/js-to-ts-migration 2026-06-11 12:02:20 +02:00
Gauvino
f97852ae98 refactor: migrate JS/MJS sources and scripts to TypeScript
Migrate all remaining migratable .js/.mjs files to .ts with strong typing:
- constants/MediaTypes: as const + derived MediaType union
- utils/profiles/{download,subtitles,trackplayer}: typed against
  @jellyfin/sdk DeviceProfile/SubtitleProfile/CodecProfile models;
  native.ts now validates its profile with `satisfies DeviceProfile`
- trackplayer.d.ts removed (superseded by real TS implementation)
- index.js -> index.ts (entry point, "main" is extension-less)
- scripts/{typecheck,check-i18n-keys,detect-duplicate-issue} -> .ts,
  all run via bun (typecheck switched from node to bun)

Remove scripts/symlink-native-dirs.js: dead since 446439c2 (2025-02-28)
when its only reference (prebuild:tv-new) was dropped; superseded by
`expo prebuild --clean` + cross-env EXPO_TV. Drop the matching
.gitignore relics (/iostv, /iosmobile, /androidmobile, /androidtv).

Document tooling-required .js exceptions (babel/metro/react-native/
tailwind configs) in CLAUDE.md and copilot-instructions.md so code
review guidelines stop flagging them.
2026-06-11 11:51:59 +02:00
16 changed files with 128 additions and 180 deletions

View File

@@ -42,7 +42,7 @@ and provides seamless media streaming with offline capabilities and Chromecast s
## Coding Standards ## Coding Standards
- Use TypeScript for ALL files (no .js files) - Use TypeScript for ALL files (no .js files). Tooling-required exceptions: `babel.config.js`, `metro.config.js`, `react-native.config.js`, `tailwind.config.js` (their loaders cannot parse TypeScript)
- Use descriptive English names for variables, functions, and components - Use descriptive English names for variables, functions, and components
- Prefer functional React components with hooks - Prefer functional React components with hooks
- Use Jotai atoms for global state management - Use Jotai atoms for global state management

View File

@@ -29,7 +29,7 @@ jobs:
bun-version: latest bun-version: latest
- name: 🔍 Detect duplicate issues - name: 🔍 Detect duplicate issues
run: bun scripts/detect-duplicate-issue.mjs run: bun scripts/detect-duplicate-issue.ts
env: env:
GH_TOKEN: ${{ github.token }} GH_TOKEN: ${{ github.token }}
GITHUB_REPOSITORY: ${{ github.repository }} GITHUB_REPOSITORY: ${{ github.repository }}

4
.gitignore vendored
View File

@@ -12,10 +12,6 @@ web-build/
# Platform-specific Build Directories # Platform-specific Build Directories
/ios /ios
/android /android
/iostv
/iosmobile
/androidmobile
/androidtv
# Gradle caches (top-level + per-module native projects) # Gradle caches (top-level + per-module native projects)
**/.gradle/ **/.gradle/

View File

@@ -152,7 +152,7 @@ import { apiAtom } from "@/providers/JellyfinProvider";
## Coding Standards ## Coding Standards
- Use TypeScript for all files (no .js) - Use TypeScript for all files (no .js). Tooling-required exceptions: `babel.config.js`, `metro.config.js`, `react-native.config.js`, `tailwind.config.js` (their loaders cannot parse TypeScript)
- Use functional React components with hooks - Use functional React components with hooks
- Use Jotai atoms for global state, React Query for server state - Use Jotai atoms for global state, React Query for server state
- Follow BiomeJS formatting rules (2-space indent, semicolons, LF line endings) - Follow BiomeJS formatting rules (2-space indent, semicolons, LF line endings)

View File

@@ -3,9 +3,13 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this * License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. * file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/ */
export default { const MediaTypes = {
Audio: "Audio", Audio: "Audio",
Video: "Video", Video: "Video",
Photo: "Photo", Photo: "Photo",
Book: "Book", Book: "Book",
}; } as const;
export type MediaType = (typeof MediaTypes)[keyof typeof MediaTypes];
export default MediaTypes;

View File

View File

@@ -17,13 +17,13 @@
"ios:unsigned-build": "cross-env EXPO_TV=0 bun scripts/ios/build-ios.ts --production", "ios:unsigned-build": "cross-env EXPO_TV=0 bun scripts/ios/build-ios.ts --production",
"ios:unsigned-build:tv": "cross-env EXPO_TV=1 bun scripts/ios/build-ios.ts --production", "ios:unsigned-build:tv": "cross-env EXPO_TV=1 bun scripts/ios/build-ios.ts --production",
"prepare": "husky", "prepare": "husky",
"typecheck": "node scripts/typecheck.js", "typecheck": "bun scripts/typecheck.ts",
"check": "biome check . --max-diagnostics 1000", "check": "biome check . --max-diagnostics 1000",
"lint": "biome check --write --unsafe --max-diagnostics 1000", "lint": "biome check --write --unsafe --max-diagnostics 1000",
"format": "biome format --write .", "format": "biome format --write .",
"doctor": "expo-doctor", "doctor": "expo-doctor",
"i18n:check": "bun scripts/check-i18n-keys.mjs", "i18n:check": "bun scripts/check-i18n-keys.ts",
"i18n:fix-unused": "bun scripts/check-i18n-keys.mjs --fix-unused", "i18n:fix-unused": "bun scripts/check-i18n-keys.ts --fix-unused",
"test": "bun run typecheck && bun run lint && bun run format && bun run i18n:check && bun run doctor", "test": "bun run typecheck && bun run lint && bun run format && bun run i18n:check && bun run doctor",
"postinstall": "patch-package" "postinstall": "patch-package"
}, },

View File

@@ -18,11 +18,11 @@
* - Edge cases the static scan cannot see can be allow-listed in the config file. * - Edge cases the static scan cannot see can be allow-listed in the config file.
* *
* Usage: * Usage:
* bun scripts/check-i18n-keys.mjs # report + exit 1 on missing OR unused * bun scripts/check-i18n-keys.ts # report + exit 1 on missing OR unused
* bun scripts/check-i18n-keys.mjs --unused=warn # exit 1 only on missing; unused = warning * bun scripts/check-i18n-keys.ts --unused=warn # exit 1 only on missing; unused = warning
* bun scripts/check-i18n-keys.mjs --unused=off # ignore unused entirely * bun scripts/check-i18n-keys.ts --unused=off # ignore unused entirely
* bun scripts/check-i18n-keys.mjs --json # machine-readable output * bun scripts/check-i18n-keys.ts --json # machine-readable output
* bun scripts/check-i18n-keys.mjs --fix-unused # remove dead keys from en.json (Crowdin syncs the rest) * bun scripts/check-i18n-keys.ts --fix-unused # remove dead keys from en.json (Crowdin syncs the rest)
*/ */
import { import {
@@ -34,9 +34,20 @@ import {
} from "node:fs"; } from "node:fs";
import { extname, join, relative } from "node:path"; import { extname, join, relative } from "node:path";
type LocaleTree = { [key: string]: LocaleTree | string };
interface I18nConfig {
localesDir: string;
sourceLocale: string;
srcDirs: string[];
srcExtensions: string[];
excludeDirs: string[];
ignoreUnused: string[];
}
const ROOT = process.cwd(); const ROOT = process.cwd();
const args = process.argv.slice(2); const args = process.argv.slice(2);
const flag = (name, def) => { const flag = (name: string, def: string | boolean): string | boolean => {
const a = args.find((x) => x === `--${name}` || x.startsWith(`--${name}=`)); const a = args.find((x) => x === `--${name}` || x.startsWith(`--${name}=`));
if (!a) return def; if (!a) return def;
const [, v] = a.split("="); const [, v] = a.split("=");
@@ -48,7 +59,7 @@ const FIX_UNUSED = !!flag("fix-unused", false);
// ---- config ---- // ---- config ----
const CONFIG_PATH = join(ROOT, "scripts", "i18n-keys.config.json"); const CONFIG_PATH = join(ROOT, "scripts", "i18n-keys.config.json");
const DEFAULT_CONFIG = { const DEFAULT_CONFIG: I18nConfig = {
localesDir: "translations", localesDir: "translations",
sourceLocale: "en", sourceLocale: "en",
// Scan the whole repo by default so keys referenced outside the obvious dirs // Scan the whole repo by default so keys referenced outside the obvious dirs
@@ -69,29 +80,36 @@ const DEFAULT_CONFIG = {
// Keys (or glob-ish prefixes ending with .* or *) known to be used dynamically / externally. // Keys (or glob-ish prefixes ending with .* or *) known to be used dynamically / externally.
ignoreUnused: [], ignoreUnused: [],
}; };
const config = existsSync(CONFIG_PATH) const config: I18nConfig = existsSync(CONFIG_PATH)
? { ...DEFAULT_CONFIG, ...JSON.parse(readFileSync(CONFIG_PATH, "utf8")) } ? {
...DEFAULT_CONFIG,
...(JSON.parse(readFileSync(CONFIG_PATH, "utf8")) as Partial<I18nConfig>),
}
: DEFAULT_CONFIG; : DEFAULT_CONFIG;
// ---- helpers ---- // ---- helpers ----
const flatten = (obj, prefix = "", out = {}) => { const flatten = (
obj: LocaleTree,
prefix = "",
out: Record<string, string> = {},
): Record<string, string> => {
for (const [k, v] of Object.entries(obj)) { for (const [k, v] of Object.entries(obj)) {
const key = prefix ? `${prefix}.${k}` : k; const key = prefix ? `${prefix}.${k}` : k;
if (v && typeof v === "object" && !Array.isArray(v)) flatten(v, key, out); if (v && typeof v === "object" && !Array.isArray(v)) flatten(v, key, out);
else out[key] = v; else out[key] = v as string;
} }
return out; return out;
}; };
const globMatch = (key, pattern) => { const globMatch = (key: string, pattern: string): boolean => {
if (pattern.endsWith(".*")) if (pattern.endsWith(".*"))
return key === pattern.slice(0, -2) || key.startsWith(pattern.slice(0, -1)); return key === pattern.slice(0, -2) || key.startsWith(pattern.slice(0, -1));
if (pattern.endsWith("*")) return key.startsWith(pattern.slice(0, -1)); if (pattern.endsWith("*")) return key.startsWith(pattern.slice(0, -1));
return key === pattern; return key === pattern;
}; };
const walk = (dir, files = []) => { const walk = (dir: string, files: string[] = []): string[] => {
let entries; let entries: string[];
try { try {
entries = readdirSync(dir); entries = readdirSync(dir);
} catch { } catch {
@@ -99,7 +117,7 @@ const walk = (dir, files = []) => {
} }
for (const name of entries) { for (const name of entries) {
const full = join(dir, name); const full = join(dir, name);
let st; let st: ReturnType<typeof statSync>;
try { try {
st = statSync(full); st = statSync(full);
} catch { } catch {
@@ -118,7 +136,7 @@ const walk = (dir, files = []) => {
// ---- load source keys ---- // ---- load source keys ----
const sourcePath = join(ROOT, config.localesDir, `${config.sourceLocale}.json`); const sourcePath = join(ROOT, config.localesDir, `${config.sourceLocale}.json`);
const sourceKeys = Object.keys( const sourceKeys = Object.keys(
flatten(JSON.parse(readFileSync(sourcePath, "utf8"))), flatten(JSON.parse(readFileSync(sourcePath, "utf8")) as LocaleTree),
); );
const sourceKeySet = new Set(sourceKeys); const sourceKeySet = new Set(sourceKeys);
@@ -129,16 +147,16 @@ const TPL_DYN_RE = /\bt\(\s*`([^`$]*)\$\{/g; // t(`a.b.${x}`) -> prefix "a.b."
const I18NKEY_RE = /\bi18nKey\s*=\s*(?:\{\s*)?(['"])((?:\\.|(?!\1).)+?)\1/g; // <Trans i18nKey="a.b"> const I18NKEY_RE = /\bi18nKey\s*=\s*(?:\{\s*)?(['"])((?:\\.|(?!\1).)+?)\1/g; // <Trans i18nKey="a.b">
const KEY_SHAPE = /^[A-Za-z0-9_]+(\.[A-Za-z0-9_]+)+$/; // dotted key, e.g. home.x.y const KEY_SHAPE = /^[A-Za-z0-9_]+(\.[A-Za-z0-9_]+)+$/; // dotted key, e.g. home.x.y
const usedStatic = new Set(); // keys passed to t(...) / i18nKey — used for MISSING detection const usedStatic = new Set<string>(); // keys passed to t(...) / i18nKey — used for MISSING detection
const dynamicPrefixes = new Set(); const dynamicPrefixes = new Set<string>();
const fullyDynamic = []; // { file, line } const fullyDynamic: Array<{ file: string; line: number }> = [];
let codeBlob = ""; // all (comment-stripped) source text — searched for delimited key literals let codeBlob = ""; // all (comment-stripped) source text — searched for delimited key literals
// Strip comments so keys mentioned in comments (e.g. `// t("old.key")`) are not counted as // Strip comments so keys mentioned in comments (e.g. `// t("old.key")`) are not counted as
// usage. Block comments and JSX {/* */} are blanked (preserving newlines for line numbers); // usage. Block comments and JSX {/* */} are blanked (preserving newlines for line numbers);
// line comments are only stripped when `//` follows start/whitespace/punctuation, which keeps // line comments are only stripped when `//` follows start/whitespace/punctuation, which keeps
// `://` inside string URLs intact. // `://` inside string URLs intact.
const stripComments = (src) => const stripComments = (src: string): string =>
src src
.replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, " ")) .replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, " "))
.replace(/(^|[\s;{}()[\],=>])\/\/[^\n]*/g, (_m, p) => p); .replace(/(^|[\s;{}()[\],=>])\/\/[^\n]*/g, (_m, p) => p);
@@ -168,11 +186,11 @@ const prefixList = [...dynamicPrefixes];
// the code (covers t("k"), <Trans i18nKey>, and keys stored as bare string constants in // the code (covers t("k"), <Trans i18nKey>, and keys stored as bare string constants in
// arrays/config then resolved via t(variable)), or it is reached via a dynamic prefix, or // arrays/config then resolved via t(variable)), or it is reached via a dynamic prefix, or
// explicitly allow-listed. Delimited search avoids substring false-matches (e.g. a.b vs a.b_c). // explicitly allow-listed. Delimited search avoids substring false-matches (e.g. a.b vs a.b_c).
const literalUsed = (key) => const literalUsed = (key: string): boolean =>
codeBlob.includes(`"${key}"`) || codeBlob.includes(`"${key}"`) ||
codeBlob.includes(`'${key}'`) || codeBlob.includes(`'${key}'`) ||
codeBlob.includes(`\`${key}\``); codeBlob.includes(`\`${key}\``);
const isUsed = (key) => const isUsed = (key: string): boolean =>
literalUsed(key) || literalUsed(key) ||
prefixList.some((p) => key.startsWith(p)) || prefixList.some((p) => key.startsWith(p)) ||
config.ignoreUnused.some((g) => globMatch(key, g)); config.ignoreUnused.some((g) => globMatch(key, g));
@@ -191,25 +209,22 @@ const missing = [...usedStatic]
// keys are static literals in practice; revisit if dynamic key constants become common. // keys are static literals in practice; revisit if dynamic key constants become common.
// ---- optional fix: strip dead keys from the source locale (en.json) ---- // ---- optional fix: strip dead keys from the source locale (en.json) ----
const removeKey = (obj, parts) => { const removeKey = (obj: LocaleTree, parts: string[]): void => {
const [head, ...rest] = parts; const [head, ...rest] = parts;
if (!(head in obj)) return; if (!(head in obj)) return;
if (rest.length === 0) { if (rest.length === 0) {
delete obj[head]; delete obj[head];
return; return;
} }
removeKey(obj[head], rest); const child = obj[head];
if ( if (!child || typeof child !== "object") return;
obj[head] && removeKey(child, rest);
typeof obj[head] === "object" && if (Object.keys(child).length === 0) delete obj[head];
Object.keys(obj[head]).length === 0
)
delete obj[head];
}; };
if (FIX_UNUSED && unused.length) { if (FIX_UNUSED && unused.length) {
// Only edit the SOURCE locale (en.json). Crowdin owns the target locales and removes // Only edit the SOURCE locale (en.json). Crowdin owns the target locales and removes
// the keys from them automatically on the next sync once they disappear from the source. // the keys from them automatically on the next sync once they disappear from the source.
const data = JSON.parse(readFileSync(sourcePath, "utf8")); const data = JSON.parse(readFileSync(sourcePath, "utf8")) as LocaleTree;
for (const key of unused) removeKey(data, key.split(".")); for (const key of unused) removeKey(data, key.split("."));
writeFileSync(sourcePath, `${JSON.stringify(data, null, 2)}\n`); writeFileSync(sourcePath, `${JSON.stringify(data, null, 2)}\n`);
console.log( console.log(
@@ -259,7 +274,7 @@ if (JSON_OUT) {
); );
for (const k of unused) console.log(` - ${k}`); for (const k of unused) console.log(` - ${k}`);
console.log( console.log(
`\n → remove with: bun scripts/check-i18n-keys.mjs --fix-unused`, `\n → remove with: bun scripts/check-i18n-keys.ts --fix-unused`,
); );
console.log( console.log(
` → or allow-list a dynamic key in scripts/i18n-keys.config.json ("ignoreUnused").`, ` → or allow-list a dynamic key in scripts/i18n-keys.config.json ("ignoreUnused").`,

View File

@@ -21,8 +21,14 @@
import { execFileSync } from "node:child_process"; import { execFileSync } from "node:child_process";
import { readFileSync } from "node:fs"; import { readFileSync } from "node:fs";
interface Issue {
number: number;
title: string;
body: string | null;
}
// Parse a numeric env var, falling back to `def` only when unset/empty/NaN so an explicit 0 is honoured. // Parse a numeric env var, falling back to `def` only when unset/empty/NaN so an explicit 0 is honoured.
const numEnv = (name, def) => { const numEnv = (name: string, def: number): number => {
const raw = process.env[name]; const raw = process.env[name];
if (raw === undefined || raw === "") return def; if (raw === undefined || raw === "") return def;
const n = Number(raw); const n = Number(raw);
@@ -51,9 +57,9 @@ const STOP = new Set(
).split(/\s+/), ).split(/\s+/),
); );
const stem = (w) => w.replace(/(ing|ed|es|s)$/, ""); const stem = (w: string): string => w.replace(/(ing|ed|es|s)$/, "");
const tokens = (s) => const tokens = (s: string | null): string[] =>
(s || "") (s || "")
.toLowerCase() .toLowerCase()
.replace(/```[\s\S]*?```/g, " ") // drop code blocks .replace(/```[\s\S]*?```/g, " ") // drop code blocks
@@ -65,7 +71,7 @@ const tokens = (s) =>
.map(stem) .map(stem)
.filter((w) => w.length > 2); .filter((w) => w.length > 2);
const jaccard = (a, b) => { const jaccard = (a: string[], b: string[]): number => {
const A = new Set(a); const A = new Set(a);
const B = new Set(b); const B = new Set(b);
if (!A.size || !B.size) return 0; if (!A.size || !B.size) return 0;
@@ -76,14 +82,14 @@ const jaccard = (a, b) => {
const newTitle = tokens(TITLE); const newTitle = tokens(TITLE);
const newBody = tokens(BODY); const newBody = tokens(BODY);
const score = (o) => const score = (o: Issue): number =>
0.6 * jaccard(newTitle, tokens(o.title)) + 0.6 * jaccard(newTitle, tokens(o.title)) +
0.4 * jaccard(newBody, tokens(o.body)); 0.4 * jaccard(newBody, tokens(o.body));
// fetch open issues (excluding PRs and the new issue itself) // fetch open issues (excluding PRs and the new issue itself)
let issues; let issues: Issue[];
if (process.env.DUP_FIXTURE) { if (process.env.DUP_FIXTURE) {
issues = JSON.parse(readFileSync(process.env.DUP_FIXTURE, "utf8")); issues = JSON.parse(readFileSync(process.env.DUP_FIXTURE, "utf8")) as Issue[];
} else { } else {
const raw = execFileSync( const raw = execFileSync(
"gh", "gh",
@@ -105,7 +111,7 @@ if (process.env.DUP_FIXTURE) {
issues = raw issues = raw
.split("\n") .split("\n")
.filter(Boolean) .filter(Boolean)
.map((l) => JSON.parse(l)); .map((l) => JSON.parse(l) as Issue);
} }
const matches = issues const matches = issues
@@ -123,7 +129,7 @@ if (!matches.length) {
// Neutralise other issues' titles before echoing them back: break @mentions and // Neutralise other issues' titles before echoing them back: break @mentions and
// strip markdown/HTML control chars so a maliciously-named issue can't ping people // strip markdown/HTML control chars so a maliciously-named issue can't ping people
// or inject formatting into our comment. GitHub linkifies "#123" on its own. // or inject formatting into our comment. GitHub linkifies "#123" on its own.
const safeTitle = (t) => const safeTitle = (t: string): string =>
(t || "") (t || "")
.replace(/@/g, "@") .replace(/@/g, "@")
.replace(/[`<>|*_~[\]]/g, " ") .replace(/[`<>|*_~[\]]/g, " ")

View File

@@ -1,62 +0,0 @@
#!/usr/bin/env node
const _fs = require("node:fs");
const path = require("node:path");
const process = require("node:process");
const { execSync } = require("node:child_process");
const root = process.cwd();
// const tvosPath = path.join(root, 'iostv');
// const iosPath = path.join(root, 'iosmobile');
// const androidPath = path.join(root, 'androidmobile');
// const androidTVPath = path.join(root, 'androidtv');
// const device = process.argv[2];
// const platform = process.argv[2];
const isTV = process.env.EXPO_TV || false;
const paths = new Map([
["tvos", path.join(root, "iostv")],
["ios", path.join(root, "iosmobile")],
["android", path.join(root, "androidmobile")],
["androidtv", path.join(root, "androidtv")],
]);
// const platformPath = paths.get(platform);
if (isTV) {
stdout = execSync(
`mkdir -p ${paths.get("tvos")}; ln -nsf ${paths.get("tvos")} ios`,
);
console.log(stdout.toString());
stdout = execSync(
`mkdir -p ${paths.get("androidtv")}; ln -nsf ${paths.get(
"androidtv",
)} android`,
);
console.log(stdout.toString());
} else {
stdout = execSync(
`mkdir -p ${paths.get("ios")}; ln -nsf ${paths.get("ios")} ios`,
);
console.log(stdout.toString());
stdout = execSync(
`mkdir -p ${paths.get("android")}; ln -nsf ${paths.get("android")} android`,
);
console.log(stdout.toString());
}
// target = "";
// switch (platform) {
// case "tvos":
// target = "ios";
// break;
// case "ios":
// target = "ios";
// break;
// case "android":
// target = "android";
// break;
// case "androidtv":
// target = "android";
// break;
// }

View File

@@ -1,5 +1,8 @@
const { execFileSync } = require("node:child_process"); import { execFileSync } from "node:child_process";
const process = require("node:process"); import { createRequire } from "node:module";
import process from "node:process";
const require = createRequire(import.meta.url);
// Enhanced ANSI color codes and styles // Enhanced ANSI color codes and styles
const colors = { const colors = {
@@ -32,7 +35,7 @@ const centeredTitle = " ".repeat(titlePadding) + title;
const useColor = process.stdout.isTTY && !process.env.NO_COLOR; const useColor = process.stdout.isTTY && !process.env.NO_COLOR;
function log(message, color = "") { function log(message: string, color = "") {
if (useColor && color) { if (useColor && color) {
console.log(`${color}${message}${colors.reset}`); console.log(`${color}${message}${colors.reset}`);
} else { } else {
@@ -40,7 +43,7 @@ function log(message, color = "") {
} }
} }
function formatError(errorLine) { function formatError(errorLine: string): string {
if (!useColor) return errorLine; if (!useColor) return errorLine;
// Color file paths in cyan // Color file paths in cyan
@@ -70,12 +73,15 @@ function formatError(errorLine) {
return formatted; return formatted;
} }
function parseErrorsAndCreateSummary(errorOutput) { function parseErrorsAndCreateSummary(errorOutput: string): {
formattedErrors: string[];
errorsByFile: Map<string, number>;
} {
const lines = errorOutput.split("\n").filter((line) => line.trim()); const lines = errorOutput.split("\n").filter((line) => line.trim());
const errorsByFile = new Map(); const errorsByFile = new Map<string, number>();
const formattedErrors = []; const formattedErrors: string[] = [];
let currentError = []; let currentError: string[] = [];
for (const line of lines) { for (const line of lines) {
const trimmedLine = line.trim(); const trimmedLine = line.trim();
@@ -96,7 +102,7 @@ function parseErrorsAndCreateSummary(errorOutput) {
if (!errorsByFile.has(filePath)) { if (!errorsByFile.has(filePath)) {
errorsByFile.set(filePath, 0); errorsByFile.set(filePath, 0);
} }
errorsByFile.set(filePath, errorsByFile.get(filePath) + 1); errorsByFile.set(filePath, (errorsByFile.get(filePath) ?? 0) + 1);
// Start new error // Start new error
currentError.push(formatError(line)); currentError.push(formatError(line));
@@ -119,7 +125,7 @@ function parseErrorsAndCreateSummary(errorOutput) {
return { formattedErrors, errorsByFile }; return { formattedErrors, errorsByFile };
} }
function createErrorSummaryTable(errorsByFile) { function createErrorSummaryTable(errorsByFile: Map<string, number>): string {
if (errorsByFile.size === 0) return ""; if (errorsByFile.size === 0) return "";
const sortedFiles = Array.from(errorsByFile.entries()).sort( const sortedFiles = Array.from(errorsByFile.entries()).sort(
@@ -136,12 +142,12 @@ function createErrorSummaryTable(errorsByFile) {
return table; return table;
} }
function runTypeCheck() { function runTypeCheck(): { ok: boolean } {
const extraArgs = process.argv.slice(2); const extraArgs = process.argv.slice(2);
// Prefer local TypeScript binary when available // Prefer local TypeScript binary when available
const runnerArgs = ["-p", "tsconfig.json", "--noEmit", ...extraArgs]; const runnerArgs = ["-p", "tsconfig.json", "--noEmit", ...extraArgs];
let execArgs = null; let execArgs: { cmd: string; args: string[] };
try { try {
const tscBin = require.resolve("typescript/bin/tsc"); const tscBin = require.resolve("typescript/bin/tsc");
execArgs = { cmd: process.execPath, args: [tscBin, ...runnerArgs] }; execArgs = { cmd: process.execPath, args: [tscBin, ...runnerArgs] };
@@ -174,7 +180,8 @@ function runTypeCheck() {
); );
return { ok: true }; return { ok: true };
} catch (error) { } catch (error) {
const errorOutput = (error && (error.stderr || error.stdout)) || ""; const execError = error as { stderr?: string; stdout?: string };
const errorOutput = execError.stderr || execError.stdout || "";
// Filter out jellyseerr utils errors - this is a third-party git submodule // Filter out jellyseerr utils errors - this is a third-party git submodule
// that generates a large volume of known type errors // that generates a large volume of known type errors

View File

@@ -3,17 +3,17 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this * License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. * file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/ */
import { generateDeviceProfile } from "./native"; import type {
DeviceProfile,
/** SubtitleProfile,
* @typedef {"auto" | "stereo" | "5.1" | "passthrough"} AudioTranscodeModeType } from "@jellyfin/sdk/lib/generated-client/models";
*/ import { type AudioTranscodeModeType, generateDeviceProfile } from "./native";
/** /**
* Download-specific subtitle profiles. * Download-specific subtitle profiles.
* These are more permissive than streaming profiles since we can embed subtitles. * These are more permissive than streaming profiles since we can embed subtitles.
*/ */
const downloadSubtitleProfiles = [ const downloadSubtitleProfiles: SubtitleProfile[] = [
// Official formats // Official formats
{ Format: "vtt", Method: "Encode" }, { Format: "vtt", Method: "Encode" },
{ Format: "webvtt", Method: "Encode" }, { Format: "webvtt", Method: "Encode" },
@@ -46,11 +46,10 @@ const downloadSubtitleProfiles = [
/** /**
* Generates a device profile optimized for downloads. * Generates a device profile optimized for downloads.
* Uses the same audio codec logic as streaming but with download-specific bitrate limits. * Uses the same audio codec logic as streaming but with download-specific bitrate limits.
*
* @param {AudioTranscodeModeType} [audioMode="auto"] - Audio transcoding mode
* @returns {Object} Jellyfin device profile for downloads
*/ */
export const generateDownloadProfile = (audioMode = "auto") => { export const generateDownloadProfile = (
audioMode: AudioTranscodeModeType = "auto",
): DeviceProfile => {
// Get the base profile with proper audio codec configuration // Get the base profile with proper audio codec configuration
const baseProfile = generateDeviceProfile({ audioMode }); const baseProfile = generateDeviceProfile({ audioMode });

View File

@@ -3,6 +3,7 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this * License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. * file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/ */
import type { DeviceProfile } from "@jellyfin/sdk/lib/generated-client/models";
import { Platform } from "react-native"; import { Platform } from "react-native";
import MediaTypes from "../../constants/MediaTypes"; import MediaTypes from "../../constants/MediaTypes";
import { getSubtitleProfiles } from "./subtitles"; import { getSubtitleProfiles } from "./subtitles";
@@ -193,7 +194,7 @@ export const generateDeviceProfile = (options: ProfileOptions = {}) => {
}, },
], ],
SubtitleProfiles: getSubtitleProfiles(), SubtitleProfiles: getSubtitleProfiles(),
}; } satisfies DeviceProfile;
return profile; return profile;
}; };

View File

@@ -3,6 +3,7 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this * License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. * file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/ */
import type { SubtitleProfile } from "@jellyfin/sdk/lib/generated-client/models";
// Image-based formats - these need to be burned in by Jellyfin (Encode method) // Image-based formats - these need to be burned in by Jellyfin (Encode method)
// because MPV cannot load them externally over HTTP // because MPV cannot load them externally over HTTP
@@ -13,7 +14,7 @@ const IMAGE_BASED_FORMATS = [
"pgssub", "pgssub",
"teletext", "teletext",
"vobsub", "vobsub",
]; ] as const;
// Text-based formats - these can be loaded externally by MPV // Text-based formats - these can be loaded externally by MPV
const TEXT_BASED_FORMATS = [ const TEXT_BASED_FORMATS = [
@@ -37,10 +38,10 @@ const TEXT_BASED_FORMATS = [
"text", "text",
"vplayer", "vplayer",
"xsub", "xsub",
]; ] as const;
export const getSubtitleProfiles = () => { export const getSubtitleProfiles = (): SubtitleProfile[] => {
const profiles = []; const profiles: SubtitleProfile[] = [];
// Image-based formats: Embed or Encode (burn-in), NOT External // Image-based formats: Embed or Encode (burn-in), NOT External
for (const format of IMAGE_BASED_FORMATS) { for (const format of IMAGE_BASED_FORMATS) {
@@ -58,4 +59,4 @@ export const getSubtitleProfiles = () => {
}; };
// Export for use in player filtering // Export for use in player filtering
export const IMAGE_SUBTITLE_CODECS = IMAGE_BASED_FORMATS; export const IMAGE_SUBTITLE_CODECS: readonly string[] = IMAGE_BASED_FORMATS;

View File

@@ -1,19 +0,0 @@
/**
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
export type PlatformType = "ios" | "android";
export interface TrackPlayerProfileOptions {
/** Target platform */
platform?: PlatformType;
}
export function generateTrackPlayerProfile(
options?: TrackPlayerProfileOptions,
): any;
declare const _default: any;
export default _default;

View File

@@ -3,23 +3,26 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this * License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. * file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/ */
import type {
CodecProfile,
DeviceProfile,
DirectPlayProfile,
} from "@jellyfin/sdk/lib/generated-client/models";
import { Platform } from "react-native"; import { Platform } from "react-native";
import MediaTypes from "../../constants/MediaTypes"; import MediaTypes from "../../constants/MediaTypes";
/** export type PlatformType = "ios" | "android";
* @typedef {"ios" | "android"} PlatformType
* export interface TrackPlayerProfileOptions {
* @typedef {Object} TrackPlayerProfileOptions /** Target platform */
* @property {PlatformType} [platform] - Target platform platform?: PlatformType;
*/ }
/** /**
* Audio direct play profiles for react-native-track-player. * Audio direct play profiles for react-native-track-player.
* iOS uses AVPlayer, Android uses ExoPlayer - each has different codec support. * iOS uses AVPlayer, Android uses ExoPlayer - each has different codec support.
*
* @param {PlatformType} platform
*/ */
const getDirectPlayProfile = (platform) => { const getDirectPlayProfile = (platform: PlatformType): DirectPlayProfile => {
if (platform === "ios") { if (platform === "ios") {
// iOS AVPlayer supported formats // iOS AVPlayer supported formats
return { return {
@@ -39,10 +42,8 @@ const getDirectPlayProfile = (platform) => {
/** /**
* Audio codec profiles for react-native-track-player. * Audio codec profiles for react-native-track-player.
*
* @param {PlatformType} platform
*/ */
const getCodecProfile = (platform) => { const getCodecProfile = (platform: PlatformType): CodecProfile => {
if (platform === "ios") { if (platform === "ios") {
// iOS AVPlayer codec constraints // iOS AVPlayer codec constraints
return { return {
@@ -64,12 +65,11 @@ const getCodecProfile = (platform) => {
* This profile is specifically for standalone audio playback using: * This profile is specifically for standalone audio playback using:
* - AVPlayer on iOS * - AVPlayer on iOS
* - ExoPlayer on Android * - ExoPlayer on Android
*
* @param {TrackPlayerProfileOptions} [options] - Profile configuration options
* @returns {Object} Jellyfin device profile for track player
*/ */
export const generateTrackPlayerProfile = (options = {}) => { export const generateTrackPlayerProfile = (
const platform = options.platform || Platform.OS; options: TrackPlayerProfileOptions = {},
): DeviceProfile => {
const platform = (options.platform || Platform.OS) as PlatformType;
return { return {
Name: "Track Player", Name: "Track Player",