- "src/server/routes/system.ts" - "src/server/index.ts" - "src/index.ts" - "src/__tests__/missing-scan-api.test.ts" GSD-Task: S06/T02
271 lines
9.8 KiB
TypeScript
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);
|
|
});
|