Proyecto App Windows

This commit is contained in:
2026-05-03 23:36:48 +02:00
commit 96793b2e1e
1248 changed files with 189750 additions and 0 deletions
+227
View File
@@ -0,0 +1,227 @@
/**
* Spanish copy for the Xtream / workspace shell UI (replaces merged English in es.json).
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const esPath = path.resolve(
__dirname,
'..',
'..',
'apps/web/src/assets/i18n/es.json'
);
const j = JSON.parse(fs.readFileSync(esPath, 'utf8'));
const shell = {
GO_TO_DASHBOARD: 'Volver al panel',
GO_TO_SOURCES: 'Ir a fuentes',
DROP_OVERLAY_TITLE: 'Suelta para añadir lista',
DROP_OVERLAY_BODY:
'Suelta un archivo .m3u o .m3u8 en cualquier parte del espacio de trabajo para importarlo.',
DROP_OVERLAY_REJECTED_BODY:
'Suelta un archivo .m3u o .m3u8. Pulsa Esc para cerrar.',
DROP_OVERLAY_REJECTED_UNSUPPORTED: 'Tipo de archivo no admitido',
DROP_OVERLAY_REJECTED_EMPTY: 'Ese archivo está vacío',
DROP_OVERLAY_REJECTED_READ_ERROR: 'No se pudo leer el archivo',
DROP_IMPORT_SUCCESS: 'Importando «{{title}}»…',
EXTERNAL_PLAYBACK_CLOSE: 'Cerrar reproductor',
BRAND_ALT: 'IPTVnator',
RAIL_DASHBOARD: 'Panel',
OPEN_DASHBOARD: 'Abrir panel',
RAIL_SOURCES: 'Fuentes',
OPEN_SOURCES: 'Abrir fuentes',
RAIL_GLOBAL_FAVORITES: 'Favoritos globales',
RAIL_GLOBAL_RECENT: 'Visto recientemente',
RAIL_SETTINGS: 'Ajustes',
OPEN_SETTINGS: 'Abrir ajustes',
RAIL_LIBRARY: 'Biblioteca',
RAIL_MOVIES: 'Películas',
RAIL_LIVE: 'TV en directo',
RAIL_SERIES: 'Series',
RAIL_RECENTLY_ADDED: 'Añadidos recientemente',
RAIL_SEARCH: 'Búsqueda avanzada',
RAIL_RECENT: 'Visto recientemente',
RAIL_FAVORITES: 'Favoritos',
RAIL_DOWNLOADS: 'Descargas',
RAIL_ALL_CHANNELS: 'Todos los canales',
RAIL_GROUPS: 'Grupos',
RAIL_CONTEXT_ACTIONS: 'Acciones de la sección',
SEARCH_LABEL: 'Buscar',
SEARCH_SOURCES_PLACEHOLDER: 'Buscar fuentes (todas las listas)…',
SEARCH_PLAYLIST_PLACEHOLDER: 'Buscar en esta lista…',
SEARCH_SECTION_PLACEHOLDER: 'Buscar en esta sección…',
FILTER_SECTION_PLACEHOLDER: 'Filtrar esta sección…',
SEARCH_STATUS_LOADED_ONLY: 'Solo canales cargados',
COMMAND_PALETTE_SHORTCUT: 'Paleta de comandos (Ctrl/Cmd+K)',
OPEN_COMMAND_PALETTE: 'Abrir paleta de comandos',
ADD_PLAYLIST_ALL: 'Añadir lista',
ADD_PLAYLIST: 'Añadir lista',
OPEN_DOWNLOADS: 'Abrir descargas',
OPEN_CONTEXT_ACTIONS: 'Abrir acciones de la sección',
NO_SECTION_ACTIONS: 'No hay acciones para esta sección',
PLAYLIST_INFO: 'Info de la lista',
ACCOUNT_INFO: 'Info de la cuenta',
REFRESH_PLAYLIST: 'Actualizar lista',
CLEAR_COMPLETED_DOWNLOADS_ALL: 'Borrar descargas completadas (todas las listas)',
CLEAR_COMPLETED_DOWNLOADS_ALL_ARIA:
'Borrar descargas completadas de todas las listas',
CLEAR_COMPLETED_DOWNLOADS_THIS_PLAYLIST:
'Borrar descargas completadas (esta lista)',
CLEAR_COMPLETED_DOWNLOADS_THIS_PLAYLIST_ARIA:
'Borrar descargas completadas de esta lista',
CLEAR_RECENTLY_VIEWED_SECTION: 'Borrar visto recientemente',
CLEAR_RECENTLY_VIEWED_SECTION_ARIA:
'Borrar visto recientemente de esta sección',
CLEAR_RECENTLY_VIEWED_TYPE: 'Borrar visto recientemente ({{type}})',
CLEAR_RECENTLY_VIEWED_DIALOG_TITLE:
'¿Borrar visto recientemente ({{type}})?',
CLEAR_RECENTLY_VIEWED_DIALOG_MESSAGE_PLAYLIST:
'Se quitarán del historial todos los elementos de {{type}} de esta lista. No se puede deshacer.',
CLEAR_RECENTLY_VIEWED_DIALOG_MESSAGE_ALL:
'Se quitarán del historial todos los elementos de {{type}} de todas las listas. No se puede deshacer.',
CLEAR_FAVORITES_TYPE: 'Borrar favoritos de {{type}}',
CLEAR_FAVORITES_DIALOG_TITLE: '¿Quitar todos los favoritos de {{type}}?',
CLEAR_FAVORITES_DIALOG_MESSAGE_PLAYLIST:
'Se quitarán todos los favoritos de {{type}} de esta lista. No se puede deshacer.',
CLEAR_FAVORITES_DIALOG_MESSAGE_ALL:
'Se quitarán todos los favoritos de {{type}} de todas las listas. No se puede deshacer.',
XTREAM_CODE: 'Código Xtream',
STALKER_PORTAL: 'Portal Stalker',
CHANNELS_COUNT: '{{count}} canales',
NO_SOURCES_AVAILABLE: 'No hay fuentes disponibles',
ONE_SOURCE_AVAILABLE: '1 fuente disponible',
SOURCES_AVAILABLE: '{{count}} fuentes disponibles',
XTREAM_IMPORT_TITLE: 'Sincronizando lista',
XTREAM_IMPORT_REMOTE_BADGE: 'Fuente remota',
XTREAM_IMPORT_LOCAL_BADGE: 'Biblioteca local',
XTREAM_IMPORT_DETAIL_REMOTE: 'Esperando respuesta del proveedor.',
XTREAM_IMPORT_DETAIL_LOCAL:
'Preparando un cambio de lista más rápido para la próxima visita.',
XTREAM_IMPORT_PREPARING: 'Preparando biblioteca local…',
XTREAM_IMPORT_LOADING: 'Obteniendo datos de la lista…',
XTREAM_IMPORT_SAVING: 'Guardando datos de la lista en local…',
XTREAM_IMPORT_RESTORING_FAVORITES: 'Restaurando favoritos desde local…',
XTREAM_IMPORT_RESTORING_RECENT: 'Restaurando visto recientemente desde local…',
XTREAM_IMPORT_PROGRESS: '{{type}} importados: {{current}} / {{total}}',
XTREAM_IMPORT_CANCEL: 'Detener sincronización',
XTREAM_IMPORT_CANCELLING: 'Deteniendo sincronización…',
};
const shellCommands = {
ADD_PLAYLIST_M3U_LABEL: 'Añadir lista M3U',
ADD_PLAYLIST_M3U_DESCRIPTION:
'Abrir el diálogo en la pestaña de archivo o URL M3U',
ADD_PLAYLIST_XTREAM_LABEL: 'Añadir lista Xtream Codes',
ADD_PLAYLIST_XTREAM_DESCRIPTION:
'Abrir el diálogo en la pestaña Xtream Codes',
ADD_PLAYLIST_STALKER_LABEL: 'Añadir portal Stalker',
ADD_PLAYLIST_STALKER_DESCRIPTION:
'Abrir el diálogo en la pestaña Portal Stalker',
GLOBAL_SEARCH_LABEL: 'Buscar en todas las listas Xtream',
GLOBAL_SEARCH_DESCRIPTION: 'Abrir búsqueda global',
PLAYLIST_SEARCH_LABEL: 'Búsqueda avanzada en esta lista',
PLAYLIST_SEARCH_DESCRIPTION: 'Abrir búsqueda avanzada de la lista',
OPEN_GLOBAL_FAVORITES_LABEL: 'Abrir favoritos globales',
OPEN_GLOBAL_FAVORITES_DESCRIPTION: 'Ir a favoritos agregados',
OPEN_DOWNLOADS_LABEL: 'Abrir descargas',
OPEN_DOWNLOADS_DESCRIPTION: 'Ir a la vista de descargas',
OPEN_GLOBAL_RECENT_LABEL: 'Abrir visto recientemente',
OPEN_GLOBAL_RECENT_DESCRIPTION: 'Abrir página global de recientes',
OPEN_SETTINGS_LABEL: 'Abrir ajustes',
OPEN_SETTINGS_DESCRIPTION: 'Ir a los ajustes de la aplicación',
OPEN_SOURCES_LABEL: 'Gestionar fuentes',
OPEN_SOURCES_DESCRIPTION: 'Ir a la gestión de fuentes',
OPEN_DASHBOARD_LABEL: 'Abrir panel',
OPEN_DASHBOARD_DESCRIPTION: 'Ir al panel',
ADD_PLAYLIST_LABEL: 'Añadir fuente',
ADD_PLAYLIST_DESCRIPTION: 'Abrir diálogo para añadir lista',
REFRESH_PLAYLIST_LABEL: 'Actualizar lista',
REFRESH_PLAYLIST_DESCRIPTION: 'Volver a sincronizar con el origen',
PLAYLIST_INFO_LABEL: 'Detalles de la lista',
PLAYLIST_INFO_DESCRIPTION: 'Mostrar información de la lista actual',
ACCOUNT_INFO_LABEL: 'Info de la cuenta',
ACCOUNT_INFO_DESCRIPTION: 'Mostrar datos de la cuenta Xtream',
OPEN_VIEW_DESCRIPTION: 'Ir a {{view}}',
CLEAR_CURRENT_VIEW_DESCRIPTION:
'Borrar todos los elementos visibles de {{type}} en esta vista',
OPEN_MULTI_EPG_DESCRIPTION: 'Abrir la guía multi-canal',
GO_TO_VOD_LABEL: 'Películas',
GO_TO_VOD_DESCRIPTION: 'Ir a películas',
GO_TO_LIVE_LABEL: 'TV en directo',
GO_TO_LIVE_DESCRIPTION: 'Ir a TV en directo',
GO_TO_SERIES_LABEL: 'Series',
GO_TO_SERIES_DESCRIPTION: 'Ir a series',
};
const context = {
CATEGORIES: 'Categorías',
MOVIE_CATEGORIES: 'Categorías de películas',
SERIES_CATEGORIES: 'Categorías de series',
LIVE_CATEGORIES: 'Categorías en directo',
XTREAM_SYNCING_MOVIES: 'Sincronizando categorías de películas…',
XTREAM_SYNCING_SERIES: 'Sincronizando categorías de series…',
XTREAM_SYNCING_LIVE: 'Sincronizando categorías en directo…',
XTREAM_SOURCE: 'Fuente Xtream',
STALKER_PORTAL: 'Portal Stalker',
MANAGE_CATEGORIES: 'Gestionar categorías',
LOAD_CATEGORIES_ERROR: 'No se pudieron cargar las categorías',
LOAD_CATEGORIES_ERROR_HINT:
'Reintenta o comprueba la configuración del portal.',
FILTER_BY_TYPE: 'Filtrar por tipo',
ITEM_COUNT_ONE: '1 elemento',
ITEM_COUNT_OTHER: '{{count}} elementos',
SEARCH: 'Buscar',
SEARCH_CATEGORIES: 'Buscar categorías',
};
const commandPalette = {
TITLE: 'Paleta de comandos',
SUBTITLE: 'Acciones globales y del contexto',
SEARCH_PLACEHOLDER: 'Escribe para filtrar comandos',
SEARCH_LABEL: 'Filtrar comandos',
EMPTY: 'No se encontraron comandos',
GROUP_GLOBAL: 'Acciones globales',
GROUP_PLAYLIST: 'Esta lista',
GROUP_VIEW: 'Vista actual',
GROUP_SECTION: 'Esta sección',
};
j.WORKSPACE = j.WORKSPACE || {};
j.WORKSPACE.SHELL = { ...j.WORKSPACE.SHELL, ...shell };
j.WORKSPACE.SHELL.COMMANDS = {
...j.WORKSPACE.SHELL.COMMANDS,
...shellCommands,
};
j.WORKSPACE.CONTEXT = { ...j.WORKSPACE.CONTEXT, ...context };
j.WORKSPACE.COMMAND_PALETTE = {
...j.WORKSPACE.COMMAND_PALETTE,
...commandPalette,
};
if (j.PORTALS) {
j.PORTALS.SELECT_CATEGORY = 'Selecciona una categoría';
}
if (j.CANCEL === 'Cancel') j.CANCEL = 'Cancelar';
if (j.CLEAR === 'Clear') j.CLEAR = 'Limpiar';
if (j.SHOW_MORE === 'More') j.SHOW_MORE = 'Más';
if (j.SHOW_LESS === 'Less') j.SHOW_LESS = 'Menos';
if (j.PLAYLISTS === 'Playlists') j.PLAYLISTS = 'Listas de reproducción';
if (j.REMOTE_CONTROL?.TITLE === 'Remote control') {
j.REMOTE_CONTROL = {
...j.REMOTE_CONTROL,
TITLE: 'Control remoto',
SUBTITLE: 'Controla el reproductor IPTV a distancia',
UP_CHANNEL: 'Canal siguiente',
DOWN_CHANNEL: 'Canal anterior',
HEADER: 'Control remoto IPTVnator',
FOOTER: 'Control remoto IPTVnator',
};
}
fs.writeFileSync(esPath, JSON.stringify(j, null, 4) + '\n');
console.log('Applied Spanish UI strings for WORKSPACE shell / PORTALS / root keys.');
+45
View File
@@ -0,0 +1,45 @@
/**
* Recursively copies missing keys from en.json into es.json (does not overwrite
* existing Spanish strings). Fixes ngx-translate showing raw keys like WORKSPACE.SHELL.*.
*
* After running this, optionally run `node tools/i18n/apply-shell-es-ui.mjs` to
* replace merged English workspace shell strings with Spanish for KiraTV / es UI.
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const root = path.resolve(__dirname, '..', '..');
const enPath = path.join(root, 'apps/web/src/assets/i18n/en.json');
const esPath = path.join(root, 'apps/web/src/assets/i18n/es.json');
function fillMissing(enVal, esVal) {
if (enVal === null || typeof enVal !== 'object' || Array.isArray(enVal)) {
return esVal !== undefined ? esVal : enVal;
}
const out =
esVal !== null && typeof esVal === 'object' && !Array.isArray(esVal)
? { ...esVal }
: {};
for (const [k, v] of Object.entries(enVal)) {
if (out[k] === undefined) {
out[k] = Array.isArray(v) ? [...v] : v;
} else if (
v !== null &&
typeof v === 'object' &&
!Array.isArray(v) &&
typeof out[k] === 'object' &&
!Array.isArray(out[k])
) {
out[k] = fillMissing(v, out[k]);
}
}
return out;
}
const en = JSON.parse(fs.readFileSync(enPath, 'utf8'));
const es = JSON.parse(fs.readFileSync(esPath, 'utf8'));
const merged = fillMissing(en, es);
fs.writeFileSync(esPath, JSON.stringify(merged, null, 4) + '\n');
console.log('Merged missing i18n keys from en.json into es.json');
+75
View File
@@ -0,0 +1,75 @@
#!/usr/bin/env node
/**
* Downloads the official VLC for Windows x64 zip and unpacks it into
* tools/packaging/bundled-vlc/win64/ (vlc.exe at the root of that folder).
* Used by make-kiratv-windows-nsis.mjs so the NSIS installer ships VLC in resources/vlc.
*
* GPL-2.0+ applies to VLC; see https://www.videolan.org/legal.html
*/
import { execSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const root = path.resolve(__dirname, '..', '..');
const destDir = path.join(root, 'tools', 'packaging', 'bundled-vlc', 'win64');
const VLC_VERSION = '3.0.21';
const zipName = `vlc-${VLC_VERSION}-win64.zip`;
const zipUrl = `https://get.videolan.org/vlc/${VLC_VERSION}/win64/${zipName}`;
const force = process.argv.includes('--force');
const vlcExe = path.join(destDir, 'vlc.exe');
if (!force && fs.existsSync(vlcExe)) {
console.log('VLC bundle already present:', vlcExe);
console.log('Use --force to re-download.');
process.exit(0);
}
const tmpParent = path.join(root, 'tmp');
fs.mkdirSync(tmpParent, { recursive: true });
const tmpRoot = fs.mkdtempSync(path.join(tmpParent, 'vlc-dl-'));
const zipPath = path.join(tmpRoot, zipName);
const extractDir = path.join(tmpRoot, 'extracted');
try {
console.log('Downloading', zipUrl);
const res = await fetch(zipUrl, { redirect: 'follow' });
if (!res.ok) {
throw new Error(`HTTP ${res.status} ${res.statusText}`);
}
const buf = Buffer.from(await res.arrayBuffer());
fs.writeFileSync(zipPath, buf);
fs.mkdirSync(extractDir, { recursive: true });
if (process.platform === 'win32') {
execSync(`tar -xf "${zipPath}" -C "${extractDir}"`, {
stdio: 'inherit',
shell: true,
});
} else {
execSync(`unzip -o -q "${zipPath}" -d "${extractDir}"`, {
stdio: 'inherit',
});
}
const top = fs.readdirSync(extractDir, { withFileTypes: true });
const innerDir = top.find((e) => e.isDirectory());
if (!innerDir) {
throw new Error('Unexpected zip layout: no top-level directory');
}
const innerPath = path.join(extractDir, innerDir.name);
const innerVlc = path.join(innerPath, 'vlc.exe');
if (!fs.existsSync(innerVlc)) {
throw new Error('Unexpected zip layout: vlc.exe not found under ' + innerPath);
}
fs.rmSync(destDir, { recursive: true, force: true });
fs.mkdirSync(path.dirname(destDir), { recursive: true });
fs.cpSync(innerPath, destDir, { recursive: true });
console.log('VLC extracted to', destDir);
} finally {
fs.rmSync(tmpRoot, { recursive: true, force: true });
}
+72
View File
@@ -0,0 +1,72 @@
const fs = require('fs/promises');
const path = require('path');
function log(message) {
console.log(` - ${message}`);
}
function createLoaderScript({ executableName, productName }) {
return `#!/usr/bin/env bash
set -u
UNPRIVILEGED_USERNS_ENABLED=$(cat /proc/sys/kernel/unprivileged_userns_clone 2>/dev/null)
RESTRICT_UNPRIVILEGED_USERNS=$(cat /proc/sys/kernel/apparmor_restrict_unprivileged_userns 2>/dev/null)
SCRIPT_PATH="\${BASH_SOURCE[0]}"
if command -v readlink >/dev/null 2>&1; then
RESOLVED_SCRIPT_PATH=$(readlink -f "$SCRIPT_PATH" 2>/dev/null || true)
if [ -n "$RESOLVED_SCRIPT_PATH" ]; then
SCRIPT_PATH="$RESOLVED_SCRIPT_PATH"
fi
fi
SCRIPT_DIR="$(cd "$(dirname "$SCRIPT_PATH")" && pwd)"
APPLY_NO_SANDBOX_FLAG=0
if [ "$UNPRIVILEGED_USERNS_ENABLED" != 1 ] || [ "$RESTRICT_UNPRIVILEGED_USERNS" = 1 ]; then
APPLY_NO_SANDBOX_FLAG=1
fi
if [ "$SCRIPT_DIR" = "/usr/bin" ]; then
SCRIPT_DIR="/opt/${productName}"
fi
EXEC_ARGS=()
if [ "$APPLY_NO_SANDBOX_FLAG" = 1 ]; then
echo "Note: Running with --no-sandbox since unprivileged_userns_clone is disabled or apparmor_restrict_unprivileged_userns is enabled."
EXEC_ARGS+=(--no-sandbox)
fi
exec "$SCRIPT_DIR/${executableName}.bin" "\${EXEC_ARGS[@]}" "$@"
`;
}
async function afterPackHook(params) {
if (params.electronPlatformName !== 'linux') {
return;
}
log('applying Linux launcher sandbox fix');
const executable = path.join(params.appOutDir, params.packager.executableName);
try {
await fs.rename(executable, `${executable}.bin`);
await fs.writeFile(
executable,
createLoaderScript({
executableName: params.packager.executableName,
productName: params.packager.appInfo.productName,
})
);
await fs.chmod(executable, 0o755);
} catch (error) {
log(`failed to create launcher wrapper: ${error.message}`);
throw new Error('Failed to create launcher wrapper');
}
log('Linux launcher sandbox fix applied');
}
module.exports = afterPackHook;
module.exports.createLoaderScript = createLoaderScript;
@@ -0,0 +1,52 @@
#!/usr/bin/env node
/**
* Build KiraTV and package .deb + AppImage for the current machine architecture.
*/
import { execSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const workspaceRoot = path.resolve(__dirname, '..', '..');
const configPath = path.join(workspaceRoot, 'electron-builder.json');
function linuxArchFromNode() {
switch (process.arch) {
case 'arm64':
return 'arm64';
case 'arm':
return 'armv7l';
default:
return 'x64';
}
}
const original = fs.readFileSync(configPath, 'utf8');
const config = JSON.parse(original);
const arch = linuxArchFromNode();
config.linux = config.linux ?? {};
config.linux.target = [
{ target: 'deb', arch: [arch] },
{ target: 'AppImage', arch: [arch] },
];
fs.writeFileSync(configPath, JSON.stringify(config, null, 4) + '\n');
const run = (cmd) => {
execSync(cmd, { stdio: 'inherit', cwd: workspaceRoot, env: process.env });
};
try {
const nx = path.join(workspaceRoot, 'node_modules', '.bin', 'nx');
const nxCmd = fs.existsSync(nx) ? `"${nx}"` : 'npx nx';
run(`${nxCmd} run electron-backend:build --configuration=kiratv`);
run(`${nxCmd} run electron-backend:make --configuration=kiratv`);
console.log(
'\nPaquetes generados en dist/executables: .deb y .AppImage (kiratv-*-linux-*).\n'
);
} finally {
fs.writeFileSync(configPath, original);
}
+50
View File
@@ -0,0 +1,50 @@
#!/usr/bin/env node
/**
* Build KiraTV and package only a .deb for the current machine architecture.
* Temporarily narrows electron-builder linux targets, then restores config.
*/
import { execSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const workspaceRoot = path.resolve(__dirname, '..', '..');
const configPath = path.join(workspaceRoot, 'electron-builder.json');
function debArchFromNode() {
switch (process.arch) {
case 'arm64':
return 'arm64';
case 'arm':
return 'armv7l';
default:
return 'x64';
}
}
const original = fs.readFileSync(configPath, 'utf8');
const config = JSON.parse(original);
const arch = debArchFromNode();
config.linux = config.linux ?? {};
config.linux.target = [{ target: 'deb', arch: [arch] }];
fs.writeFileSync(configPath, JSON.stringify(config, null, 4) + '\n');
const run = (cmd) => {
execSync(cmd, { stdio: 'inherit', cwd: workspaceRoot, env: process.env });
};
try {
const nx = path.join(workspaceRoot, 'node_modules', '.bin', 'nx');
const nxCmd = fs.existsSync(nx) ? `"${nx}"` : 'npx nx';
run(`${nxCmd} run electron-backend:build --configuration=kiratv`);
run(`${nxCmd} run electron-backend:make --configuration=kiratv`);
console.log(
'\nPaquete .deb generado en dist/executables (busca kiratv-*-linux-*.deb).\n'
);
} finally {
fs.writeFileSync(configPath, original);
}
@@ -0,0 +1,28 @@
#!/usr/bin/env node
/**
* Linux: .deb + AppImage (arquitectura actual).
* Windows: solo si process.platform === 'win32' → NSIS x64 con VLC embebido.
*/
import { execSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const root = path.resolve(__dirname, '..', '..');
function run(relScript) {
const p = path.join(root, relScript);
execSync(`node "${p}"`, { stdio: 'inherit', cwd: root, env: process.env });
}
run('tools/packaging/make-kiratv-linux-deb-appimage.mjs');
if (process.platform === 'win32') {
run('tools/packaging/make-kiratv-windows-nsis.mjs');
} else {
console.log(
'\n[Windows] Omitido en este SO. En un PC Windows ejecuta:\n' +
' pnpm run make:kiratv:windows-nsis\n'
);
}
@@ -0,0 +1,89 @@
#!/usr/bin/env node
/**
* Windows only: download bundled VLC, patch electron-builder to ship resources/vlc,
* then build KiraTV NSIS installer (x64).
*/
import { execSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
if (process.platform !== 'win32') {
console.error(
'Este script debe ejecutarse en Windows (Node win32).\n' +
'Para generar el .exe con VLC embebido, usa una máquina Windows o un job windows-latest en CI.'
);
process.exit(1);
}
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const workspaceRoot = path.resolve(__dirname, '..', '..');
const configPath = path.join(workspaceRoot, 'electron-builder.json');
execSync(`node "${path.join(__dirname, 'download-vlc-windows.mjs')}"`, {
stdio: 'inherit',
cwd: workspaceRoot,
});
const vlcExe = path.join(
workspaceRoot,
'tools',
'packaging',
'bundled-vlc',
'win64',
'vlc.exe'
);
if (!fs.existsSync(vlcExe)) {
console.error('Missing bundled VLC after download:', vlcExe);
process.exit(1);
}
const original = fs.readFileSync(configPath, 'utf8');
const config = JSON.parse(original);
const baseExtra = Array.isArray(config.extraResources)
? config.extraResources
: config.extraResources
? [config.extraResources]
: [];
config.extraResources = [
...baseExtra.filter((e) => {
if (typeof e === 'string') {
return !String(e).includes('bundled-vlc');
}
if (e && typeof e === 'object' && e.to === 'vlc') {
return false;
}
return true;
}),
{
from: 'tools/packaging/bundled-vlc/win64',
to: 'vlc',
filter: ['**/*'],
},
];
config.win = config.win ?? {};
config.win.target = [{ target: 'nsis', arch: ['x64'] }];
fs.writeFileSync(configPath, JSON.stringify(config, null, 4) + '\n');
const run = (cmd) => {
execSync(cmd, { stdio: 'inherit', cwd: workspaceRoot, env: process.env });
};
try {
const nx = path.join(workspaceRoot, 'node_modules', '.bin', 'nx');
const nxCmd = fs.existsSync(nx) ? `"${nx}"` : 'npx nx';
run(`${nxCmd} run electron-backend:build --configuration=kiratv`);
// On Windows, Nx defaults to the Windows platform. Do not pass --platform/--arch:
// Nx forwards them to dependency targets (web, remote-control-web) and breaks.
run(`${nxCmd} run electron-backend:make --configuration=kiratv`);
console.log(
'\nInstalador NSIS generado en dist/executables (kiratv-*-windows-x64-setup.exe) con VLC en resources/vlc.\n'
);
} finally {
fs.writeFileSync(configPath, original);
}
@@ -0,0 +1,513 @@
import fs from 'fs';
import path from 'path';
const args = process.argv.slice(2);
const normalizedArgs = args[0] === '--' ? args.slice(1) : args;
const [platform, arch = ''] = normalizedArgs;
if (!platform) {
console.error(
'Usage: node tools/packaging/verify-electron-package-layout.mjs <macos|linux|windows> [arch]'
);
process.exit(1);
}
const workspaceRoot = process.cwd();
const executablesRoot = path.join(workspaceRoot, 'dist', 'executables');
const packageJsonPath = path.join(workspaceRoot, 'package.json');
const electronBuilderConfigPath = path.join(
workspaceRoot,
'electron-builder.json'
);
const builderEffectiveConfigPath = path.join(
executablesRoot,
'builder-effective-config.yaml'
);
const flatpakMetainfoPath = path.join(
workspaceRoot,
'apps',
'electron-backend',
'linux',
'com.kiracloud.kiratv.metainfo.xml'
);
const packageMetadata = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
const electronBuilderConfig = JSON.parse(
fs.readFileSync(electronBuilderConfigPath, 'utf8')
);
const flatpakFinishArgs = electronBuilderConfig.flatpak?.finishArgs ?? [];
const snapConfigInspection = loadSnapConfigInspection();
const workerRelativeDir = path.join(
'dist',
'apps',
'electron-backend',
'workers'
);
const workerFiles = ['epg-parser.worker.js', 'database.worker.js'];
const nativeModuleRelativeDirs = [
path.join('app.asar.unpacked', 'node_modules'),
path.join('app.asar.unpacked', 'electron-backend', 'node_modules'),
path.join(
'app.asar.unpacked',
'dist',
'apps',
'electron-backend',
'node_modules'
),
];
const linuxExecutableName = getLinuxExecutableName();
function directoryExists(directoryPath) {
return (
fs.existsSync(directoryPath) && fs.statSync(directoryPath).isDirectory()
);
}
function fileExists(filePath) {
return fs.existsSync(filePath) && fs.statSync(filePath).isFile();
}
function getMacResourceDirs() {
const candidates = [
{
arch: 'x64',
directory: path.join(
executablesRoot,
'mac',
'KiraTV.app',
'Contents',
'Resources'
),
},
{
arch: 'arm64',
directory: path.join(
executablesRoot,
'mac-arm64',
'KiraTV.app',
'Contents',
'Resources'
),
},
];
return candidates
.filter((candidate) => !arch || candidate.arch === arch)
.map((candidate) => candidate.directory)
.filter(directoryExists);
}
function getUnpackedResourceDirs(prefix) {
if (!directoryExists(executablesRoot)) {
return [];
}
return fs
.readdirSync(executablesRoot, { withFileTypes: true })
.filter(
(entry) =>
entry.isDirectory() &&
entry.name.startsWith(prefix) &&
entry.name.endsWith('-unpacked')
)
.map((entry) => path.join(executablesRoot, entry.name, 'resources'))
.filter(directoryExists);
}
function getResourceDirs() {
switch (platform) {
case 'macos':
return getMacResourceDirs();
case 'linux':
return getUnpackedResourceDirs('linux');
case 'windows':
return getUnpackedResourceDirs('win');
default:
console.error(`Unsupported platform "${platform}"`);
process.exit(1);
}
}
function sanitizeExecutableName(value) {
return value.replace(/[<>:"/\\|?*\u0000-\u001f]/g, '');
}
function getLinuxExecutableName() {
const configuredExecutableName =
electronBuilderConfig.linux?.executableName ??
electronBuilderConfig.executableName;
if (configuredExecutableName) {
return sanitizeExecutableName(configuredExecutableName);
}
return packageMetadata.name.toLowerCase();
}
function parseYamlScalar(value) {
const normalizedValue = value.trim();
if (normalizedValue === 'true') {
return true;
}
if (normalizedValue === 'false') {
return false;
}
if (normalizedValue === 'null') {
return null;
}
const singleQuotedMatch = normalizedValue.match(/^'(.*)'$/);
if (singleQuotedMatch) {
return singleQuotedMatch[1].replace(/''/g, "'");
}
const doubleQuotedMatch = normalizedValue.match(/^"(.*)"$/);
if (doubleQuotedMatch) {
return doubleQuotedMatch[1].replace(/\\"/g, '"').replace(/\\\\/g, '\\');
}
return normalizedValue;
}
function getYamlSectionLines(yamlContent, sectionName) {
const lines = yamlContent.split(/\r?\n/);
const sectionHeader = `${sectionName}:`;
const sectionLines = [];
let isInsideSection = false;
for (const line of lines) {
if (!isInsideSection) {
if (line === sectionHeader) {
isInsideSection = true;
}
continue;
}
if (line.trim() === '') {
sectionLines.push(line);
continue;
}
const indentation = line.match(/^ */)?.[0].length ?? 0;
if (indentation === 0) {
break;
}
sectionLines.push(line);
}
return isInsideSection ? sectionLines : null;
}
function parseIndentedYamlSequence(lines, startIndex, indentation) {
const values = [];
let nextIndex = startIndex;
while (nextIndex < lines.length) {
const line = lines[nextIndex];
const trimmedLine = line.trim();
if (trimmedLine === '') {
nextIndex += 1;
continue;
}
const currentIndentation = line.match(/^ */)?.[0].length ?? 0;
if (currentIndentation < indentation) {
break;
}
if (currentIndentation > indentation || !trimmedLine.startsWith('- ')) {
break;
}
values.push(parseYamlScalar(trimmedLine.slice(2)));
nextIndex += 1;
}
return {
values,
nextIndex,
};
}
function parseIndentedYamlMapping(lines, startIndex, indentation) {
const values = {};
let nextIndex = startIndex;
while (nextIndex < lines.length) {
const line = lines[nextIndex];
const trimmedLine = line.trim();
if (trimmedLine === '') {
nextIndex += 1;
continue;
}
const currentIndentation = line.match(/^ */)?.[0].length ?? 0;
if (currentIndentation < indentation) {
break;
}
if (currentIndentation > indentation) {
break;
}
const separatorIndex = trimmedLine.indexOf(':');
if (separatorIndex === -1) {
break;
}
const key = trimmedLine.slice(0, separatorIndex).trim();
const rawValue = trimmedLine.slice(separatorIndex + 1).trim();
values[key] = parseYamlScalar(rawValue);
nextIndex += 1;
}
return {
values,
nextIndex,
};
}
function parseEffectiveSnapConfig(yamlContent) {
const sectionLines = getYamlSectionLines(yamlContent, 'snap');
if (!sectionLines) {
return null;
}
const snapConfig = {};
for (let index = 0; index < sectionLines.length; index += 1) {
const line = sectionLines[index];
const trimmedLine = line.trim();
if (trimmedLine === '') {
continue;
}
const indentation = line.match(/^ */)?.[0].length ?? 0;
if (indentation !== 2) {
continue;
}
const separatorIndex = trimmedLine.indexOf(':');
if (separatorIndex === -1) {
continue;
}
const key = trimmedLine.slice(0, separatorIndex).trim();
const rawValue = trimmedLine.slice(separatorIndex + 1).trim();
if (rawValue !== '') {
snapConfig[key] = parseYamlScalar(rawValue);
continue;
}
if (key === 'executableArgs') {
const { values, nextIndex } = parseIndentedYamlSequence(
sectionLines,
index + 1,
4
);
snapConfig[key] = values;
index = nextIndex - 1;
continue;
}
if (key === 'environment') {
const { values, nextIndex } = parseIndentedYamlMapping(
sectionLines,
index + 1,
4
);
snapConfig[key] = values;
index = nextIndex - 1;
}
}
return snapConfig;
}
function loadSnapConfigInspection() {
if (fileExists(builderEffectiveConfigPath)) {
const effectiveConfigContent = fs.readFileSync(
builderEffectiveConfigPath,
'utf8'
);
const effectiveSnapConfig = parseEffectiveSnapConfig(
effectiveConfigContent
);
if (effectiveSnapConfig) {
return {
config: effectiveSnapConfig,
sourcePath: builderEffectiveConfigPath,
};
}
}
return {
config: electronBuilderConfig.snap ?? {},
sourcePath: electronBuilderConfigPath,
};
}
function verifyLinuxLauncher(resourceDir, errors) {
const appDir = path.dirname(resourceDir);
const launcherPath = path.join(appDir, linuxExecutableName);
const launcherBinaryPath = `${launcherPath}.bin`;
if (!fileExists(launcherBinaryPath)) {
errors.push(
`Missing Linux launcher binary in ${appDir}: ${path.basename(launcherBinaryPath)}`
);
return;
}
if (!fileExists(launcherPath)) {
errors.push(
`Missing Linux launcher wrapper in ${appDir}: ${path.basename(launcherPath)}`
);
return;
}
const launcherScript = fs.readFileSync(launcherPath, 'utf8');
const requiredMarkers = [
'SCRIPT_PATH="${BASH_SOURCE[0]}"',
'readlink -f "$SCRIPT_PATH"',
`exec "$SCRIPT_DIR/${linuxExecutableName}.bin"`,
];
const missingMarkers = requiredMarkers.filter(
(marker) => !launcherScript.includes(marker)
);
if (missingMarkers.length > 0) {
errors.push(
[
`Linux launcher wrapper is missing symlink-safe logic in ${launcherPath}.`,
'Missing markers:',
...missingMarkers.map((marker) => `- ${marker}`),
].join('\n')
);
}
}
function verifyFlatpakPermissions(errors) {
if (!Array.isArray(flatpakFinishArgs)) {
errors.push('Flatpak finishArgs must be configured as an array.');
return;
}
if (!flatpakFinishArgs.includes('--talk-name=org.freedesktop.Flatpak')) {
errors.push(
'Flatpak finishArgs must include --talk-name=org.freedesktop.Flatpak for host player launching.'
);
}
}
function verifySnapPackagingConfig(errors) {
if (snapConfigInspection.config?.base !== 'core22') {
errors.push(
`Snap config in ${snapConfigInspection.sourcePath} must set base to core22 so packaged native modules stay compatible with the Snap runtime glibc.`
);
}
const snapExecutableArgs = Array.isArray(
snapConfigInspection.config?.executableArgs
)
? snapConfigInspection.config.executableArgs
: [];
if (!snapExecutableArgs.includes('--ozone-platform=x11')) {
errors.push(
`Snap config in ${snapConfigInspection.sourcePath} must include --ozone-platform=x11 in executableArgs.`
);
}
if (snapConfigInspection.config?.allowNativeWayland === true) {
errors.push(
`Snap config in ${snapConfigInspection.sourcePath} must not enable allowNativeWayland while the X11 startup workaround is required.`
);
}
}
function verifyResourceDir(resourceDir) {
const missingWorkers = workerFiles.filter(
(workerFile) =>
!fileExists(path.join(resourceDir, workerRelativeDir, workerFile))
);
const nativeModuleDirs = nativeModuleRelativeDirs.map((relativeDir) =>
path.join(resourceDir, relativeDir)
);
const matchingNativeModuleDir = nativeModuleDirs.find((nativeDir) =>
fileExists(path.join(nativeDir, 'better-sqlite3', 'package.json'))
);
const errors = [];
if (missingWorkers.length > 0) {
errors.push(
`Missing worker artifacts in ${resourceDir}: ${missingWorkers.join(', ')}`
);
}
if (!matchingNativeModuleDir) {
errors.push(
[
`Unable to find unpacked better-sqlite3 in ${resourceDir}.`,
'Checked:',
...nativeModuleDirs.map((nativeDir) => `- ${nativeDir}`),
].join('\n')
);
}
if (platform === 'linux') {
if (!fileExists(flatpakMetainfoPath)) {
errors.push(
`Missing Flatpak metainfo file: ${flatpakMetainfoPath}`
);
}
verifyFlatpakPermissions(errors);
verifySnapPackagingConfig(errors);
verifyLinuxLauncher(resourceDir, errors);
}
return {
resourceDir,
errors,
matchingNativeModuleDir,
};
}
const resourceDirs = getResourceDirs();
if (resourceDirs.length === 0) {
console.error(
`No packaged resource directories found for ${platform}${arch ? ` (${arch})` : ''}.`
);
process.exit(1);
}
const results = resourceDirs.map(verifyResourceDir);
const failures = results.filter((result) => result.errors.length > 0);
for (const result of results) {
console.log(`Verified packaged resources: ${result.resourceDir}`);
if (result.matchingNativeModuleDir) {
console.log(
`Resolved unpacked better-sqlite3 at: ${result.matchingNativeModuleDir}`
);
}
}
if (failures.length > 0) {
for (const failure of failures) {
console.error(failure.errors.join('\n'));
}
process.exit(1);
}
File diff suppressed because it is too large Load Diff
+759
View File
@@ -0,0 +1,759 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import process from 'node:process';
import sharp from 'sharp';
import {
listMarketingArtworkFixtures,
type MarketingArtworkFixture,
} from '../../apps/xtream-mock-server/src/app/generators/marketing.generator.js';
type ArtworkKind = 'backdrop' | 'poster';
type ArtworkQuality = 'low' | 'medium' | 'high' | 'auto';
type RunMode = 'dry-run' | 'generate' | 'manifest' | 'validate';
type ArtworkJob = {
contentType: 'movie' | 'series';
filename: string;
kind: ArtworkKind;
outputPath: string;
prompt: string;
quality: ArtworkQuality;
size: string;
slug: string;
title: string;
};
type ArtworkManifest = {
assetRoot: string;
assets: Array<{
contentType: 'movie' | 'series';
filename: string;
kind: ArtworkKind;
manualApprovalStatus: 'pending-human-review';
model: string;
prompt: string;
quality: ArtworkQuality;
size: string;
slug: string;
title: string;
}>;
model: string;
outputFormat: 'png';
version: 1;
};
const workspaceRoot = process.cwd();
const assetRoot = path.join(
workspaceRoot,
'apps/xtream-mock-server/public/marketing'
);
const manifestPath = path.join(assetRoot, 'manifest.json');
const relativeAssetRoot = 'apps/xtream-mock-server/public/marketing';
const defaultModel = 'gpt-image-2';
const outputFormat = 'png';
const args = process.argv.slice(2);
const mode = getMode(args);
const model = getStringArg('--model') ?? process.env['OPENAI_IMAGE_MODEL'] ?? defaultModel;
const quality = getQualityArg('--quality') ?? getQuality(process.env['OPENAI_IMAGE_QUALITY']) ?? 'medium';
const force = args.includes('--force');
const limit = getNumberArg('--limit');
const delayMs = getNumberArg('--delay-ms') ?? 0;
const allJobs = buildArtworkJobs(model, quality);
const jobs = allJobs.slice(0, limit ?? undefined);
async function main(): Promise<void> {
if (mode === 'dry-run') {
printDryRun();
return;
}
ensureOutputDirs();
writeManifest(model, allJobs);
if (mode === 'manifest') {
console.log(`Wrote artwork manifest: ${relativePath(manifestPath)}`);
return;
}
if (mode === 'generate') {
await generateMissingArtwork();
}
await validateArtwork(jobs);
}
function buildArtworkJobs(
selectedModel: string,
selectedQuality: ArtworkQuality
): ArtworkJob[] {
return listMarketingArtworkFixtures().flatMap((fixture) =>
(['poster', 'backdrop'] as const).map((kind) => {
const filename = `${kind}/${fixture.slug}.png`;
return {
contentType: fixture.contentType,
filename,
kind,
outputPath: path.join(assetRoot, filename),
prompt: buildPrompt(fixture, kind),
quality: selectedQuality,
size: kind === 'poster' ? '1024x1536' : '1536x864',
slug: fixture.slug,
title: fixture.name,
};
})
);
}
function buildPrompt(fixture: MarketingArtworkFixture, kind: ArtworkKind): string {
const profile = promptProfileForTitle(fixture.name);
const direction = visualDirectionForTitle(fixture.name);
const shared = [
`Create original fictional demo artwork for IPTVnator release screenshots.`,
`Fictional ${fixture.contentType}: "${fixture.name}" (${fixture.year}).`,
`Genre: ${fixture.genre}.`,
`Story seed: ${fixture.tagline} ${fixture.description}`,
`Visual direction: ${direction}`,
`Visual medium and style: ${profile.style}`,
`Palette and texture: ${profile.palette}`,
[
'Safety and originality constraints:',
'Do not depict or reference any real movie, series, franchise, celebrity, actor likeness, branded character, copyrighted logo, trademarked costume, existing poster composition, or readable brand mark.',
'No watermarks, no studio logos, no credits, no title typography, and no legible text.',
].join(' '),
];
if (kind === 'poster') {
return [
'Use case: ads-marketing',
'Asset type: portrait cover/poster PNG for a fictional streaming demo catalog.',
...shared,
`Composition: portrait 2:3 ${profile.posterComposition}; high contrast at thumbnail size; title-safe image with no necessary text because the app UI renders metadata separately.`,
].join('\n');
}
return [
'Use case: ads-marketing',
'Asset type: landscape backdrop PNG for a fictional streaming demo details screen.',
...shared,
`Composition: wide 16:9 ${profile.backdropComposition}; darker edges and open negative space so app UI overlays remain readable; no text or logos.`,
].join('\n');
}
type PromptProfile = {
backdropComposition: string;
palette: string;
posterComposition: string;
style: string;
};
function promptProfileForTitle(title: string): PromptProfile {
const profiles: Record<string, PromptProfile> = {
'Crimson Skylark': {
style: 'golden-hour aerial adventure photography with realistic rescue-drama production design, wind, clouds, practical flight gear, and natural lens flare',
palette:
'sunrise coral, storm gray, aviation green, grainy but realistic atmospheric haze',
posterComposition:
'single rescue courier against a huge storm sky, diagonal flight path, naturalistic poster photography',
backdropComposition:
'vast sky route above a modern city, layered storm clouds, small courier silhouette, breathable horizon',
},
'The Voltage Guard': {
style: 'gritty disaster-thriller photography, rain-soaked infrastructure, documentary realism, sparks, emergency work lights, no fantasy costumes',
palette:
'steel blue, sodium orange, wet asphalt black, white electrical flares',
posterComposition:
'workers and rail geometry forming a tense action triangle, practical uniforms and tools',
backdropComposition:
'wide elevated transit grid under unstable weather, workers small in frame, strong industrial depth',
},
'Midnight Mantle': {
style: 'gothic noir illustration with theatrical shadow shapes, old print texture, rain, velvet blacks, and painterly mystery atmosphere',
palette:
'deep violet, amber marquee light, wet black streets, muted crimson accents',
posterComposition:
'theatre silhouette and sweeping cloak as an abstract mystery shape, not a superhero pose',
backdropComposition:
'empty theatre district at night, marquee glow without readable letters, cloak-like shadow crossing rooftops',
},
'Quantum Comet': {
style: 'hard science-fiction concept art with crisp spacecraft detail, star charts, comet ice, scale diagrams, and luminous navigation UI shapes without readable text',
palette:
'icy cyan, white comet dust, deep navy, precise magenta navigation glows',
posterComposition:
'cartographer figure and fractured comet arc arranged like a scientific expedition poster',
backdropComposition:
'wide comet trail and distant armada silhouettes, clean negative space around the center',
},
'Orbit of Atlas': {
style: 'epic cosmic survival painting with mineral surfaces, colony lights, gravitational dust ribbons, and solemn scale',
palette:
'burnt gold, moon gray, dusty umber, cold blue space shadows',
posterComposition:
'small rescue pilot dwarfed by a cracked moon, monumental survival mood',
backdropComposition:
'fractured moon over a remote colony, dust and sunlight bending in wide arcs',
},
'Nova Relay': {
style: 'sleek space-opera animation art with clean silhouettes, radiant motion trails, glossy star-bridge architecture, and optimistic color',
palette:
'electric teal, royal blue, white light trails, warm planetary glow',
posterComposition:
'twin runners crossing a luminous bridge with strong graphic diagonals',
backdropComposition:
'star bridge leading toward a blue planet, two tiny figures and sweeping light messages',
},
'Beacon Boy and the Clockwork Garden': {
style: 'storybook 3D animated fantasy, handmade brass mechanisms, moss, warm lantern light, rounded forms, and tactile toy-like detail',
palette:
'moss green, brass gold, warm amber, soft twilight blue',
posterComposition:
'young inventor, grandmother, and clockwork garden guardian in a warm discovery tableau',
backdropComposition:
'hidden underground garden with gears, vines, lanterns, and gentle mechanical guardians',
},
'The Invisible Saturday': {
style: 'bright claymation-inspired family comedy, miniature fairground sets, visible handmade textures, playful invisible-person gags',
palette:
'cotton-candy pink, sunny yellow, grass green, soft sky blue',
posterComposition:
'floating ribbons, footprints, and balloons implying invisible siblings at a fair',
backdropComposition:
'wide neighborhood fair scene with playful impossible traces and generous open sky',
},
'Captain Kindling': {
style: 'warm hand-painted camping comedy illustration, cozy forest dusk, ember glow, imperfect brush texture, gentle humor',
palette:
'campfire orange, pine green, twilight purple, warm tan canvas',
posterComposition:
'camp counselor spark and misfit camper silhouettes around a glowing clearing',
backdropComposition:
'wide forest camp at dusk with cabin lights, ember trails, and friendly wilderness depth',
},
'Masks of Tomorrow': {
style: 'fashion-documentary editorial photography, fabric tables, workshop tools, mirrors, fittings, and soft daylight realism',
palette:
'cream fabric, graphite tools, tomato red thread, daylight blue shadows',
posterComposition:
'hands, mannequins, and unfinished masks arranged as an editorial magazine cover image',
backdropComposition:
'busy workshop with textiles and signal equipment, human craft details, no logo marks',
},
'Signal Tower Seven': {
style: 'retro 1970s disaster-drama poster art, analog radio equipment, snowy mountain rescue maps, screen-print texture',
palette:
'faded mustard, radio-room brown, emergency red, winter blue',
posterComposition:
'radio operator profile, signal tower, and storm map shapes in vintage rescue-drama layout',
backdropComposition:
'wide analog signal room overlooking winter mountains and storm lights',
},
'The League of Lanterns': {
style: 'human-interest documentary photography with natural faces, handheld lanterns, dark streets, and hopeful realism',
palette:
'blackout navy, lantern gold, brick red, soft skin tones',
posterComposition:
'volunteers carrying lanterns through darkness, intimate eye-level composition',
backdropComposition:
'wide city block during blackout with lantern paths and community silhouettes',
},
'Paper Moon Parade': {
style: 'simple flat paper-cut illustration for children, large clean shapes, visible paper grain, gentle handmade collage, no realistic rendering',
palette:
'midnight blue, butter yellow, pale lavender, soft coral, warm paper white',
posterComposition:
'big smiling paper moon, tiny town silhouettes, parade shapes, lots of clean negative space',
backdropComposition:
'wide paper-cut town street under a rolling moon with simple parade banners and sparse stars',
},
'Dot and the Cloud Train': {
style: 'ultra-simple preschool cartoon, rounded vector shapes, soft gradients, thick friendly outlines, very low detail',
palette:
'sky blue, marshmallow white, lemon yellow, strawberry pink, mint green',
posterComposition:
'small dot character stepping onto a rounded cloud train, clear simple shapes for young kids',
backdropComposition:
'wide pastel sky with a cloud train crossing simple weather-color stops',
},
'Tiny Planet Picnic': {
style: 'minimal flat family sci-fi cartoon, clean vector-like shapes, playful scale, sparse details, soft geometric planets',
palette:
'peach, teal, lilac, sunny yellow, deep space blue used sparingly',
posterComposition:
'three children on a tiny round planet with picnic blanket and orbiting flowers',
backdropComposition:
'wide tiny planet landscape with flowers orbiting like satellites and open simple sky',
},
'The Quiet Aquarium': {
style: 'abstract meditative poster art, flat translucent shapes, slow gradients, simplified fish silhouettes, calm museum-like composition',
palette:
'deep sea blue, glass cyan, muted violet, soft black, small coral accents',
posterComposition:
'large abstract aquarium window with drifting shapes and one quiet visitor silhouette',
backdropComposition:
'wide closed aquarium interior with glowing tanks, drifting abstract fish shapes, and quiet negative space',
},
'Square City Sonata': {
style: 'Bauhaus-inspired abstract musical city art, flat geometric blocks, no perspective realism, rhythmic rectangles and circles',
palette:
'cream, brick red, cobalt blue, mustard yellow, charcoal',
posterComposition:
'stacked city blocks, windows, and circles arranged like a musical score',
backdropComposition:
'wide geometric city grid with crosswalk rhythms and streetlight circles across a flat afternoon',
},
'Lanterns Under Snow': {
style: 'minimal winter folk illustration, flat shapes, quiet negative space, soft paper texture, restrained detail',
palette:
'snow white, ink navy, lantern amber, muted pine green, pale gray',
posterComposition:
'single warm lantern path climbing a snowy hill town with small simple figures',
backdropComposition:
'wide snowy hillside town with sparse houses, paper lantern dots, and a calm empty sky',
},
'Skyline Sentinels': {
style: 'urban infrastructure drama photography, maintenance crew realism, rooftops, rails, bridge cables, sunrise work shift mood',
palette:
'concrete gray, safety yellow, sunrise peach, river blue',
posterComposition:
'young maintenance crew on a roof with tools and city geometry, not heroic costumes',
backdropComposition:
'wide rooftop and bridge panorama with work lights and morning haze',
},
'The Aegis Club': {
style: 'warm workplace dramedy photography, neighborhood social club interiors, old wood, mismatched chairs, trophies, candid ensemble energy',
palette:
'walnut brown, faded green upholstery, brass lamps, soft cream walls',
posterComposition:
'group around an old club table with lived-in props and gentle humor',
backdropComposition:
'wide social club room with training area, memorabilia, and warm lamps',
},
'Starfall Academy': {
style: 'colorful anime-inspired space-school illustration with expressive cadets, school-ship corridors, science experiments, and star windows',
palette:
'bubblegum pink, starship white, asteroid gray, neon mint',
posterComposition:
'cadets and floating school equipment in dynamic anime school-club composition',
backdropComposition:
'wide school-ship classroom near asteroid belt with desks, windows, and drifting experiment lights',
},
'Tomorrow Bureau': {
style: 'minimalist bureaucratic sci-fi thriller, glass offices, files, clocks, reflection grids, restrained corporate unease',
palette:
'frosted glass, desaturated teal, paper white, warning red pinpoints',
posterComposition:
'analyst silhouette amid files and clocks, clean tense graphic composition',
backdropComposition:
'wide operations room with glass walls, layered clock reflections, and negative space',
},
'Alley Mask': {
style: 'neo-noir street photography, bicycle courier realism, coded light marks, wet alleys, handheld camera energy',
palette:
'wet asphalt, sodium yellow, deep green shadows, chalk-white signal marks',
posterComposition:
'bike courier and alley geometry, face partly obscured by ordinary rain gear, grounded mystery',
backdropComposition:
'wide narrow alley after rain with coded signal glyphs and long perspective',
},
'Neon Sparrow': {
style: 'stylized cyberpunk graphic novel art, rooftop rain, neon clue trails, birdlike light motifs, bold ink shapes',
palette:
'magenta neon, cyan rain, black ink, pale green glow',
posterComposition:
'rooftop messenger and sparrow-like light trail in bold graphic silhouette',
backdropComposition:
'wide rain-soaked rooftop cityscape with glowing clues and open sky',
},
'Cape Makers': {
style: 'behind-the-scenes craft docuseries photography, sewing machines, engineering benches, stage fabric, candid hands-at-work details',
palette:
'indigo cloth, warm workshop bulbs, steel tools, chalk marks',
posterComposition:
'tailors and engineers at work around stage gear, tactile documentary composition',
backdropComposition:
'wide busy design studio with fabric rolls, benches, sketches without readable text',
},
'Citizens of the Beacon': {
style: 'quiet social documentary photography, ordinary residents, practical signal lamps, neighborhood streets, compassionate realism',
palette:
'lamp amber, dusk blue, concrete gray, muted household colors',
posterComposition:
'ordinary residents around a beacon lamp, intimate human-interest portrait',
backdropComposition:
'wide neighborhood rescue network at dusk with lamps in windows and street corners',
},
'Bubble Street Workshop': {
style: 'flat kids craft cartoon, simple circles and paper shapes, playful handmade textures, bright but not busy',
palette:
'bubblegum pink, craft paper tan, sky blue, crayon green, sunflower yellow',
posterComposition:
'round neighbors building a simple invention from paper, string, and circles',
backdropComposition:
'wide cheerful street workshop with tables, bubbles, paper circles, and open simple storefront shapes',
},
'Flatland Detectives': {
style: 'flat geometric mystery comedy, shape-language characters, simple shadows, crisp poster design, no realism',
palette:
'warm gray, detective orange, navy, cream, clue green',
posterComposition:
'two geometric detectives inspecting a simple shape clue with playful noir lighting',
backdropComposition:
'wide flat neighborhood map scene with shadows, arrows, and geometric clues',
},
'Color Orchard': {
style: 'preschool nature cartoon, very simple flat rounded shapes, soft friendly faces implied by objects, low detail',
palette:
'apple red, leaf green, pear yellow, sky blue, soft soil brown',
posterComposition:
'tiny caretakers under one large color-changing tree, simple fruit shapes and musical notes without text',
backdropComposition:
'wide gentle orchard made of rounded flat trees changing colors across the morning',
},
'Museum of Little Robots': {
style: 'flat retro-future cartoon, simple rounded robots, clean museum shapes, limited details, soft mid-century geometry',
palette:
'dusty teal, warm cream, tomato red, brushed metal gray, muted orange',
posterComposition:
'small helpful robots arranging one oversized exhibit in a quiet museum hall',
backdropComposition:
'wide retro-future museum after closing with tiny robots, plinths, and simple geometric exhibits',
},
};
return (
profiles[title] ?? {
backdropComposition:
'immersive genre scene with clean depth and readable negative space',
palette: 'balanced multi-color palette with tactile texture',
posterComposition:
'distinctive key image built around an original fictional subject',
style: 'original genre artwork with a clearly differentiated visual language',
}
);
}
function visualDirectionForTitle(title: string): string {
const directions: Record<string, string> = {
'Crimson Skylark':
'an aerial rescue courier navigating storm routes above Meridian City, a solar signal crest glowing in the cloudbreak',
'The Voltage Guard':
'transit workers protecting an elevated rail grid from a living thunderstorm, blue-white electricity reflected in wet streets',
'Midnight Mantle':
'an old theatre district at night, a mysterious cloak rumor visualized through shadows, marquee light, and rain-soaked cobblestones',
'Quantum Comet':
'an orbital cartographer crossing fractured comet trails beside a time-bent armada, star maps glowing in cold blue light',
'Orbit of Atlas':
'a rescue pilot navigating a cracked moon orbit over a fragile colony, gravity ribbons bending dust and sunlight',
'Nova Relay':
'twin signal runners racing across a luminous star bridge, urgent light messages streaking toward a blue planet',
'Beacon Boy and the Clockwork Garden':
'a young inventor and grandmother discovering mechanical garden guardians under a city park, brass leaves and gentle lantern light',
'The Invisible Saturday':
'two adventurous siblings at a neighborhood fair where only their footprints, floating ribbons, and playful shadows reveal them',
'Captain Kindling':
'a forest camp at dusk with ember-bright protective magic, young misfit campers gathered around a warm heroic spark',
'Masks of Tomorrow':
'costume makers, signal engineers, and citizens preparing original hero emblems in a workshop filled with fabric, tools, and soft window light',
'Signal Tower Seven':
'retro radio operators coordinating rescues from a tall signal room, analog dials, maps, and amber emergency light',
'The League of Lanterns':
'volunteers carrying lanterns through an impossible blackout, safe glowing paths forming across city streets',
'Paper Moon Parade':
'a flat paper-cut moon rolling through a tiny town with simple parade shapes and shy children joining in',
'Dot and the Cloud Train':
'a small dot boarding a soft cloud train that stops at simple weather-color stations',
'Tiny Planet Picnic':
'children preparing a picnic on a pocket-sized planet where flowers orbit like friendly satellites',
'The Quiet Aquarium':
'abstract glowing aquarium tanks after closing time, drifting shapes, quiet visitors, and calm water-light',
'Square City Sonata':
'geometric blocks, windows, crosswalks, and streetlights arranged as a flat city symphony',
'Lanterns Under Snow':
'neighbors carrying warm paper lanterns across a silent snowy hill town',
'Skyline Sentinels':
'a young rooftop rescue team watching rails, bridges, and river lights from high above Meridian City at sunrise',
'The Aegis Club':
'retired heroes in a restored neighborhood social club, worn memorabilia, warm lamps, and a new generation training nearby',
'Starfall Academy':
'cadets with unstable cosmic powers aboard a school ship near the asteroid belt, classroom windows filled with stars',
'Tomorrow Bureau':
'future analysts in a glass-walled operations room sorting one-day-ahead disaster files, clocks and reflections layered across the scene',
'Alley Mask':
'a bike courier stepping through deep city shadows while following strange signal glyphs in narrow alleys after rain',
'Neon Sparrow':
'a rooftop messenger following glowing clues that appear in midnight rain, neon reflections and small bright wing motifs',
'Cape Makers':
'tailors, engineers, and artists building original stage gear in a busy design studio, fabric tests and hand-built emblems everywhere',
'Citizens of the Beacon':
'ordinary residents maintaining a citywide rescue network after the first beacon, hopeful faces lit by practical signal lamps',
'Bubble Street Workshop':
'neighbors building simple inventions from circles, paper, string, and bright workshop tables',
'Flatland Detectives':
'two geometric detectives solving gentle neighborhood mysteries using maps, shadows, and shape clues',
'Color Orchard':
'a preschool orchard changing color each morning while tiny caretakers learn seasons and sound',
'Museum of Little Robots':
'small helpful robots arranging exhibits after closing time in a quiet retro-future museum',
};
return directions[title] ?? 'an original cinematic genre scene for a fictional streaming title';
}
async function generateMissingArtwork(): Promise<void> {
const apiKey = process.env['OPENAI_API_KEY'];
if (!apiKey) {
throw new Error('OPENAI_API_KEY is required for --generate.');
}
for (const [index, job] of jobs.entries()) {
if (existsSync(job.outputPath) && !force) {
console.log(
`[${index + 1}/${jobs.length}] exists, skipping ${job.filename}`
);
continue;
}
console.log(
`[${index + 1}/${jobs.length}] generating ${job.filename} (${job.size}, ${job.quality})`
);
const imageBytes = await requestImage(apiKey, job);
writeFileSync(job.outputPath, imageBytes);
if (delayMs > 0 && index < jobs.length - 1) {
await sleep(delayMs);
}
}
}
async function requestImage(apiKey: string, job: ArtworkJob): Promise<Buffer> {
const payload = {
model,
prompt: job.prompt,
quality: job.quality,
size: job.size,
output_format: outputFormat,
moderation: 'auto',
};
for (let attempt = 1; attempt <= 4; attempt += 1) {
const response = await fetch('https://api.openai.com/v1/images/generations', {
body: JSON.stringify(payload),
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
method: 'POST',
});
const body = await response.json().catch(() => undefined);
if (response.ok) {
const imageBase64 = readImageBase64(body);
return Buffer.from(imageBase64, 'base64');
}
const message = readErrorMessage(body) ?? `${response.status} ${response.statusText}`;
if ((response.status === 429 || response.status >= 500) && attempt < 4) {
const retryAfter = Number(response.headers.get('retry-after'));
const waitMs = Number.isFinite(retryAfter)
? retryAfter * 1000
: attempt * 15_000;
console.warn(
`OpenAI image request failed for ${job.filename}: ${message}. Retrying in ${Math.round(
waitMs / 1000
)}s.`
);
await sleep(waitMs);
continue;
}
throw new Error(
`OpenAI image request failed for ${job.filename}: ${message}`
);
}
throw new Error(`OpenAI image request failed for ${job.filename}.`);
}
function readImageBase64(body: unknown): string {
if (
typeof body === 'object' &&
body !== null &&
'data' in body &&
Array.isArray((body as { data: unknown }).data)
) {
const first = (body as { data: Array<{ b64_json?: unknown }> }).data[0];
if (typeof first?.b64_json === 'string' && first.b64_json.length > 0) {
return first.b64_json;
}
}
throw new Error('OpenAI response did not include data[0].b64_json.');
}
function readErrorMessage(body: unknown): string | undefined {
if (
typeof body === 'object' &&
body !== null &&
'error' in body &&
typeof (body as { error?: { message?: unknown } }).error?.message === 'string'
) {
return (body as { error: { message: string } }).error.message;
}
return undefined;
}
async function validateArtwork(selectedJobs: ArtworkJob[]): Promise<void> {
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as ArtworkManifest;
const expectedManifestCount = selectedJobs.length;
if (manifest.version !== 1 || manifest.assets.length < expectedManifestCount) {
throw new Error(
`Manifest has ${manifest.assets.length} assets, expected at least ${expectedManifestCount}.`
);
}
for (const job of selectedJobs) {
if (!existsSync(job.outputPath)) {
throw new Error(`Missing generated artwork: ${relativePath(job.outputPath)}`);
}
const metadata = await sharp(job.outputPath).metadata();
const [expectedWidth, expectedHeight] = job.size.split('x').map(Number);
if (metadata.format !== 'png') {
throw new Error(`Artwork is not PNG: ${relativePath(job.outputPath)}`);
}
if (metadata.width !== expectedWidth || metadata.height !== expectedHeight) {
throw new Error(
`Artwork has invalid dimensions: ${relativePath(job.outputPath)} ` +
`expected ${job.size}, got ${metadata.width}x${metadata.height}`
);
}
}
console.log(
`Validated ${selectedJobs.length} artwork assets and manifest: ${relativePath(
manifestPath
)}`
);
}
function writeManifest(selectedModel: string, selectedJobs: ArtworkJob[]): void {
const manifest: ArtworkManifest = {
assetRoot: relativeAssetRoot,
assets: selectedJobs.map((job) => ({
contentType: job.contentType,
filename: job.filename,
kind: job.kind,
manualApprovalStatus: 'pending-human-review',
model: selectedModel,
prompt: job.prompt,
quality: job.quality,
size: job.size,
slug: job.slug,
title: job.title,
})),
model: selectedModel,
outputFormat,
version: 1,
};
writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
}
function printDryRun(): void {
const manifest = {
assetRoot: relativeAssetRoot,
assets: jobs.map((job) => ({
filename: job.filename,
kind: job.kind,
model,
quality: job.quality,
size: job.size,
title: job.title,
})),
count: jobs.length,
outputFormat,
};
console.log(JSON.stringify(manifest, null, 2));
}
function ensureOutputDirs(): void {
mkdirSync(path.join(assetRoot, 'poster'), { recursive: true });
mkdirSync(path.join(assetRoot, 'backdrop'), { recursive: true });
}
function getMode(argv: string[]): RunMode {
const selectedModes = [
argv.includes('--dry-run') ? 'dry-run' : undefined,
argv.includes('--generate') ? 'generate' : undefined,
argv.includes('--manifest') ? 'manifest' : undefined,
argv.includes('--validate') ? 'validate' : undefined,
].filter(Boolean) as RunMode[];
if (selectedModes.length > 1) {
throw new Error('Choose only one mode: --dry-run, --manifest, --generate, or --validate.');
}
return selectedModes[0] ?? 'dry-run';
}
function getStringArg(name: string): string | undefined {
const index = args.indexOf(name);
return index >= 0 ? args[index + 1] : undefined;
}
function getNumberArg(name: string): number | undefined {
const raw = getStringArg(name);
if (raw === undefined) {
return undefined;
}
const value = Number(raw);
if (!Number.isFinite(value) || value < 0) {
throw new Error(`${name} must be a non-negative number.`);
}
return value;
}
function getQualityArg(name: string): ArtworkQuality | undefined {
return getQuality(getStringArg(name));
}
function getQuality(raw: string | undefined): ArtworkQuality | undefined {
if (
raw === 'low' ||
raw === 'medium' ||
raw === 'high' ||
raw === 'auto'
) {
return raw;
}
if (raw === undefined) {
return undefined;
}
throw new Error('Quality must be one of: low, medium, high, auto.');
}
function relativePath(filePath: string): string {
return path.relative(workspaceRoot, filePath);
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
main().catch((error: unknown) => {
console.error(error instanceof Error ? error.message : error);
process.exit(1);
});
+116
View File
@@ -0,0 +1,116 @@
import fs from 'fs';
import path from 'path';
import { spawn } from 'child_process';
const args = process.argv.slice(2);
const normalizedArgs = args[0] === '--' ? args.slice(1) : args;
const [platformArg, arch = ''] = normalizedArgs;
const currentPlatform =
platformArg ??
(process.platform === 'darwin'
? 'macos'
: process.platform === 'win32'
? 'windows'
: 'linux');
const workspaceRoot = process.cwd();
const executablesRoot = path.join(workspaceRoot, 'dist', 'executables');
const remoteDebuggingPort = '9222';
function ensureFile(filePath) {
return fs.existsSync(filePath) && fs.statSync(filePath).isFile();
}
function findUnpackedExecutable(prefix, executableNames) {
if (!fs.existsSync(executablesRoot)) {
return undefined;
}
const unpackedDirs = fs
.readdirSync(executablesRoot, { withFileTypes: true })
.filter(
(entry) =>
entry.isDirectory() &&
entry.name.startsWith(prefix) &&
entry.name.endsWith('-unpacked')
)
.map((entry) => path.join(executablesRoot, entry.name));
for (const unpackedDir of unpackedDirs) {
for (const executableName of executableNames) {
const executablePath = path.join(unpackedDir, executableName);
if (ensureFile(executablePath)) {
return executablePath;
}
}
}
return undefined;
}
function resolvePackagedExecutable() {
if (currentPlatform === 'macos') {
const candidates = [
{
arch: 'x64',
executable: path.join(
executablesRoot,
'mac',
'IPTVnator.app',
'Contents',
'MacOS',
'IPTVnator'
),
},
{
arch: 'arm64',
executable: path.join(
executablesRoot,
'mac-arm64',
'IPTVnator.app',
'Contents',
'MacOS',
'IPTVnator'
),
},
];
const match = candidates.find(
(candidate) => (!arch || candidate.arch === arch) && ensureFile(candidate.executable)
);
return match?.executable;
}
if (currentPlatform === 'windows') {
return findUnpackedExecutable('win', ['IPTVnator.exe']);
}
if (currentPlatform === 'linux') {
return findUnpackedExecutable('linux', ['IPTVnator', 'iptvnator']);
}
return undefined;
}
const executable = resolvePackagedExecutable();
if (!executable) {
console.error(
`Unable to resolve a packaged executable for ${currentPlatform}${arch ? ` (${arch})` : ''}.`
);
process.exit(1);
}
console.log(`Launching packaged app: ${executable}`);
console.log(
`After the window opens, connect with: agent-browser --cdp ${remoteDebuggingPort} tab list`
);
const child = spawn(executable, [`--remote-debugging-port=${remoteDebuggingPort}`], {
stdio: 'inherit',
detached: false,
});
child.on('exit', (code) => {
process.exit(code ?? 0);
});
+65
View File
@@ -0,0 +1,65 @@
#!/usr/bin/env node
import { readdirSync } from 'node:fs';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
import process from 'node:process';
const [targetDir, ...jestArgs] = process.argv.slice(2);
if (!targetDir) {
console.error(
'Usage: node tools/testing/run-web-esm-lib-tests.mjs <directory> [jest args...]'
);
process.exit(1);
}
const workspaceRoot = process.cwd();
const absoluteTargetDir = path.resolve(workspaceRoot, targetDir);
function collectSpecFiles(directory) {
const entries = readdirSync(directory, { withFileTypes: true });
const specFiles = [];
for (const entry of entries) {
const fullPath = path.join(directory, entry.name);
if (entry.isDirectory()) {
specFiles.push(...collectSpecFiles(fullPath));
continue;
}
if (
entry.isFile() &&
(entry.name.endsWith('.spec.ts') || entry.name.endsWith('.test.ts'))
) {
specFiles.push(fullPath);
}
}
return specFiles;
}
const specFiles = collectSpecFiles(absoluteTargetDir).sort();
if (specFiles.length === 0) {
console.error(`No spec files found under ${targetDir}`);
process.exit(1);
}
const jestBin = path.resolve(workspaceRoot, 'node_modules/jest/bin/jest.js');
const jestConfig = path.resolve(
workspaceRoot,
'jest.web-esm.workspace.ts'
);
const result = spawnSync(
process.execPath,
[jestBin, '--config', jestConfig, '--runTestsByPath', ...specFiles, ...jestArgs],
{
env: process.env,
stdio: 'inherit',
}
);
process.exit(result.status ?? 1);