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 { 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); });