diff --git a/client/src/routes/Files.svelte b/client/src/routes/Files.svelte index 3a4897a..8c986da 100644 --- a/client/src/routes/Files.svelte +++ b/client/src/routes/Files.svelte @@ -85,6 +85,7 @@ return { ...file, isDirectory, + mediaCategory: file.mediaCategory || null, hiddenRoot, originalSegments, displaySegments, @@ -97,7 +98,13 @@ function buildDirectoryEntries(fileList) { const directories = new Map(); - const ensureDirectoryEntry = (key, displayName, parentDisplayPath, originalPath) => { + const ensureDirectoryEntry = ( + key, + displayName, + parentDisplayPath, + originalPath, + mediaCategory + ) => { if (!key) return; if (!directories.has(key)) { directories.set(key, { @@ -108,6 +115,7 @@ parentDisplayPath, originalPaths: new Set(), isDirectory: true, + mediaCategory: mediaCategory || null }); } if (originalPath) { @@ -128,7 +136,13 @@ const displayPath = segments.join("/"); const parentDisplayPath = segments.slice(0, -1).join("/"); const displayName = file.displayName || segments[segments.length - 1] || displayPath; - ensureDirectoryEntry(displayPath, displayName, parentDisplayPath, fullOriginalPath); + ensureDirectoryEntry( + displayPath, + displayName, + parentDisplayPath, + fullOriginalPath, + file.mediaCategory + ); } if (segments.length <= 1) continue; @@ -2189,16 +2203,18 @@
{/if} diff --git a/server/server.js b/server/server.js index 0716c85..7bbb33b 100644 --- a/server/server.js +++ b/server/server.js @@ -43,6 +43,17 @@ const IMAGE_THUMB_ROOT = path.join(THUMBNAIL_DIR, "images"); const MOVIE_DATA_ROOT = path.join(CACHE_DIR, "movie_data"); const TV_DATA_ROOT = path.join(CACHE_DIR, "tv_data"); const YT_DATA_ROOT = path.join(CACHE_DIR, "yt_data"); +const MUSIC_EXTENSIONS = new Set([ + ".mp3", + ".m4a", + ".aac", + ".flac", + ".wav", + ".ogg", + ".oga", + ".opus", + ".mka" +]); for (const dir of [ THUMBNAIL_DIR, @@ -494,6 +505,28 @@ function sanitizeRelative(relPath) { return relPath.replace(/^[\\/]+/, ""); } +function determineMediaType({ + tracker, + movieMatch, + seriesEpisode, + categories, + relPath +}) { + if (seriesEpisode) return "tv"; + if (movieMatch) return "movie"; + if ( + Array.isArray(categories) && + categories.some((cat) => String(cat).toLowerCase() === "music") + ) { + return "music"; + } + if (relPath) { + const ext = path.extname(relPath).toLowerCase(); + if (MUSIC_EXTENSIONS.has(ext)) return "music"; + } + return "video"; +} + function getYtDlpBinary() { if (resolvedYtDlpBinary) return resolvedYtDlpBinary; const candidates = [ @@ -583,6 +616,7 @@ function launchYoutubeJob(job) { "--write-thumbnail", "--convert-thumbnails", "jpg", + "--write-info-json", job.url ]; const child = spawn(binary, args, { @@ -697,6 +731,7 @@ async function finalizeYoutubeJob(job, exitCode) { job.currentStage.done = true; } + const infoJson = findYoutubeInfoJson(job.savePath); const videoFile = findYoutubeVideoFile(job.savePath); if (!videoFile) { job.state = "error"; @@ -723,14 +758,23 @@ async function finalizeYoutubeJob(job, exitCode) { job.progress = 1; job.state = "completed"; - await writeYoutubeMetadata(job, absVideo, mediaInfo); - updateYoutubeThumbnail(job); + const metadataPayload = await writeYoutubeMetadata( + job, + absVideo, + mediaInfo, + infoJson + ); + const payload = updateYoutubeThumbnail(job, metadataPayload) || metadataPayload; + const mediaType = payload?.type || "video"; + const categories = payload?.categories || null; upsertInfoFile(job.savePath, { infoHash: job.id, name: job.title, tracker: "youtube", added: job.added, folder: job.folderId, + type: mediaType, + categories, files: { [relativeName]: { size: stats.size, @@ -739,7 +783,9 @@ async function finalizeYoutubeJob(job, exitCode) { youtube: { url: job.url, videoId: job.videoId - } + }, + categories, + type: mediaType } }, primaryVideoPath: relativeName, @@ -771,6 +817,17 @@ function findYoutubeVideoFile(savePath) { return videos[0]; } +function findYoutubeInfoJson(savePath) { + const entries = fs.readdirSync(savePath, { withFileTypes: true }); + const jsons = entries + .filter((entry) => + entry.isFile() && entry.name.toLowerCase().endsWith(".info.json") + ) + .map((entry) => entry.name) + .sort(); + return jsons[0] || null; +} + function deriveYoutubeTitle(fileName, videoId) { const base = fileName.replace(path.extname(fileName), ""); const pattern = videoId ? new RegExp(`\\[${videoId}\\]`, "i") : null; @@ -778,9 +835,29 @@ function deriveYoutubeTitle(fileName, videoId) { return cleaned.replace(/[-_.]+$/g, "").trim() || base; } -async function writeYoutubeMetadata(job, videoPath, mediaInfo) { +async function writeYoutubeMetadata(job, videoPath, mediaInfo, infoJsonFile) { const targetDir = path.join(YT_DATA_ROOT, job.folderId); fs.mkdirSync(targetDir, { recursive: true }); + let infoJson = null; + if (infoJsonFile) { + try { + infoJson = JSON.parse( + fs.readFileSync(path.join(job.savePath, infoJsonFile), "utf-8") + ); + } catch (err) { + console.warn("YouTube info.json okunamadı:", err.message); + } + } + const categories = Array.isArray(infoJson?.categories) + ? infoJson.categories + : null; + const derivedType = determineMediaType({ + tracker: "youtube", + movieMatch: null, + seriesEpisode: null, + categories, + relPath: job.files?.[0]?.name || null + }); const payload = { id: job.id, title: job.title, @@ -789,16 +866,20 @@ async function writeYoutubeMetadata(job, videoPath, mediaInfo) { added: job.added, folderId: job.folderId, file: job.files?.[0]?.name || null, - mediaInfo + mediaInfo, + type: derivedType, + categories, + ytMeta: infoJson || null }; fs.writeFileSync( path.join(targetDir, "metadata.json"), JSON.stringify(payload, null, 2), "utf-8" ); + return payload; } -function updateYoutubeThumbnail(job) { +function updateYoutubeThumbnail(job, metadataPayload = null) { const thumbs = fs .readdirSync(job.savePath, { withFileTypes: true }) .filter((entry) => entry.isFile() && entry.name.toLowerCase().endsWith(".jpg")); @@ -813,6 +894,21 @@ function updateYoutubeThumbnail(job) { } catch (err) { console.warn("Thumbnail kopyalanamadı:", err.message); } + try { + const metaPath = path.join(YT_DATA_ROOT, job.folderId, "metadata.json"); + let payload = metadataPayload; + if (!payload && fs.existsSync(metaPath)) { + payload = JSON.parse(fs.readFileSync(metaPath, "utf-8")); + } + if (payload) { + payload.thumbnail = job.thumbnail; + fs.writeFileSync(metaPath, JSON.stringify(payload, null, 2), "utf-8"); + return payload; + } + } catch (err) { + console.warn("YT metadata güncellenemedi:", err.message); + } + return metadataPayload || null; } function removeYoutubeJob(jobId, { removeFiles = true } = {}) { @@ -3975,7 +4071,14 @@ async function onTorrentDone({ torrent }) { size: file.length, extension: ext || null, mimeType, - mediaInfo: metaInfo + mediaInfo: metaInfo, + type: determineMediaType({ + tracker: torrent.announce?.[0] || null, + movieMatch: null, + seriesEpisode: null, + categories: null, + relPath: normalizedRelPath + }) }; const seriesInfo = parseSeriesInfo(file.name); @@ -4024,6 +4127,13 @@ async function onTorrentDone({ torrent }) { matchedAt: Date.now() } }; + perFileMetadata[normalizedRelPath].type = determineMediaType({ + tracker: torrent.announce?.[0] || null, + movieMatch: null, + seriesEpisode: seriesEpisodes[normalizedRelPath], + categories: null, + relPath: normalizedRelPath + }); } } catch (err) { console.warn( @@ -4072,8 +4182,8 @@ async function onTorrentDone({ torrent }) { infoUpdate.files[bestVideoPath] = { ...entry, movieMatch: ensuredMedia.metadata - ? { - id: ensuredMedia.metadata.id ?? null, + ? { + id: ensuredMedia.metadata.id ?? null, title: ensuredMedia.metadata.title || ensuredMedia.metadata.matched_title || @@ -4086,12 +4196,24 @@ async function onTorrentDone({ torrent }) { poster: ensuredMedia.metadata.poster_path || null, backdrop: ensuredMedia.metadata.backdrop_path || null, cacheKey: ensuredMedia.cacheKey || null, - matchedAt: Date.now() - } - : entry.movieMatch - }; - } + matchedAt: Date.now() + } + : entry.movieMatch + }; + const movieType = determineMediaType({ + tracker: torrent.announce?.[0] || null, + movieMatch: ensuredMedia.metadata, + seriesEpisode: seriesEpisodes[bestVideoPath] || null, + categories: null, + relPath: bestVideoPath + }); + perFileMetadata[bestVideoPath] = { + ...(perFileMetadata[bestVideoPath] || {}), + type: movieType + }; + infoUpdate.files[bestVideoPath].type = movieType; } +} upsertInfoFile(entry.savePath, infoUpdate); broadcastFileUpdate(rootFolder); @@ -4112,6 +4234,19 @@ async function onTorrentDone({ torrent }) { } } + if (bestVideoPath && infoUpdate.files && infoUpdate.files[bestVideoPath]) { + const rootType = determineMediaType({ + tracker: torrent.announce?.[0] || null, + movieMatch: infoUpdate.files[bestVideoPath].movieMatch || null, + seriesEpisode: seriesEpisodes[bestVideoPath] || null, + categories: null, + relPath: bestVideoPath + }); + infoUpdate.type = rootType; + infoUpdate.files[bestVideoPath].type = + infoUpdate.files[bestVideoPath].type || rootType; + } + broadcastSnapshot(); } @@ -4929,6 +5064,7 @@ app.get("/api/files", requireAuth, (req, res) => { tracker, torrentName, infoHash, + mediaCategory: dirInfo.type || null, extension: null, mediaInfo: null, primaryVideoPath: null, @@ -4989,6 +5125,8 @@ app.get("/api/files", requireAuth, (req, res) => { const seriesEpisodeInfo = relWithinRoot ? info.seriesEpisodes?.[relWithinRoot] || null : null; + const mediaCategory = + fileMeta?.type || (relWithinRoot ? info.type : null) || null; const isPrimaryVideo = !!info.primaryVideoPath && info.primaryVideoPath === relWithinRoot; const displayName = entry.name; @@ -5006,6 +5144,7 @@ app.get("/api/files", requireAuth, (req, res) => { tracker, torrentName, infoHash, + mediaCategory, extension: extensionForFile, mediaInfo: mediaInfoForFile, primaryVideoPath: info.primaryVideoPath || null,