diff --git a/server/server.js b/server/server.js index edcf223..c046648 100644 --- a/server/server.js +++ b/server/server.js @@ -76,6 +76,16 @@ const YT_COOKIES_PATH = process.env.YT_DLP_COOKIES || process.env.YT_DLP_COOKIE_FILE || path.join(CACHE_DIR, "yt_cookies.txt"); +const YT_SETTINGS_PATH = path.join(CACHE_DIR, "yt_settings.json"); +const YT_DEFAULT_RESOLUTION = "1080p"; +const YT_ALLOWED_RESOLUTIONS = new Set([ + "1080p", + "720p", + "480p", + "360p", + "240p", + "144p" +]); const YT_EXTRACTOR_ARGS = process.env.YT_DLP_EXTRACTOR_ARGS || null; let resolvedYtDlpBinary = null; @@ -625,8 +635,10 @@ function determineMediaType({ movieMatch, seriesEpisode, categories, - relPath + relPath, + audioOnly = false }) { + if (audioOnly) return "music"; if (seriesEpisode) return "tv"; if (movieMatch) return "movie"; if ( @@ -686,6 +698,7 @@ function normalizeYoutubeWatchUrl(value) { function startYoutubeDownload(url) { const normalized = normalizeYoutubeWatchUrl(url); if (!normalized) return null; + const ytSettings = loadYoutubeSettings(); const videoId = new URL(normalized).searchParams.get("v"); const folderId = `yt_${videoId}_${Date.now().toString(36)}`; const savePath = path.join(DOWNLOAD_DIR, folderId); @@ -716,6 +729,8 @@ function startYoutubeDownload(url) { error: null, debug: { binary: null, args: null, logs: [] } }; + job.resolution = ytSettings.resolution; + job.onlyAudio = ytSettings.onlyAudio; youtubeJobs.set(job.id, job); launchYoutubeJob(job); @@ -738,9 +753,49 @@ function appendYoutubeLog(job, line) { job.debug.logs = lines; } +function loadYoutubeSettings() { + const defaults = { + resolution: YT_DEFAULT_RESOLUTION, + onlyAudio: false + }; + + try { + if (!fs.existsSync(YT_SETTINGS_PATH)) return defaults; + const raw = fs.readFileSync(YT_SETTINGS_PATH, "utf-8"); + const parsed = JSON.parse(raw); + const resolution = YT_ALLOWED_RESOLUTIONS.has(parsed?.resolution) + ? parsed.resolution + : YT_DEFAULT_RESOLUTION; + const onlyAudio = Boolean(parsed?.onlyAudio); + return { resolution, onlyAudio }; + } catch (err) { + console.warn("⚠️ YouTube ayarları okunamadı, varsayılan kullanılacak:", err.message); + return defaults; + } +} + +function saveYoutubeSettings({ resolution, onlyAudio }) { + const resValue = YT_ALLOWED_RESOLUTIONS.has(resolution) + ? resolution + : YT_DEFAULT_RESOLUTION; + const settings = { resolution: resValue, onlyAudio: Boolean(onlyAudio) }; + ensureDirForFile(YT_SETTINGS_PATH); + fs.writeFileSync(YT_SETTINGS_PATH, JSON.stringify(settings, null, 2), "utf-8"); + return settings; +} + +function buildYoutubeFormat({ resolution, onlyAudio }) { + if (onlyAudio) return "bestaudio/b"; + const match = String(resolution || YT_DEFAULT_RESOLUTION).match(/(\\d+)/); + const height = match ? Number(match[1]) : 1080; + const safeHeight = Number.isFinite(height) && height > 0 ? height : 1080; + return `bestvideo[height<=${safeHeight}]+bestaudio/best[height<=${safeHeight}]`; +} + function launchYoutubeJob(job) { const binary = getYtDlpBinary(); const jsRuntimeArg = process.env.YT_DLP_JS_RUNTIME || "node"; + const ytSettings = loadYoutubeSettings(); const cookieFile = (YT_COOKIES_PATH && fs.existsSync(YT_COOKIES_PATH) && YT_COOKIES_PATH) || @@ -750,9 +805,11 @@ function launchYoutubeJob(job) { YT_EXTRACTOR_ARGS || (cookieFile ? "youtube:player-client=web" : "youtube:player-client=android"); + const formatSelector = buildYoutubeFormat(ytSettings); + const args = [ "-f", - "bv+ba/b", + formatSelector, "--write-thumbnail", "--convert-thumbnails", "jpg", @@ -772,7 +829,10 @@ function launchYoutubeJob(job) { logs: [], jsRuntime: jsRuntimeArg, cookies: cookieFile, - extractorArgs: extractorArgValue + extractorArgs: extractorArgValue, + resolution: ytSettings.resolution, + onlyAudio: ytSettings.onlyAudio, + format: formatSelector }; const child = spawn(binary, args, { cwd: job.savePath, @@ -1015,6 +1075,23 @@ function deriveYoutubeTitle(fileName, videoId) { return cleaned.replace(/[-_.]+$/g, "").trim() || base; } +function isYoutubeMusic(infoJson, mediaInfo, audioOnlyFlag = false) { + if (audioOnlyFlag) return true; + const categories = Array.isArray(infoJson?.categories) + ? infoJson.categories.map((c) => String(c).toLowerCase()) + : []; + if (categories.includes("music")) return true; + + const tags = Array.isArray(infoJson?.tags) + ? infoJson.tags.map((t) => String(t).toLowerCase()) + : []; + if (tags.some((t) => t.includes("music"))) return true; + + // Sadece ses akışı varsa müzik kabul et + if (!mediaInfo?.video && mediaInfo?.audio) return true; + return false; +} + async function writeYoutubeMetadata(job, videoPath, mediaInfo, infoJsonFile) { const targetDir = path.join(YT_DATA_ROOT, job.folderId); fs.mkdirSync(targetDir, { recursive: true }); @@ -1031,12 +1108,14 @@ async function writeYoutubeMetadata(job, videoPath, mediaInfo, infoJsonFile) { const categories = Array.isArray(infoJson?.categories) ? infoJson.categories : null; + const isAudioOnly = isYoutubeMusic(infoJson, mediaInfo, Boolean(job.onlyAudio)); const derivedType = determineMediaType({ tracker: "youtube", movieMatch: null, seriesEpisode: null, categories, - relPath: job.files?.[0]?.name || null + relPath: job.files?.[0]?.name || null, + audioOnly: isAudioOnly }); const payload = { id: job.id, @@ -4291,7 +4370,8 @@ async function onTorrentDone({ torrent }) { movieMatch: null, seriesEpisode: null, categories: null, - relPath: normalizedRelPath + relPath: normalizedRelPath, + audioOnly: false }) }; @@ -4346,7 +4426,8 @@ async function onTorrentDone({ torrent }) { movieMatch: null, seriesEpisode: seriesEpisodes[normalizedRelPath], categories: null, - relPath: normalizedRelPath + relPath: normalizedRelPath, + audioOnly: false }); } } catch (err) { @@ -4419,7 +4500,8 @@ async function onTorrentDone({ torrent }) { movieMatch: ensuredMedia.metadata, seriesEpisode: seriesEpisodes[bestVideoPath] || null, categories: null, - relPath: bestVideoPath + relPath: bestVideoPath, + audioOnly: false }); perFileMetadata[bestVideoPath] = { ...(perFileMetadata[bestVideoPath] || {}), @@ -4454,7 +4536,8 @@ async function onTorrentDone({ torrent }) { movieMatch: infoUpdate.files[bestVideoPath].movieMatch || null, seriesEpisode: seriesEpisodes[bestVideoPath] || null, categories: null, - relPath: bestVideoPath + relPath: bestVideoPath, + audioOnly: false }); infoUpdate.type = rootType; infoUpdate.files[bestVideoPath].type = @@ -5920,6 +6003,41 @@ app.post("/api/youtube/cookies", requireAuth, (req, res) => { } }); +// --- 🎚️ YouTube kalite ayarları --- +app.get("/api/youtube/settings", requireAuth, (req, res) => { + try { + const settings = loadYoutubeSettings(); + let updatedAt = null; + if (fs.existsSync(YT_SETTINGS_PATH)) { + updatedAt = fs.statSync(YT_SETTINGS_PATH).mtimeMs; + } + res.json({ ok: true, ...settings, updatedAt }); + } catch (err) { + console.warn("⚠️ YouTube ayarları okunamadı:", err.message); + res.status(500).json({ error: "Ayarlar okunamadı." }); + } +}); + +app.post("/api/youtube/settings", requireAuth, (req, res) => { + try { + const { resolution, onlyAudio } = req.body || {}; + if (resolution && !YT_ALLOWED_RESOLUTIONS.has(resolution)) { + return res.status(400).json({ error: "Geçersiz çözünürlük." }); + } + const saved = saveYoutubeSettings({ + resolution: resolution || YT_DEFAULT_RESOLUTION, + onlyAudio: Boolean(onlyAudio) + }); + const stat = fs.existsSync(YT_SETTINGS_PATH) + ? fs.statSync(YT_SETTINGS_PATH).mtimeMs + : Date.now(); + res.json({ ok: true, ...saved, updatedAt: stat }); + } catch (err) { + console.error("❌ YouTube ayarları kaydedilemedi:", err.message); + res.status(500).json({ error: "Ayarlar kaydedilemedi." }); + } +}); + // --- 🎫 YouTube cookies yönetimi --- app.get("/api/youtube/cookies", requireAuth, (req, res) => { try {