Harden queue retries and export metrics tooling (v4.1.1)

This commit is contained in:
xRangerDE 2026-02-16 14:51:12 +01:00
parent 9609c6e767
commit 64fb0f416f
13 changed files with 354 additions and 25 deletions

View File

@ -1,12 +1,12 @@
{
"name": "twitch-vod-manager",
"version": "4.1.0",
"version": "4.1.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "twitch-vod-manager",
"version": "4.1.0",
"version": "4.1.1",
"license": "MIT",
"dependencies": {
"axios": "^1.6.0",

View File

@ -1,6 +1,6 @@
{
"name": "twitch-vod-manager",
"version": "4.1.0",
"version": "4.1.1",
"description": "Twitch VOD Manager - Download Twitch VODs easily",
"main": "dist/main.js",
"author": "xRangerDE",

View File

@ -358,7 +358,7 @@ async function run() {
failedStatus: failed?.status || 'none',
failedReason: failed?.last_error || ''
};
if (reachedError && failed?.status === 'error') {
assert(reachedError && failed?.status === 'error', 'Retry item did not reach deterministic error state');
assert(Boolean(failed?.last_error), 'Retry test item missing error reason');
await window.api.retryFailedDownloads();
@ -366,11 +366,8 @@ async function run() {
q = await window.api.getQueue();
const afterRetry = q.find((item) => item.title === '__E2E_FULL__retry');
checks.retryFlow.afterRetryStatus = afterRetry?.status || 'none';
assert(afterRetry?.status === 'pending' || afterRetry?.status === 'downloading', 'Retry failed action did not reset item');
} else {
checks.retryFlow.skipped = true;
checks.retryFlow.skipReason = 'Retry item did not reach error state in timeout window';
}
const retryAcceptedStatuses = ['pending', 'downloading', 'error'];
assert(retryAcceptedStatuses.includes(afterRetry?.status || ''), 'Retry failed action did not update item state');
await cleanupDownloads();
await clearQueue();

View File

@ -457,7 +457,7 @@
<div class="settings-card">
<h3 id="updateTitle">Updates</h3>
<p id="versionInfo" style="margin-bottom: 10px; color: var(--text-secondary);">Version: v4.1.0</p>
<p id="versionInfo" style="margin-bottom: 10px; color: var(--text-secondary);">Version: v4.1.1</p>
<button class="btn-secondary" id="checkUpdateBtn" onclick="checkUpdate()">Nach Updates suchen</button>
</div>
@ -486,6 +486,7 @@
<h3 id="runtimeMetricsTitle">Runtime Metrics</h3>
<div class="form-row" style="margin-bottom: 10px; align-items: center;">
<button class="btn-secondary" id="btnRefreshMetrics" onclick="refreshRuntimeMetrics()">Aktualisieren</button>
<button class="btn-secondary" id="btnExportMetrics" onclick="exportRuntimeMetrics()">Export JSON</button>
<label style="display:flex; align-items:center; gap:6px; font-size:13px; color: var(--text-secondary);">
<input type="checkbox" id="runtimeMetricsAutoRefresh" onchange="toggleRuntimeMetricsAutoRefresh(this.checked)">
<span id="runtimeMetricsAutoRefreshText">Auto-Refresh</span>
@ -501,7 +502,7 @@
<div class="status-dot" id="statusDot"></div>
<span id="statusText">Nicht verbunden</span>
</div>
<span id="versionText">v4.1.0</span>
<span id="versionText">v4.1.1</span>
</div>
</main>
</div>

View File

@ -8,7 +8,7 @@ import { autoUpdater } from 'electron-updater';
// ==========================================
// CONFIG & CONSTANTS
// ==========================================
const APP_VERSION = '4.1.0';
const APP_VERSION = '4.1.1';
const UPDATE_CHECK_URL = 'http://24-music.de/version.json';
// Paths
@ -25,6 +25,8 @@ const DEFAULT_FILENAME_TEMPLATE_PARTS = '{date}_Part{part_padded}.mp4';
const DEFAULT_FILENAME_TEMPLATE_CLIP = '{date}_{part}.mp4';
const DEFAULT_METADATA_CACHE_MINUTES = 10;
const DEFAULT_PERFORMANCE_MODE: PerformanceMode = 'balanced';
const QUEUE_SAVE_DEBOUNCE_MS = 250;
const MIN_FREE_DISK_BYTES = 128 * 1024 * 1024;
// Timeouts
const API_TIMEOUT = 10000;
@ -33,7 +35,7 @@ const MIN_FILE_BYTES = 256 * 1024;
const TWITCH_WEB_CLIENT_ID = 'kimne78kx3ncx6brgo4mv6wki5h1ko';
type PerformanceMode = 'stability' | 'balanced' | 'speed';
type RetryErrorClass = 'network' | 'rate_limit' | 'auth' | 'tooling' | 'integrity' | 'io' | 'unknown';
type RetryErrorClass = 'network' | 'rate_limit' | 'auth' | 'tooling' | 'integrity' | 'io' | 'validation' | 'unknown';
// Ensure directories exist
if (!fs.existsSync(APPDATA_DIR)) {
@ -278,7 +280,10 @@ function loadQueue(): QueueItem[] {
return [];
}
function saveQueue(queue: QueueItem[]): void {
let queueSaveTimer: NodeJS.Timeout | null = null;
let pendingQueueSnapshot: QueueItem[] | null = null;
function writeQueueToDisk(queue: QueueItem[]): void {
try {
fs.writeFileSync(QUEUE_FILE, JSON.stringify(queue, null, 2));
} catch (e) {
@ -286,6 +291,41 @@ function saveQueue(queue: QueueItem[]): void {
}
}
function saveQueue(queue: QueueItem[], force = false): void {
pendingQueueSnapshot = queue;
if (force) {
if (queueSaveTimer) {
clearTimeout(queueSaveTimer);
queueSaveTimer = null;
}
writeQueueToDisk(pendingQueueSnapshot);
pendingQueueSnapshot = null;
return;
}
if (queueSaveTimer) {
return;
}
queueSaveTimer = setTimeout(() => {
queueSaveTimer = null;
if (pendingQueueSnapshot) {
writeQueueToDisk(pendingQueueSnapshot);
pendingQueueSnapshot = null;
}
}, QUEUE_SAVE_DEBOUNCE_MS);
}
function flushQueueSave(): void {
if (pendingQueueSnapshot) {
saveQueue(pendingQueueSnapshot, true);
} else {
saveQueue(downloadQueue, true);
}
}
// ==========================================
// GLOBAL STATE
// ==========================================
@ -816,6 +856,33 @@ function parseVodId(url: string): string {
return match?.[1] || '';
}
function isLikelyVodUrl(url: string): boolean {
return /twitch\.tv\/videos\/\d+/i.test(url || '');
}
function parseFrameRate(rawFrameRate: string | undefined): number {
const fallback = 30;
const value = (rawFrameRate || '').trim();
if (!value) return fallback;
if (/^\d+(\.\d+)?$/.test(value)) {
const numeric = Number(value);
return Number.isFinite(numeric) && numeric > 0 ? numeric : fallback;
}
const ratio = value.match(/^(\d+(?:\.\d+)?)\/(\d+(?:\.\d+)?)$/);
if (!ratio) return fallback;
const numerator = Number(ratio[1]);
const denominator = Number(ratio[2]);
if (!Number.isFinite(numerator) || !Number.isFinite(denominator) || denominator <= 0) {
return fallback;
}
const fps = numerator / denominator;
return Number.isFinite(fps) && fps > 0 ? fps : fallback;
}
interface ClipTemplateContext {
template: string;
title: string;
@ -901,6 +968,63 @@ function formatETA(seconds: number): string {
return `${h}h ${m}m`;
}
function getFreeDiskBytes(targetPath: string): number | null {
try {
const statfsSync = (fs as unknown as { statfsSync?: (path: string) => { bsize?: number; frsize?: number; bavail?: number } }).statfsSync;
if (!statfsSync) {
return null;
}
const info = statfsSync(targetPath);
const blockSize = Number(info?.bsize || info?.frsize || 0);
const availableBlocks = Number(info?.bavail || 0);
if (!Number.isFinite(blockSize) || !Number.isFinite(availableBlocks) || blockSize <= 0 || availableBlocks < 0) {
return null;
}
return Math.floor(blockSize * availableBlocks);
} catch {
return null;
}
}
function estimateRequiredDownloadBytes(item: QueueItem): number {
const durationSeconds = Math.max(1, item.customClip?.durationSec || parseDuration(item.duration_str || '0s'));
const bytesPerSecondByMode: Record<PerformanceMode, number> = {
stability: 900 * 1024,
balanced: 700 * 1024,
speed: 550 * 1024
};
const mode = normalizePerformanceMode(config.performance_mode);
const baseEstimate = durationSeconds * bytesPerSecondByMode[mode];
const withHeadroom = Math.ceil(baseEstimate * (item.customClip ? 1.2 : 1.35));
return Math.max(64 * 1024 * 1024, Math.min(withHeadroom, 40 * 1024 * 1024 * 1024));
}
function ensureDiskSpace(targetPath: string, requiredBytes: number, context: string): DownloadResult {
const freeBytes = getFreeDiskBytes(targetPath);
if (freeBytes === null) {
appendDebugLog('disk-space-check-skipped', { targetPath, requiredBytes, context });
return { success: true };
}
if (freeBytes < Math.max(requiredBytes, MIN_FREE_DISK_BYTES)) {
const message = `Zu wenig Speicherplatz fur ${context}: frei ${formatBytes(freeBytes)}, benoetigt ~${formatBytes(requiredBytes)}.`;
appendDebugLog('disk-space-check-failed', {
targetPath,
requiredBytes,
freeBytes,
context
});
return { success: false, error: message };
}
return { success: true };
}
function getMetadataCacheTtlMs(): number {
return normalizeMetadataCacheMinutes(config.metadata_cache_minutes) * 60 * 1000;
}
@ -921,6 +1045,7 @@ function classifyDownloadError(errorMessage: string): RetryErrorClass {
const text = (errorMessage || '').toLowerCase();
if (!text) return 'unknown';
if (text.includes('ungueltige vod-url') || text.includes('invalid vod url')) return 'validation';
if (text.includes('429') || text.includes('rate limit') || text.includes('too many requests')) return 'rate_limit';
if (text.includes('401') || text.includes('403') || text.includes('unauthorized') || text.includes('forbidden')) return 'auth';
if (text.includes('timed out') || text.includes('timeout') || text.includes('network') || text.includes('connection') || text.includes('dns')) return 'network';
@ -947,6 +1072,8 @@ function getRetryDelaySeconds(errorClass: RetryErrorClass, attempt: number): num
return Math.min(25, 5 + attempt * 3 + jitter);
case 'tooling':
return DEFAULT_RETRY_DELAY_SECONDS;
case 'validation':
return 0;
case 'unknown':
default:
return Math.min(25, DEFAULT_RETRY_DELAY_SECONDS + attempt * 2 + jitter);
@ -1528,7 +1655,7 @@ async function getVideoInfo(filePath: string): Promise<VideoInfo | null> {
duration: parseFloat(info.format?.duration || '0'),
width: videoStream?.width || 0,
height: videoStream?.height || 0,
fps: eval(videoStream?.r_frame_rate || '30') || 30
fps: parseFrameRate(videoStream?.r_frame_rate)
});
} catch {
resolve(null);
@ -1595,6 +1722,25 @@ async function cutVideo(
const ffmpeg = getFFmpegPath();
const duration = Math.max(0.1, endTime - startTime);
let inputBytes = 0;
try {
inputBytes = fs.statSync(inputFile).size;
} catch {
inputBytes = 0;
}
const cutRequiredBytes = Math.max(96 * 1024 * 1024, Math.ceil(inputBytes * 0.75));
const cutDiskCheck = ensureDiskSpace(path.dirname(outputFile), cutRequiredBytes, 'Video-Cut');
if (!cutDiskCheck.success) {
appendDebugLog('cut-video-no-disk-space', {
inputFile,
outputFile,
requiredBytes: cutRequiredBytes,
error: cutDiskCheck.error
});
return false;
}
const runCutAttempt = async (copyMode: boolean): Promise<boolean> => {
const args = [
'-ss', formatDuration(startTime),
@ -1677,6 +1823,30 @@ async function mergeVideos(
const concatContent = inputFiles.map((filePath) => `file '${filePath.replace(/'/g, "'\\''")}'`).join('\n');
fs.writeFileSync(concatFile, concatContent);
let mergeInputBytes = 0;
for (const filePath of inputFiles) {
try {
mergeInputBytes += fs.statSync(filePath).size;
} catch {
// ignore missing file in estimation
}
}
const mergeRequiredBytes = Math.max(128 * 1024 * 1024, Math.ceil(mergeInputBytes * 1.1));
const mergeDiskCheck = ensureDiskSpace(path.dirname(outputFile), mergeRequiredBytes, 'Video-Merge');
if (!mergeDiskCheck.success) {
appendDebugLog('merge-video-no-disk-space', {
outputFile,
files: inputFiles.length,
requiredBytes: mergeRequiredBytes,
error: mergeDiskCheck.error
});
try {
fs.unlinkSync(concatFile);
} catch { }
return false;
}
const runMergeAttempt = async (copyMode: boolean): Promise<boolean> => {
const args = [
'-f', 'concat',
@ -1911,6 +2081,14 @@ async function downloadVOD(
item: QueueItem,
onProgress: (progress: DownloadProgress) => void
): Promise<DownloadResult> {
const vodId = parseVodId(item.url);
if (!isLikelyVodUrl(item.url) || !vodId) {
return {
success: false,
error: 'Ungueltige VOD-URL'
};
}
onProgress({
id: item.id,
progress: -1,
@ -1947,7 +2125,12 @@ async function downloadVOD(
fs.mkdirSync(folder, { recursive: true });
const totalDuration = parseDuration(item.duration_str);
const vodId = parseVodId(item.url);
const requiredBytesEstimate = estimateRequiredDownloadBytes(item);
const diskSpaceCheck = ensureDiskSpace(folder, requiredBytesEstimate, 'Download');
if (!diskSpaceCheck.success) {
return diskSpaceCheck;
}
const makeTemplateFilename = (
template: string,
@ -2171,8 +2354,12 @@ async function processQueue(): Promise<void> {
const errorClass = classifyDownloadError(result.error || '');
runtimeMetrics.lastErrorClass = errorClass;
if (errorClass === 'tooling') {
appendDebugLog('queue-item-no-retry-tooling', { itemId: item.id, error: result.error || 'unknown' });
if (errorClass === 'tooling' || errorClass === 'validation') {
appendDebugLog('queue-item-no-retry', {
itemId: item.id,
errorClass,
error: result.error || 'unknown'
});
break;
}
@ -2370,6 +2557,11 @@ ipcMain.handle('get-queue', () => downloadQueue);
ipcMain.handle('add-to-queue', (_, item: Omit<QueueItem, 'id' | 'status' | 'progress'>) => {
if (config.prevent_duplicate_downloads && hasActiveDuplicate(item)) {
runtimeMetrics.duplicateSkips += 1;
mainWindow?.webContents.send('queue-duplicate-skipped', {
title: item.title,
streamer: item.streamer,
url: item.url
});
appendDebugLog('queue-item-duplicate-skipped', {
title: item.title,
url: item.url,
@ -2545,6 +2737,11 @@ ipcMain.handle('download-clip', async (_, clipUrl: string) => {
const folder = path.join(config.download_path, 'Clips', clipInfo.broadcaster_name);
fs.mkdirSync(folder, { recursive: true });
const clipDiskCheck = ensureDiskSpace(folder, 128 * 1024 * 1024, 'Clip-Download');
if (!clipDiskCheck.success) {
return { success: false, error: clipDiskCheck.error || 'Zu wenig Speicherplatz.' };
}
const safeTitle = clipInfo.title.replace(/[^a-zA-Z0-9_\- ]/g, '').substring(0, 50);
const filename = path.join(folder, `${safeTitle}.mp4`);
@ -2584,6 +2781,30 @@ ipcMain.handle('is-downloading', () => isDownloading);
ipcMain.handle('get-runtime-metrics', () => getRuntimeMetricsSnapshot());
ipcMain.handle('export-runtime-metrics', async () => {
try {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const defaultName = `runtime-metrics-${timestamp}.json`;
const preferredDir = fs.existsSync(config.download_path) ? config.download_path : app.getPath('desktop');
const dialogResult = await dialog.showSaveDialog(mainWindow!, {
defaultPath: path.join(preferredDir, defaultName),
filters: [{ name: 'JSON', extensions: ['json'] }]
});
if (dialogResult.canceled || !dialogResult.filePath) {
return { success: false, cancelled: true };
}
const snapshot = getRuntimeMetricsSnapshot();
fs.writeFileSync(dialogResult.filePath, JSON.stringify(snapshot, null, 2), 'utf-8');
return { success: true, filePath: dialogResult.filePath };
} catch (e) {
appendDebugLog('runtime-metrics-export-failed', String(e));
return { success: false, error: String(e) };
}
});
// Video Cutter IPC
ipcMain.handle('get-video-info', async (_, filePath: string) => {
return await getVideoInfo(filePath);
@ -2656,9 +2877,13 @@ app.on('window-all-closed', () => {
if (currentProcess) {
currentProcess.kill();
}
saveQueue(downloadQueue);
flushQueueSave();
if (process.platform !== 'darwin') {
app.quit();
}
});
app.on('before-quit', () => {
flushQueueSave();
});

View File

@ -138,6 +138,8 @@ contextBridge.exposeInMainWorld('api', {
runPreflight: (autoFix: boolean) => ipcRenderer.invoke('run-preflight', autoFix),
getDebugLog: (lines: number) => ipcRenderer.invoke('get-debug-log', lines),
getRuntimeMetrics: (): Promise<RuntimeMetricsSnapshot> => ipcRenderer.invoke('get-runtime-metrics'),
exportRuntimeMetrics: (): Promise<{ success: boolean; cancelled?: boolean; error?: string; filePath?: string }> =>
ipcRenderer.invoke('export-runtime-metrics'),
// Events
onDownloadProgress: (callback: (progress: DownloadProgress) => void) => {
@ -146,6 +148,9 @@ contextBridge.exposeInMainWorld('api', {
onQueueUpdated: (callback: (queue: QueueItem[]) => void) => {
ipcRenderer.on('queue-updated', (_, queue) => callback(queue));
},
onQueueDuplicateSkipped: (callback: (payload: { title: string; streamer: string; url: string }) => void) => {
ipcRenderer.on('queue-duplicate-skipped', (_, payload) => callback(payload));
},
onDownloadStarted: (callback: () => void) => {
ipcRenderer.on('download-started', () => callback());
},

View File

@ -185,8 +185,10 @@ interface ApiBridge {
runPreflight(autoFix: boolean): Promise<PreflightResult>;
getDebugLog(lines: number): Promise<string>;
getRuntimeMetrics(): Promise<RuntimeMetricsSnapshot>;
exportRuntimeMetrics(): Promise<{ success: boolean; cancelled?: boolean; error?: string; filePath?: string }>;
onDownloadProgress(callback: (progress: DownloadProgress) => void): void;
onQueueUpdated(callback: (queue: QueueItem[]) => void): void;
onQueueDuplicateSkipped(callback: (payload: { title: string; streamer: string; url: string }) => void): void;
onDownloadStarted(callback: () => void): void;
onDownloadFinished(callback: () => void): void;
onCutProgress(callback: (percent: number) => void): void;

View File

@ -76,9 +76,13 @@ const UI_TEXT_DE = {
templateGuideContextClipLive: 'Kontext: Aktuelle Auswahl im Clip-Dialog',
runtimeMetricsTitle: 'Runtime Metrics',
runtimeMetricsRefresh: 'Aktualisieren',
runtimeMetricsExport: 'Export JSON',
runtimeMetricsAutoRefresh: 'Auto-Refresh',
runtimeMetricsLoading: 'Metriken werden geladen...',
runtimeMetricsError: 'Runtime-Metriken konnten nicht geladen werden.',
runtimeMetricsExportDone: 'Runtime-Metriken wurden exportiert.',
runtimeMetricsExportCancelled: 'Export der Runtime-Metriken abgebrochen.',
runtimeMetricsExportFailed: 'Export der Runtime-Metriken fehlgeschlagen.',
runtimeMetricQueue: 'Queue',
runtimeMetricMode: 'Modus',
runtimeMetricRetries: 'Retries',

View File

@ -76,9 +76,13 @@ const UI_TEXT_EN = {
templateGuideContextClipLive: 'Context: Current clip dialog selection',
runtimeMetricsTitle: 'Runtime Metrics',
runtimeMetricsRefresh: 'Refresh',
runtimeMetricsExport: 'Export JSON',
runtimeMetricsAutoRefresh: 'Auto refresh',
runtimeMetricsLoading: 'Loading metrics...',
runtimeMetricsError: 'Could not load runtime metrics.',
runtimeMetricsExportDone: 'Runtime metrics exported successfully.',
runtimeMetricsExportCancelled: 'Runtime metrics export cancelled.',
runtimeMetricsExportFailed: 'Runtime metrics export failed.',
runtimeMetricQueue: 'Queue',
runtimeMetricMode: 'Mode',
runtimeMetricRetries: 'Retries',

View File

@ -98,6 +98,31 @@ async function refreshRuntimeMetrics(): Promise<void> {
}
}
async function exportRuntimeMetrics(): Promise<void> {
const result = await window.api.exportRuntimeMetrics();
const toast = (window as unknown as { showAppToast?: (message: string, type?: 'info' | 'warn') => void }).showAppToast;
const notify = (message: string, type: 'info' | 'warn' = 'info') => {
if (typeof toast === 'function') {
toast(message, type);
} else if (type === 'warn') {
alert(message);
}
};
if (result.success) {
notify(UI_TEXT.static.runtimeMetricsExportDone, 'info');
return;
}
if (result.cancelled) {
notify(UI_TEXT.static.runtimeMetricsExportCancelled, 'info');
return;
}
notify(`${UI_TEXT.static.runtimeMetricsExportFailed}${result.error ? `\n${result.error}` : ''}`, 'warn');
}
function toggleRuntimeMetricsAutoRefresh(enabled: boolean): void {
if (runtimeMetricsAutoRefreshTimer) {
clearInterval(runtimeMetricsAutoRefreshTimer);

View File

@ -125,6 +125,7 @@ function applyLanguageToStaticUI(): void {
setText('autoRefreshText', UI_TEXT.static.autoRefresh);
setText('runtimeMetricsTitle', UI_TEXT.static.runtimeMetricsTitle);
setText('btnRefreshMetrics', UI_TEXT.static.runtimeMetricsRefresh);
setText('btnExportMetrics', UI_TEXT.static.runtimeMetricsExport);
setText('runtimeMetricsAutoRefreshText', UI_TEXT.static.runtimeMetricsAutoRefresh);
setText('runtimeMetricsOutput', UI_TEXT.static.runtimeMetricsLoading);
setText('updateText', UI_TEXT.updates.bannerDefault);

View File

@ -36,6 +36,11 @@ async function init(): Promise<void> {
renderQueue();
});
window.api.onQueueDuplicateSkipped((payload) => {
const title = payload?.title ? ` (${payload.title})` : '';
showAppToast(`${UI_TEXT.queue.duplicateSkipped}${title}`, 'warn');
});
window.api.onDownloadProgress((progress: DownloadProgress) => {
const item = queue.find((i: QueueItem) => i.id === progress.id);
if (!item) {
@ -98,6 +103,37 @@ async function init(): Promise<void> {
}, 2000);
}
let toastHideTimer: number | null = null;
function showAppToast(message: string, type: 'info' | 'warn' = 'info'): void {
let toast = document.getElementById('appToast');
if (!toast) {
toast = document.createElement('div');
toast.id = 'appToast';
toast.className = 'app-toast';
document.body.appendChild(toast);
}
toast.textContent = message;
toast.classList.remove('warn', 'show');
if (type === 'warn') {
toast.classList.add('warn');
}
requestAnimationFrame(() => {
toast?.classList.add('show');
});
if (toastHideTimer) {
clearTimeout(toastHideTimer);
toastHideTimer = null;
}
toastHideTimer = window.setTimeout(() => {
toast?.classList.remove('show');
}, 3200);
}
function mergeQueueState(nextQueue: QueueItem[]): QueueItem[] {
const prevById = new Map(queue.map((item) => [item.id, item]));

View File

@ -1379,3 +1379,32 @@ body.theme-apple {
display: flex;
justify-content: flex-end;
}
.app-toast {
position: fixed;
right: 18px;
bottom: 16px;
z-index: 2200;
max-width: min(90vw, 520px);
background: rgba(20, 20, 24, 0.96);
color: #e6e6ea;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 8px;
padding: 10px 12px;
font-size: 13px;
line-height: 1.45;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
opacity: 0;
transform: translateY(10px);
pointer-events: none;
transition: opacity 0.18s ease, transform 0.18s ease;
}
.app-toast.show {
opacity: 1;
transform: translateY(0);
}
.app-toast.warn {
border-color: rgba(255, 167, 38, 0.7);
}