diff --git a/client/src/App.svelte b/client/src/App.svelte index 1371287..ba452cd 100644 --- a/client/src/App.svelte +++ b/client/src/App.svelte @@ -8,10 +8,12 @@ import Trash from "./routes/Trash.svelte"; import Movies from "./routes/Movies.svelte"; import TvShows from "./routes/TvShows.svelte"; + import Music from "./routes/Music.svelte"; import Login from "./routes/Login.svelte"; import { API, getAccessToken } from "./utils/api.js"; import { refreshMovieCount } from "./stores/movieStore.js"; import { refreshTvShowCount } from "./stores/tvStore.js"; + import { refreshMusicCount } from "./stores/musicStore.js"; import { fetchTrashItems } from "./stores/trashStore.js"; const token = getAccessToken(); @@ -25,7 +27,12 @@ refreshTimer = setTimeout(async () => { refreshTimer = null; try { - await Promise.all([refreshMovieCount(), refreshTvShowCount(), fetchTrashItems()]); + await Promise.all([ + refreshMovieCount(), + refreshTvShowCount(), + refreshMusicCount(), + fetchTrashItems() + ]); } catch (err) { console.warn("Medya sayacı yenileme başarısız:", err); } @@ -46,6 +53,7 @@ if (token) { refreshMovieCount(); refreshTvShowCount(); + refreshMusicCount(); fetchTrashItems(); const authToken = getAccessToken(); if (authToken) { @@ -60,7 +68,11 @@ } else if ( msg.type === "progress" && Array.isArray(msg.torrents) && - msg.torrents.some((t) => Number(t.progress) >= 1) + msg.torrents.some( + (t) => + Number(t.progress) >= 1 || + (t.type && String(t.type).toLowerCase() === "youtube") + ) ) { scheduleMediaRefresh(); } @@ -105,6 +117,7 @@ + diff --git a/client/src/components/Sidebar.svelte b/client/src/components/Sidebar.svelte index 6381239..4eb8fd7 100644 --- a/client/src/components/Sidebar.svelte +++ b/client/src/components/Sidebar.svelte @@ -1,8 +1,9 @@ + +
+
+
+

Music

+ {#if !loading && !error} + {items.length} Songs + {/if} +
+ +
+ + {#if loading} +
Yükleniyor…
+ {:else if error} +
{error}
+ {:else if !items.length} +
Henüz müzik videosu yok.
+ {:else} +
+ {#each items as item, idx (item.id)} +
+
{String(idx + 1).padStart(2, "0")}
+
+ {#if thumbnailURL(item)} + {item.title} + {:else} +
+ +
+ {/if} +
+
+
{cleanFileName(item.title)}
+
{sourceLabel(item)}
+
+
+ {formatDuration(item.mediaInfo?.format?.duration || item.duration)} +
+ +
+ {/each} +
+ {/if} +
+ + diff --git a/client/src/stores/movieStore.js b/client/src/stores/movieStore.js index 8bd17ae..2060dd2 100644 --- a/client/src/stores/movieStore.js +++ b/client/src/stores/movieStore.js @@ -2,15 +2,41 @@ import { writable } from "svelte/store"; import { apiFetch } from "../utils/api.js"; export const movieCount = writable(0); +let requestSeq = 0; +let lastValue = 0; +let zeroTimer = null; export async function refreshMovieCount() { + const ticket = ++requestSeq; try { const resp = await apiFetch("/api/movies"); if (!resp.ok) throw new Error(`HTTP ${resp.status}`); const list = await resp.json(); - movieCount.set(Array.isArray(list) ? list.length : 0); + if (ticket !== requestSeq) return; + const nextVal = Array.isArray(list) ? list.length : 0; + if (nextVal > 0) { + if (zeroTimer) { + clearTimeout(zeroTimer); + zeroTimer = null; + } + lastValue = nextVal; + movieCount.set(nextVal); + } else if (lastValue > 0) { + if (zeroTimer) clearTimeout(zeroTimer); + const zeroTicket = requestSeq; + zeroTimer = setTimeout(() => { + if (zeroTicket === requestSeq) { + lastValue = 0; + movieCount.set(0); + } + zeroTimer = null; + }, 500); + } else { + lastValue = 0; + movieCount.set(0); + } } catch (err) { console.warn("⚠️ Movie count güncellenemedi:", err?.message || err); - movieCount.set(0); + // Hata durumunda mevcut değeri koru, titreşimi önle } } diff --git a/client/src/stores/musicStore.js b/client/src/stores/musicStore.js new file mode 100644 index 0000000..abe71f2 --- /dev/null +++ b/client/src/stores/musicStore.js @@ -0,0 +1,42 @@ +import { writable } from "svelte/store"; +import { apiFetch } from "../utils/api.js"; + +export const musicCount = writable(0); +let requestSeq = 0; +let lastValue = 0; +let zeroTimer = null; + +export async function refreshMusicCount() { + const ticket = ++requestSeq; + try { + const resp = await apiFetch("/api/music"); + if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + const list = await resp.json(); + if (ticket !== requestSeq) return; + const nextVal = Array.isArray(list) ? list.length : 0; + if (nextVal > 0) { + if (zeroTimer) { + clearTimeout(zeroTimer); + zeroTimer = null; + } + lastValue = nextVal; + musicCount.set(nextVal); + } else if (lastValue > 0) { + if (zeroTimer) clearTimeout(zeroTimer); + const zeroTicket = requestSeq; + zeroTimer = setTimeout(() => { + if (zeroTicket === requestSeq) { + lastValue = 0; + musicCount.set(0); + } + zeroTimer = null; + }, 500); + } else { + lastValue = 0; + musicCount.set(0); + } + } catch (err) { + console.warn("⚠️ Music count güncellenemedi:", err?.message || err); + // Hata durumunda mevcut değeri koru, titreşimi önle + } +} diff --git a/client/src/stores/tvStore.js b/client/src/stores/tvStore.js index 612fcab..4b3fbf9 100644 --- a/client/src/stores/tvStore.js +++ b/client/src/stores/tvStore.js @@ -2,15 +2,41 @@ import { writable } from "svelte/store"; import { apiFetch } from "../utils/api.js"; export const tvShowCount = writable(0); +let requestSeq = 0; +let lastValue = 0; +let zeroTimer = null; export async function refreshTvShowCount() { + const ticket = ++requestSeq; try { const resp = await apiFetch("/api/tvshows"); if (!resp.ok) throw new Error(`HTTP ${resp.status}`); const list = await resp.json(); - tvShowCount.set(Array.isArray(list) ? list.length : 0); + if (ticket !== requestSeq) return; + const nextVal = Array.isArray(list) ? list.length : 0; + if (nextVal > 0) { + if (zeroTimer) { + clearTimeout(zeroTimer); + zeroTimer = null; + } + lastValue = nextVal; + tvShowCount.set(nextVal); + } else if (lastValue > 0) { + if (zeroTimer) clearTimeout(zeroTimer); + const zeroTicket = requestSeq; + zeroTimer = setTimeout(() => { + if (zeroTicket === requestSeq) { + lastValue = 0; + tvShowCount.set(0); + } + zeroTimer = null; + }, 500); + } else { + lastValue = 0; + tvShowCount.set(0); + } } catch (err) { console.warn("⚠️ TV show count güncellenemedi:", err?.message || err); - tvShowCount.set(0); + // Hata durumunda mevcut değeri koru, titreşimi önle } } diff --git a/docs/api-documentation.md b/docs/api-documentation.md index a270a6b..ab1c2ff 100644 --- a/docs/api-documentation.md +++ b/docs/api-documentation.md @@ -650,4 +650,4 @@ curl -X GET http://localhost:3001/api/system/info --- -*This API documentation provides comprehensive coverage of all available endpoints, WebSocket events, and usage patterns. For implementation details and examples, refer to the cross-referenced files and documentation.* \ No newline at end of file +*This API documentation provides comprehensive coverage of all available endpoints, WebSocket events, and usage patterns. For implementation details and examples, refer to the cross-referenced files and documentation.* diff --git a/server/server.js b/server/server.js index 7bbb33b..b67c768 100644 --- a/server/server.js +++ b/server/server.js @@ -764,9 +764,10 @@ async function finalizeYoutubeJob(job, exitCode) { mediaInfo, infoJson ); - const payload = updateYoutubeThumbnail(job, metadataPayload) || metadataPayload; - const mediaType = payload?.type || "video"; - const categories = payload?.categories || null; + const payloadWithThumb = + updateYoutubeThumbnail(job, metadataPayload) || metadataPayload; + const mediaType = payloadWithThumb?.type || "video"; + const categories = payloadWithThumb?.categories || null; upsertInfoFile(job.savePath, { infoHash: job.id, name: job.title, @@ -5125,8 +5126,13 @@ app.get("/api/files", requireAuth, (req, res) => { const seriesEpisodeInfo = relWithinRoot ? info.seriesEpisodes?.[relWithinRoot] || null : null; - const mediaCategory = - fileMeta?.type || (relWithinRoot ? info.type : null) || null; + let mediaCategory = fileMeta?.type || null; + if (!mediaCategory) { + const canInheritFromInfo = !relWithinRoot || isVideo; + if (canInheritFromInfo && info.type) { + mediaCategory = info.type; + } + } const isPrimaryVideo = !!info.primaryVideoPath && info.primaryVideoPath === relWithinRoot; const displayName = entry.name; @@ -6078,6 +6084,89 @@ app.get("/api/tvshows", requireAuth, (req, res) => { } }); +function collectMusicEntries() { + const entries = []; + const dirEntries = fs + .readdirSync(DOWNLOAD_DIR, { withFileTypes: true }) + .filter((dirent) => dirent.isDirectory()); + + for (const dirent of dirEntries) { + const folder = sanitizeRelative(dirent.name); + if (!folder) continue; + // Klasörün tamamı çöpe taşınmışsa atla + if (isPathTrashed(folder, "", true)) continue; + const info = readInfoForRoot(folder) || {}; + const files = info.files || {}; + const fileKeys = Object.keys(files); + if (!fileKeys.length) continue; + + let targetPath = info.primaryVideoPath; + if (targetPath && files[targetPath]?.type !== "music") { + targetPath = null; + } + if (!targetPath) { + targetPath = + fileKeys.find((key) => files[key]?.type === "music") || fileKeys[0]; + } + if (!targetPath) continue; + const fileMeta = files[targetPath]; + // Hedef dosya çöpteyse atla + if (isPathTrashed(folder, targetPath, false)) continue; + const mediaType = fileMeta?.type || info.type || null; + if (mediaType !== "music") continue; + + const metadataPath = path.join(YT_DATA_ROOT, folder, "metadata.json"); + let metadata = null; + if (fs.existsSync(metadataPath)) { + try { + metadata = JSON.parse(fs.readFileSync(metadataPath, "utf-8")); + } catch (err) { + console.warn(`⚠️ YT metadata okunamadı (${metadataPath}): ${err.message}`); + } + } + + const keysArray = fileKeys; + const fileIndex = Math.max(keysArray.indexOf(targetPath), 0); + const infoHash = info.infoHash || folder; + const title = + info.name || metadata?.title || path.basename(targetPath) || folder; + const thumbnail = + metadata?.thumbnail || + (metadata ? `/yt-data/${folder}/thumbnail.jpg` : null); + + entries.push({ + id: `${folder}:${targetPath}`, + folder, + infoHash, + fileIndex, + filePath: targetPath, + title, + added: info.added || info.createdAt || null, + size: fileMeta?.size || 0, + url: + metadata?.url || + fileMeta?.youtube?.url || + fileMeta?.youtube?.videoId + ? `https://www.youtube.com/watch?v=${fileMeta.youtube.videoId}` + : null, + thumbnail, + categories: metadata?.categories || fileMeta?.categories || null + }); + } + entries.sort((a, b) => (b.added || 0) - (a.added || 0)); + return entries; +} + +app.get("/api/music", requireAuth, (req, res) => { + try { + const entries = collectMusicEntries(); + res.json(entries); + } catch (err) { + console.error("🎵 Music API error:", err); + res.status(500).json({ error: err.message }); + } +}); + async function rebuildTvMetadata({ clearCache = false } = {}) { if (!TVDB_API_KEY) { throw new Error("TVDB API erişimi için gerekli anahtar tanımlı değil."); @@ -6330,6 +6419,23 @@ app.get("/stream/:hash", requireAuth, (req, res) => { return streamLocalFile(absPath, range, res); } + const info = readInfoForRoot(req.params.hash); + if (info && info.files) { + const fileKeys = Object.keys(info.files); + if (fileKeys.length) { + const idx = Number(req.query.index) || 0; + const targetKey = fileKeys[idx] || fileKeys[0]; + const absPath = path.join( + DOWNLOAD_DIR, + req.params.hash, + targetKey.replace(/\\/g, "/") + ); + if (fs.existsSync(absPath)) { + return streamLocalFile(absPath, range, res); + } + } + } + return res.status(404).end(); });