tubearr/src/index.ts
jlightner a11c4c56c5 test: Added missing-scan API (trigger + status) and content requeue end…
- "src/server/routes/system.ts"
- "src/server/index.ts"
- "src/index.ts"
- "src/__tests__/missing-scan-api.test.ts"

GSD-Task: S06/T02
2026-04-04 06:35:58 +00:00

271 lines
9.8 KiB
TypeScript

import { config as loadEnv } from 'dotenv';
// Load environment variables before anything else
loadEnv();
import { appConfig } from './config/index';
import { initDatabaseAsync, closeDatabase } from './db/index';
import { runMigrations } from './db/migrate';
import { buildServer } from './server/index';
import { ensureDefaultFormatProfile } from './db/repositories/format-profile-repository';
import { seedAppDefaults } from './db/repositories/system-config-repository';
import { RateLimiter } from './services/rate-limiter';
import { SchedulerService } from './services/scheduler';
import { FileOrganizer } from './services/file-organizer';
import { CookieManager } from './services/cookie-manager';
import { QualityAnalyzer } from './services/quality-analyzer';
import { DownloadService } from './services/download';
import { DownloadEventBus } from './services/event-bus';
import { QueueService } from './services/queue';
import { NotificationService } from './services/notification';
import { HealthService } from './services/health';
import { MissingFileScanner } from './services/missing-file-scanner';
import { MediaServerService } from './services/media-server';
import { getEnabledMediaServers } from './db/repositories/media-server-repository';
import { PlatformRegistry } from './sources/platform-source';
import { YouTubeSource } from './sources/youtube';
import { SoundCloudSource } from './sources/soundcloud';
import { GenericSource } from './sources/generic';
import { Platform } from './types/index';
import { getYtDlpVersion, updateYtDlp } from './sources/yt-dlp';
import type { ViteDevServer } from 'vite';
const APP_NAME = 'Tubearr';
async function main(): Promise<void> {
console.log(`[${APP_NAME}] Starting...`);
// 1. Initialize database with WAL mode
const db = await initDatabaseAsync(appConfig.dbPath);
// 2. Run migrations (idempotent — skips already-applied)
await runMigrations(appConfig.dbPath);
// 2b. Seed default format profile (idempotent)
await ensureDefaultFormatProfile(db);
console.log(`[${APP_NAME}] Default format profile ensured`);
// 2c. Seed app settings (idempotent — env vars seed on first boot, DB wins after)
await seedAppDefaults(db);
console.log(`[${APP_NAME}] App settings seeded`);
// 2d. Check yt-dlp version and auto-update if configured
try {
const version = await getYtDlpVersion();
if (version) {
console.log(`[${APP_NAME}] yt-dlp version: ${version}`);
// Auto-update on startup (non-blocking — continue if it fails)
if (appConfig.nodeEnv === 'production') {
const result = await updateYtDlp();
if (result.updated) {
console.log(`[${APP_NAME}] yt-dlp updated: ${result.previousVersion}${result.version}`);
}
}
} else {
console.warn(`[${APP_NAME}] yt-dlp not found on PATH — downloads will fail`);
}
} catch (err) {
console.warn(`[${APP_NAME}] yt-dlp check failed: ${err instanceof Error ? err.message : String(err)}`);
}
// 3. Build and configure Fastify server
// In dev mode, embed Vite for HMR — single port, no separate frontend process
let vite: ViteDevServer | undefined;
if (appConfig.nodeEnv !== 'production') {
const { createServer: createViteServer } = await import('vite');
const { resolve } = await import('node:path');
vite = await createViteServer({
configFile: resolve(process.cwd(), 'src/frontend/vite.config.ts'),
server: { middlewareMode: true },
appType: 'custom',
});
console.log(`[${APP_NAME}] Vite dev server embedded (HMR active)`);
}
const eventBus = new DownloadEventBus();
const server = await buildServer({ db, eventBus, vite });
// 4. Set up shared services
const rateLimiter = new RateLimiter({
[Platform.YouTube]: appConfig.rateLimiter.youtube,
[Platform.SoundCloud]: appConfig.rateLimiter.soundcloud,
});
const fileOrganizer = new FileOrganizer(appConfig.mediaPath);
const cookieManager = new CookieManager(appConfig.cookiePath);
const qualityAnalyzer = new QualityAnalyzer();
const downloadService = new DownloadService(
db,
rateLimiter,
fileOrganizer,
qualityAnalyzer,
cookieManager,
eventBus
);
// Attach download service to server for route access
(server as { downloadService: DownloadService | null }).downloadService = downloadService;
// 4b. Set up notification and queue services
const notificationService = new NotificationService(db);
const queueService = new QueueService(db, downloadService, {
concurrency: appConfig.concurrentDownloads,
onDownloadComplete: (event) => {
notificationService
.notifyDownload(event.contentTitle, event.channelName, event.platform, event.url, event.filePath)
.catch((err) => {
console.log(`[notification] onDownloadComplete error: ${err instanceof Error ? err.message : String(err)}`);
});
},
onDownloadFailed: (event) => {
notificationService
.notifyFailure(
event.contentTitle,
event.channelName,
event.platform,
event.error ?? 'Unknown error',
event.attempt ?? 0,
event.maxAttempts ?? 0
)
.catch((err) => {
console.log(`[notification] onDownloadFailed error: ${err instanceof Error ? err.message : String(err)}`);
});
},
});
(server as { queueService: QueueService | null }).queueService = queueService;
// 5. Set up scheduler (if enabled)
let scheduler: SchedulerService | null = null;
if (appConfig.scheduler.enabled) {
const platformRegistry = new PlatformRegistry();
platformRegistry.register(Platform.YouTube, new YouTubeSource());
platformRegistry.register(Platform.SoundCloud, new SoundCloudSource());
platformRegistry.register(Platform.Generic, new GenericSource());
scheduler = new SchedulerService(db, platformRegistry, rateLimiter, {
onNewContent: (contentItemId: number) => {
queueService.enqueue(contentItemId).catch((err) => {
console.error(
`[scheduler] auto-enqueue failed for contentItemId=${contentItemId}:`,
err instanceof Error ? err.message : err
);
});
},
eventBus,
});
// Attach scheduler to server so routes can notify it
(server as { scheduler: SchedulerService | null }).scheduler = scheduler;
}
// 5b. Set up health service
const healthService = new HealthService(
db,
() => scheduler?.getState() ?? null,
appConfig.mediaPath
);
(server as { healthService: HealthService | null }).healthService = healthService;
// 5c-ii. Set up missing file scanner
const missingFileScanner = new MissingFileScanner(db);
(server as { missingFileScanner: MissingFileScanner | null }).missingFileScanner = missingFileScanner;
// 5d. Wire automatic media-server scans on download completion
const mediaServerService = new MediaServerService();
eventBus.onDownload('download:complete', (payload) => {
getEnabledMediaServers(db)
.then(async (servers) => {
if (servers.length === 0) return;
console.log(
`[media-server] download complete contentItemId=${payload.contentItemId} — triggering ${servers.length} server scan(s)`
);
const results = await Promise.allSettled(
servers.map((s) => mediaServerService.triggerScan(s))
);
for (let i = 0; i < results.length; i++) {
const r = results[i];
const s = servers[i];
if (r.status === 'fulfilled') {
if (r.value.success) {
console.log(`[media-server] scan ok server="${s.name}" msg="${r.value.message}"`);
} else {
console.log(`[media-server] scan failed server="${s.name}" msg="${r.value.message}"`);
}
} else {
console.log(`[media-server] scan error server="${s.name}" err="${r.reason}"`);
}
}
})
.catch((err) => {
console.log(
`[media-server] failed to query enabled servers: ${err instanceof Error ? err.message : String(err)}`
);
});
});
// 6. Graceful shutdown handler
const shutdown = async (signal: string) => {
console.log(`[${APP_NAME}] ${signal} received — shutting down gracefully...`);
try {
// Stop queue service and scheduler before closing server
queueService.stop();
if (scheduler) {
scheduler.stop();
}
await server.close();
if (vite) await vite.close();
console.log(`[${APP_NAME}] Server closed.`);
} catch (err) {
console.error(`[${APP_NAME}] Error closing server:`, err);
}
closeDatabase();
console.log(`[${APP_NAME}] Shutdown complete.`);
process.exit(0);
};
process.on('SIGINT', () => void shutdown('SIGINT'));
process.on('SIGTERM', () => void shutdown('SIGTERM'));
// 7. Start listening
try {
const address = await server.listen({
port: appConfig.port,
host: '0.0.0.0',
});
console.log(`[${APP_NAME}] Server listening on ${address}`);
} catch (err) {
console.error(`[${APP_NAME}] Failed to start server:`, err);
closeDatabase();
process.exit(1);
}
// 8. Start queue service and scheduler after server is listening
try {
const recovered = await queueService.recoverOnStartup();
if (recovered > 0) {
console.log(`[${APP_NAME}] Queue recovered ${recovered} interrupted item(s)`);
}
queueService.start();
console.log(`[${APP_NAME}] Queue service started`);
} catch (err) {
console.error(`[${APP_NAME}] Failed to start queue service:`, err);
// Non-fatal — server is still running
}
if (scheduler) {
try {
await scheduler.start();
} catch (err) {
console.error(`[${APP_NAME}] Failed to start scheduler:`, err);
// Non-fatal — server is still running, scheduler can be retried
}
}
}
main().catch((err) => {
console.error(`[${APP_NAME}] Fatal startup error:`, err);
closeDatabase();
process.exit(1);
});