release: publish Twitch VOD Manager 1.0.3
Polish navigation, settings, streamer and queue workflows; add safe pause and partial-file lifecycle handling; apply the product identity across Windows surfaces; and replace the public README with a complete English product guide and isolated screenshot.
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { getWindowsAppIdentity } from './app-identity';
|
||||
|
||||
describe('getWindowsAppIdentity', () => {
|
||||
test('trennt Hot-Dev von der veröffentlichten Windows-Identität', () => {
|
||||
expect(getWindowsAppIdentity(true)).toEqual({
|
||||
name: 'Twitch VOD Manager',
|
||||
appUserModelId: 'io.github.sucukdeluxe.twitch-vod-manager.development'
|
||||
});
|
||||
expect(getWindowsAppIdentity(false)).toEqual({
|
||||
name: 'Twitch VOD Manager',
|
||||
appUserModelId: 'io.github.sucukdeluxe.twitch-vod-manager'
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
export interface WindowsAppIdentity {
|
||||
name: string;
|
||||
appUserModelId: string;
|
||||
}
|
||||
|
||||
export function getWindowsAppIdentity(isDevelopment: boolean): WindowsAppIdentity {
|
||||
return {
|
||||
name: 'Twitch VOD Manager',
|
||||
appUserModelId: isDevelopment
|
||||
? 'io.github.sucukdeluxe.twitch-vod-manager.development'
|
||||
: 'io.github.sucukdeluxe.twitch-vod-manager'
|
||||
};
|
||||
}
|
||||
@@ -41,6 +41,14 @@ describe('tBackend', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('German backend messages use native umlauts', () => {
|
||||
const text = Object.values(BACKEND_MESSAGES.de).join('\n').toLocaleLowerCase('de-DE');
|
||||
const forbidden = ['ungueltig', 'integritaetspruefung', 'fur ', 'benoetigt', 'prufe '];
|
||||
for (const token of forbidden) {
|
||||
expect(text).not.toContain(token);
|
||||
}
|
||||
});
|
||||
|
||||
test('no template literal left after substitution for typical params', () => {
|
||||
// attemptFailed has {attempt}, {max}, {errorClass}, {error}
|
||||
const result = tBackend('attemptFailed', { attempt: 1, max: 3, errorClass: 'network', error: 'ETIMEDOUT' }, 'en');
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
// Backend-Messages (User-visible aus main.ts produziert). Pure: Sprache wird
|
||||
// als Parameter uebergeben statt aus globalem config geholt.
|
||||
// als Parameter übergeben statt aus globalem config geholt.
|
||||
|
||||
export const BACKEND_MESSAGES = {
|
||||
de: {
|
||||
invalidVodUrl: 'Ungueltige VOD-URL',
|
||||
invalidClipUrl: 'Ungueltige Clip-URL',
|
||||
invalidVodUrl: 'Ungültige VOD-URL',
|
||||
invalidClipUrl: 'Ungültige Clip-URL',
|
||||
clipNotFound: 'Clip nicht gefunden',
|
||||
streamlinkAutoInstallFailed: 'Streamlink fehlt und konnte nicht automatisch installiert werden. Siehe debug.log.',
|
||||
streamlinkMissing: 'Streamlink fehlt.',
|
||||
@@ -15,10 +15,10 @@ export const BACKEND_MESSAGES = {
|
||||
ffmpegSplitFailed: 'FFmpeg Split fehlgeschlagen.',
|
||||
fileTooSmall: 'Datei zu klein ({bytes} Bytes)',
|
||||
clipFileTooSmall: 'Clip-Datei zu klein ({bytes} Bytes) - Twitch hat den Stream evtl. nicht ausgeliefert.',
|
||||
integrityNoVideo: 'Integritaetspruefung fehlgeschlagen: Kein Videostream gefunden.',
|
||||
integrityTooShort: 'Integritaetspruefung fehlgeschlagen: Dauer zu kurz ({duration}s).',
|
||||
integrityDurationMismatch: 'Integritaetspruefung fehlgeschlagen: {actual}s statt erwarteter ~{expected}s.',
|
||||
integrityFailedGeneric: 'Integritaetspruefung fehlgeschlagen.',
|
||||
integrityNoVideo: 'Integritätsprüfung fehlgeschlagen: Kein Videostream gefunden.',
|
||||
integrityTooShort: 'Integritätsprüfung fehlgeschlagen: Dauer zu kurz ({duration}s).',
|
||||
integrityDurationMismatch: 'Integritätsprüfung fehlgeschlagen: {actual}s statt erwarteter ~{expected}s.',
|
||||
integrityFailedGeneric: 'Integritätsprüfung fehlgeschlagen.',
|
||||
downloadCancelled: 'Download wurde abgebrochen.',
|
||||
downloadPaused: 'Download wurde pausiert.',
|
||||
downloadFailedExitCode: 'Download fehlgeschlagen (Exit-Code {code})',
|
||||
@@ -26,12 +26,12 @@ export const BACKEND_MESSAGES = {
|
||||
notAllClipPartsDownloaded: 'Nicht alle Clip-Teile konnten heruntergeladen werden.',
|
||||
notAllPartsDownloaded: 'Nicht alle Teile konnten heruntergeladen werden.',
|
||||
mergeGroupFileMissing: 'Heruntergeladene Datei {index} fehlt.',
|
||||
diskSpaceShortFor: 'Zu wenig Speicherplatz fur {context}: frei {free}, benoetigt ~{required}.',
|
||||
diskSpaceShortFor: 'Zu wenig Speicherplatz für {context}: frei {free}, benötigt ~{required}.',
|
||||
diskSpaceShortGeneric: 'Zu wenig Speicherplatz.',
|
||||
attemptFailed: 'Versuch {attempt}/{max} fehlgeschlagen ({errorClass}): {error}',
|
||||
retryingIn: 'Neuer Versuch in {seconds}s ({errorClass})...',
|
||||
statusCheckingTools: 'Prufe Download-Tools...',
|
||||
statusDownloadStarted: 'Download gestartet',
|
||||
statusCheckingTools: 'Prüfe Download-Tools...',
|
||||
statusDownloadStarted: 'Download wird gestartet …',
|
||||
statusBytesDownloaded: '{bytes} heruntergeladen',
|
||||
statusFetchingChatReplay: 'Chat-Replay wird heruntergeladen...',
|
||||
statusChatMessagesFetched: 'Chat-Nachrichten geladen: {count}',
|
||||
@@ -70,7 +70,7 @@ export const BACKEND_MESSAGES = {
|
||||
attemptFailed: 'Attempt {attempt}/{max} failed ({errorClass}): {error}',
|
||||
retryingIn: 'Retrying in {seconds}s ({errorClass})...',
|
||||
statusCheckingTools: 'Checking download tools...',
|
||||
statusDownloadStarted: 'Download started',
|
||||
statusDownloadStarted: 'Starting download …',
|
||||
statusBytesDownloaded: '{bytes} downloaded',
|
||||
statusFetchingChatReplay: 'Fetching chat replay...',
|
||||
statusChatMessagesFetched: 'Chat messages fetched: {count}',
|
||||
|
||||
@@ -36,7 +36,7 @@ const CONFIG_KV_KEYS = [
|
||||
'auto_record_poll_seconds', 'filename_template_vod', 'filename_template_parts',
|
||||
'filename_template_clip', 'smart_queue_scheduler', 'prevent_duplicate_downloads',
|
||||
'persist_queue_on_restart', 'auto_resume_queue_on_startup',
|
||||
'notify_on_each_completion',
|
||||
'notify_on_each_completion', 'sidebar_split_view',
|
||||
] as const;
|
||||
|
||||
function backupOnce(srcPath: string): void {
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { PartialDownloadRegistry } from './partial-download';
|
||||
|
||||
let tempDirectory: string;
|
||||
let registryPath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'tvm-partial-'));
|
||||
registryPath = path.join(tempDirectory, 'partial-downloads.json');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tempDirectory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('PartialDownloadRegistry', () => {
|
||||
it('veröffentlicht erst nach erfolgreichem Commit den endgültigen Dateinamen', () => {
|
||||
const registry = new PartialDownloadRegistry(registryPath);
|
||||
const finalPath = path.join(tempDirectory, 'video.mp4');
|
||||
const partialPath = registry.begin(finalPath);
|
||||
|
||||
fs.writeFileSync(partialPath, 'vollständig');
|
||||
|
||||
expect(partialPath).toBe(`${finalPath}.tvm-part`);
|
||||
expect(fs.existsSync(finalPath)).toBe(false);
|
||||
|
||||
registry.commit(partialPath, finalPath);
|
||||
|
||||
expect(fs.readFileSync(finalPath, 'utf8')).toBe('vollständig');
|
||||
expect(fs.existsSync(partialPath)).toBe(false);
|
||||
expect(fs.existsSync(registryPath)).toBe(false);
|
||||
});
|
||||
|
||||
it('entfernt eine abgebrochene Teil-Datei', () => {
|
||||
const registry = new PartialDownloadRegistry(registryPath);
|
||||
const finalPath = path.join(tempDirectory, 'abbruch.mp4');
|
||||
const partialPath = registry.begin(finalPath);
|
||||
fs.writeFileSync(partialPath, 'unvollständig');
|
||||
|
||||
registry.discard(partialPath);
|
||||
|
||||
expect(fs.existsSync(partialPath)).toBe(false);
|
||||
expect(fs.existsSync(finalPath)).toBe(false);
|
||||
expect(fs.existsSync(registryPath)).toBe(false);
|
||||
});
|
||||
|
||||
it('räumt nach einem simulierten Crash registrierte Teil-Dateien beim nächsten Start auf', () => {
|
||||
const firstRun = new PartialDownloadRegistry(registryPath);
|
||||
const finalPath = path.join(tempDirectory, 'crash.mp4');
|
||||
const partialPath = firstRun.begin(finalPath);
|
||||
fs.writeFileSync(partialPath, 'unvollständig');
|
||||
|
||||
const secondRun = new PartialDownloadRegistry(registryPath);
|
||||
const removed = secondRun.cleanup();
|
||||
|
||||
expect(removed).toEqual([partialPath]);
|
||||
expect(fs.existsSync(partialPath)).toBe(false);
|
||||
expect(fs.existsSync(finalPath)).toBe(false);
|
||||
expect(fs.existsSync(registryPath)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { writeFileAtomicSync } from '../infra/fs-atomic';
|
||||
|
||||
const PARTIAL_SUFFIX = '.tvm-part';
|
||||
|
||||
export class PartialDownloadRegistry {
|
||||
private readonly paths = new Set<string>();
|
||||
|
||||
constructor(private readonly registryPath: string) {
|
||||
this.load(this.registryPath);
|
||||
this.load(`${this.registryPath}.tmp`);
|
||||
}
|
||||
|
||||
begin(finalPath: string): string {
|
||||
const partialPath = path.resolve(`${finalPath}${PARTIAL_SUFFIX}`);
|
||||
if (fs.existsSync(partialPath)) fs.rmSync(partialPath, { force: true });
|
||||
this.paths.add(partialPath);
|
||||
this.persist();
|
||||
return partialPath;
|
||||
}
|
||||
|
||||
commit(partialPath: string, finalPath: string): void {
|
||||
const normalizedPartialPath = this.normalize(partialPath);
|
||||
const normalizedFinalPath = path.resolve(finalPath);
|
||||
if (!fs.existsSync(normalizedPartialPath)) throw new Error(`Teil-Datei fehlt: ${normalizedPartialPath}`);
|
||||
if (fs.existsSync(normalizedFinalPath)) throw new Error(`Zieldatei existiert bereits: ${normalizedFinalPath}`);
|
||||
fs.renameSync(normalizedPartialPath, normalizedFinalPath);
|
||||
this.paths.delete(normalizedPartialPath);
|
||||
this.persist();
|
||||
}
|
||||
|
||||
discard(partialPath: string): void {
|
||||
const normalizedPartialPath = this.normalize(partialPath);
|
||||
if (fs.existsSync(normalizedPartialPath)) fs.rmSync(normalizedPartialPath, { force: true });
|
||||
this.paths.delete(normalizedPartialPath);
|
||||
this.persist();
|
||||
}
|
||||
|
||||
cleanup(): string[] {
|
||||
const removed: string[] = [];
|
||||
for (const partialPath of this.paths) {
|
||||
if (fs.existsSync(partialPath)) {
|
||||
fs.rmSync(partialPath, { force: true });
|
||||
removed.push(partialPath);
|
||||
}
|
||||
}
|
||||
this.paths.clear();
|
||||
this.persist();
|
||||
return removed;
|
||||
}
|
||||
|
||||
private normalize(filePath: string): string {
|
||||
const normalizedPath = path.resolve(filePath);
|
||||
if (!normalizedPath.endsWith(PARTIAL_SUFFIX)) throw new Error(`Ungültiger Teil-Dateipfad: ${normalizedPath}`);
|
||||
return normalizedPath;
|
||||
}
|
||||
|
||||
private load(filePath: string): void {
|
||||
try {
|
||||
if (!fs.existsSync(filePath)) return;
|
||||
const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||||
const values = Array.isArray(parsed) ? parsed : parsed?.paths;
|
||||
if (!Array.isArray(values)) return;
|
||||
for (const value of values) {
|
||||
if (typeof value !== 'string') continue;
|
||||
try {
|
||||
this.paths.add(this.normalize(value));
|
||||
} catch { }
|
||||
}
|
||||
} catch { }
|
||||
}
|
||||
|
||||
private persist(): void {
|
||||
if (this.paths.size === 0) {
|
||||
fs.rmSync(this.registryPath, { force: true });
|
||||
fs.rmSync(`${this.registryPath}.tmp`, { force: true });
|
||||
return;
|
||||
}
|
||||
fs.mkdirSync(path.dirname(this.registryPath), { recursive: true });
|
||||
writeFileAtomicSync(this.registryPath, JSON.stringify({ paths: [...this.paths] }, null, 2));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { PassThrough, Writable } from 'stream';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { createPausableOutput } from './pausable-output';
|
||||
|
||||
function waitForTurn(): Promise<void> {
|
||||
return new Promise((resolve) => setImmediate(resolve));
|
||||
}
|
||||
|
||||
describe('createPausableOutput', () => {
|
||||
it('setzt denselben Ausgabestrom ohne Datenverlust fort', async () => {
|
||||
const source = new PassThrough();
|
||||
const chunks: Buffer[] = [];
|
||||
const target = new Writable({
|
||||
write(chunk, _encoding, callback) {
|
||||
chunks.push(Buffer.from(chunk));
|
||||
callback();
|
||||
}
|
||||
});
|
||||
const output = createPausableOutput(source, target);
|
||||
|
||||
source.write('erster-');
|
||||
await waitForTurn();
|
||||
output.pause();
|
||||
source.write('zweiter-');
|
||||
await waitForTurn();
|
||||
|
||||
expect(Buffer.concat(chunks).toString()).toBe('erster-');
|
||||
|
||||
output.resume();
|
||||
source.end('dritter');
|
||||
await output.finished;
|
||||
|
||||
expect(Buffer.concat(chunks).toString()).toBe('erster-zweiter-dritter');
|
||||
expect(output.isPaused()).toBe(false);
|
||||
});
|
||||
|
||||
it('schließt einen während der Pause beendeten Quellstrom erst nach dem Fortsetzen ab', async () => {
|
||||
const source = new PassThrough();
|
||||
const chunks: Buffer[] = [];
|
||||
const target = new Writable({
|
||||
write(chunk, _encoding, callback) {
|
||||
chunks.push(Buffer.from(chunk));
|
||||
callback();
|
||||
}
|
||||
});
|
||||
const output = createPausableOutput(source, target);
|
||||
let finished = false;
|
||||
void output.finished.then(() => {
|
||||
finished = true;
|
||||
});
|
||||
|
||||
source.write('vorher-');
|
||||
await waitForTurn();
|
||||
output.pause();
|
||||
source.end('nachher');
|
||||
await waitForTurn();
|
||||
|
||||
expect(finished).toBe(false);
|
||||
expect(Buffer.concat(chunks).toString()).toBe('vorher-');
|
||||
|
||||
output.resume();
|
||||
await output.finished;
|
||||
|
||||
expect(Buffer.concat(chunks).toString()).toBe('vorher-nachher');
|
||||
});
|
||||
|
||||
it('schließt Quell- und Zielstrom bei einem Abbruch zuverlässig', async () => {
|
||||
const source = new PassThrough();
|
||||
const chunks: Buffer[] = [];
|
||||
const target = new Writable({
|
||||
write(chunk, _encoding, callback) {
|
||||
chunks.push(Buffer.from(chunk));
|
||||
callback();
|
||||
}
|
||||
});
|
||||
const output = createPausableOutput(source, target);
|
||||
|
||||
source.write('behalten');
|
||||
await waitForTurn();
|
||||
output.pause();
|
||||
|
||||
await output.cancel();
|
||||
await output.finished;
|
||||
|
||||
expect(source.destroyed).toBe(true);
|
||||
expect(target.destroyed).toBe(true);
|
||||
expect(Buffer.concat(chunks).toString()).toBe('behalten');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import { Readable, Writable } from 'stream';
|
||||
|
||||
export interface PausableOutput {
|
||||
pause(): void;
|
||||
resume(): void;
|
||||
cancel(): Promise<void>;
|
||||
isPaused(): boolean;
|
||||
finished: Promise<void>;
|
||||
}
|
||||
|
||||
export function createPausableOutput(source: Readable, target: Writable): PausableOutput {
|
||||
let paused = false;
|
||||
let settled = false;
|
||||
let resolveFinished: () => void = () => {};
|
||||
let rejectFinished: (error: Error) => void = () => {};
|
||||
let resolveClosed: () => void = () => {};
|
||||
const finished = new Promise<void>((resolve, reject) => {
|
||||
resolveFinished = resolve;
|
||||
rejectFinished = reject;
|
||||
});
|
||||
const closed = new Promise<void>((resolve) => {
|
||||
resolveClosed = resolve;
|
||||
});
|
||||
const attach = () => source.pipe(target, { end: false });
|
||||
const finish = () => {
|
||||
if (!settled) target.end();
|
||||
};
|
||||
|
||||
target.once('finish', () => {
|
||||
settled = true;
|
||||
resolveFinished();
|
||||
});
|
||||
target.once('close', () => {
|
||||
resolveClosed();
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
resolveFinished();
|
||||
}
|
||||
});
|
||||
target.once('error', (error) => {
|
||||
settled = true;
|
||||
source.destroy(error);
|
||||
rejectFinished(error);
|
||||
});
|
||||
source.once('end', finish);
|
||||
source.once('error', (error) => target.destroy(error));
|
||||
attach();
|
||||
|
||||
return {
|
||||
pause() {
|
||||
if (paused || settled) return;
|
||||
paused = true;
|
||||
source.unpipe(target);
|
||||
source.pause();
|
||||
},
|
||||
resume() {
|
||||
if (!paused || settled) return;
|
||||
paused = false;
|
||||
attach();
|
||||
source.resume();
|
||||
},
|
||||
async cancel() {
|
||||
if (!settled) {
|
||||
paused = false;
|
||||
source.unpipe(target);
|
||||
source.destroy();
|
||||
target.destroy();
|
||||
}
|
||||
await closed;
|
||||
},
|
||||
isPaused: () => paused,
|
||||
finished
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { buildVodPreviewFrameUrls } from './vod-preview';
|
||||
|
||||
describe('buildVodPreviewFrameUrls', () => {
|
||||
test('builds four full-HD frame URLs from a Twitch thumb0 URL', () => {
|
||||
const input = 'https://static-cdn.jtvnw.net/cf_vods/example/thumb/thumb0-1920x1080.jpg';
|
||||
|
||||
expect(buildVodPreviewFrameUrls(input)).toEqual([
|
||||
'https://static-cdn.jtvnw.net/cf_vods/example/thumb/thumb0-1920x1080.jpg',
|
||||
'https://static-cdn.jtvnw.net/cf_vods/example/thumb/thumb1-1920x1080.jpg',
|
||||
'https://static-cdn.jtvnw.net/cf_vods/example/thumb/thumb2-1920x1080.jpg',
|
||||
'https://static-cdn.jtvnw.net/cf_vods/example/thumb/thumb3-1920x1080.jpg'
|
||||
]);
|
||||
});
|
||||
|
||||
test('rejects URLs that are not Twitch full-HD VOD thumbnails', () => {
|
||||
expect(buildVodPreviewFrameUrls('https://example.invalid/thumb0-320x180.jpg')).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
const TWITCH_VOD_THUMBNAIL_PATTERN = /thumb0-1920x1080\.jpg$/;
|
||||
|
||||
export function buildVodPreviewFrameUrls(thumbnailUrl: string): string[] {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(thumbnailUrl);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (parsed.protocol !== 'https:' || parsed.hostname !== 'static-cdn.jtvnw.net' || !TWITCH_VOD_THUMBNAIL_PATTERN.test(parsed.pathname)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [0, 1, 2, 3].map((index) => {
|
||||
const frameUrl = new URL(parsed.toString());
|
||||
frameUrl.pathname = frameUrl.pathname.replace('thumb0-1920x1080.jpg', `thumb${index}-1920x1080.jpg`);
|
||||
return frameUrl.toString();
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user