diff --git a/client.js b/client.js index f2c5de7..5245031 100644 --- a/client.js +++ b/client.js @@ -2772,14 +2772,9 @@ try { moreCommentsBtn.textContent = '⏳ Y\u00fckleniyor...'; } - var fetchUrl = - '/api-custom/youtube-comments?id=' + - encodeURIComponent(videoId) + - (token ? '&token=' + encodeURIComponent(token) : ''); - fetch(fetchUrl) - .then(function (r) { - return r.json(); - }) + var commentPath = + '/youtube-comments?id=' + encodeURIComponent(videoId) + (token ? '&token=' + encodeURIComponent(token) : ''); + fetchPluginJson(commentPath) .then(function (res) { if (!token) { commentsList.innerHTML = ''; @@ -2871,6 +2866,42 @@ try { } catch (e) {} } + // --- Universal Plugin API Fetcher (Works on ALL PeerTube instances via /plugins/yaktube-ondemand/router/... without needing bridge.js) --- + var preferredApiPrefix = null; + + function fetchPluginJson(endpointPath, options) { + var cleanPath = endpointPath.indexOf('/') === 0 ? endpointPath : '/' + endpointPath; + var pluginUrl = '/plugins/yaktube-ondemand/router' + cleanPath; + var customUrl = '/api-custom' + cleanPath; + + if (!preferredApiPrefix) { + preferredApiPrefix = isOfficialYakTubeServer() ? '/api-custom' : '/plugins/yaktube-ondemand/router'; + } + + var primaryUrl = preferredApiPrefix + cleanPath; + var fallbackUrl = primaryUrl === customUrl ? pluginUrl : customUrl; + + return fetch(primaryUrl, options) + .then(function (res) { + var ct = (res.headers && res.headers.get && res.headers.get('content-type')) || ''; + if (ct.indexOf('application/json') !== -1) { + return res.json(); + } + throw new Error('Non-JSON response'); + }) + .catch(function (err) { + if (err && err.name === 'AbortError') throw err; + return fetch(fallbackUrl, options).then(function (res2) { + var ct2 = (res2.headers && res2.headers.get && res2.headers.get('content-type')) || ''; + if (ct2.indexOf('application/json') !== -1) { + preferredApiPrefix = fallbackUrl === pluginUrl ? '/plugins/yaktube-ondemand/router' : '/api-custom'; + return res2.json(); + } + throw new Error('Backend endpoint unreachable'); + }); + }); + } + // --- Guest Video Import Quota & PeerTube Login Redirect Engine --- var cachedGuestImportLimit = null; @@ -2878,10 +2909,7 @@ try { if (cachedGuestImportLimit !== null) { return Promise.resolve(cachedGuestImportLimit); } - return fetch('/api-custom/quota-config') - .then(function (r) { - return r.json(); - }) + return fetchPluginJson('/quota-config') .then(function (d) { if (d && typeof d.guestImportLimit === 'number') { cachedGuestImportLimit = d.guestImportLimit; @@ -2890,20 +2918,7 @@ try { return 5; }) .catch(function () { - return fetch('/plugins/yaktube-ondemand/router/quota-config') - .then(function (r2) { - return r2.json(); - }) - .then(function (d2) { - if (d2 && typeof d2.guestImportLimit === 'number') { - cachedGuestImportLimit = d2.guestImportLimit; - return cachedGuestImportLimit; - } - return 5; - }) - .catch(function () { - return 5; - }); + return 5; }); } @@ -2984,7 +2999,6 @@ try { cfg.plugin.registeredExternalAuths.length === 1 ) { var ext = cfg.plugin.registeredExternalAuths[0]; - var npmName = ext.npmName || 'peertube-plugin-' + ext.name; window.location.href = '/plugins/' + ext.name + '/' + ext.version + '/auth/' + ext.authName; return; } @@ -3084,7 +3098,7 @@ try { } } catch (e) {} - fetch('/api-custom/ondemand-import', { + fetchPluginJson('/ondemand-import', { method: 'POST', headers: headers, body: JSON.stringify({ @@ -3094,9 +3108,6 @@ try { guestImportCount: currentGuestCount }) }) - .then(function (res) { - return res.json(); - }) .then(function (data) { if (data && data.quotaExceeded) { hideModal(); @@ -3123,10 +3134,7 @@ try { var attempts = 0; var pollInterval = setInterval(function () { attempts++; - fetch('/api-custom/status/' + videoId) - .then(function (sRes) { - return sRes.json(); - }) + fetchPluginJson('/status/' + videoId) .then(function (sData) { if (sData.isImporting) { showModal( @@ -3301,15 +3309,11 @@ try { var fetchOpts = abortCtrl ? { signal: abortCtrl.signal } : {}; Promise.all([ - fetch('/api-custom/youtube-search?q=' + encodeURIComponent(query), fetchOpts) - .then(function (res) { - return res.json(); - }) - .catch(function (err) { - if (err && err.name === 'AbortError') return { results: [] }; - console.warn('[YakTube] YouTube search fetch error:', err); - return { results: [] }; - }), + fetchPluginJson('/youtube-search?q=' + encodeURIComponent(query), fetchOpts).catch(function (err) { + if (err && err.name === 'AbortError') return { results: [] }; + console.warn('[YakTube] YouTube search fetch error:', err); + return { results: [] }; + }), fetch('/api/v1/search/videos?search=' + encodeURIComponent(query) + '&isLocal=true&count=12', fetchOpts) .then(function (res) { return res.json(); @@ -3712,7 +3716,7 @@ try { if (username) headers['X-User-Username'] = username; var authUrl = 'https://developer-console.yakhub.com.tr/api/yaktube/search-history' + (queryParams || ''); - var bridgeUrl = '/api-custom/search-history' + (queryParams || ''); + var historyPath = '/search-history' + (queryParams || ''); var options = { method: method, @@ -3721,6 +3725,17 @@ try { }; if (body) options.body = JSON.stringify(body); + if (!isOfficialYakTubeServer()) { + fetchPluginJson(historyPath, options) + .then(function (data) { + if (typeof onSuccess === 'function') onSuccess(data); + }) + .catch(function (e) { + if (typeof onError === 'function') onError(e); + }); + return; + } + fetch(authUrl, options) .then(function (res) { if (!res.ok) throw new Error('Auth server status ' + res.status); @@ -3730,11 +3745,7 @@ try { if (typeof onSuccess === 'function') onSuccess(data); }) .catch(function () { - // Fallback to local PeerTube bridge proxy - fetch(bridgeUrl, options) - .then(function (res) { - return res.json(); - }) + fetchPluginJson(historyPath, options) .then(function (data) { if (typeof onSuccess === 'function') onSuccess(data); }) @@ -4592,10 +4603,7 @@ try { announce(searchQuery + ' için ilgili video önerileri yükleniyor.'); - fetch('/api-custom/youtube-search?q=' + encodeURIComponent(searchQuery)) - .then(function (res) { - return res.json(); - }) + fetchPluginJson('/youtube-search?q=' + encodeURIComponent(searchQuery)) .then(function (data) { if (data && typeof data.guestImportLimit === 'number') { cachedGuestImportLimit = data.guestImportLimit; @@ -4673,10 +4681,7 @@ try { document.querySelector('my-video-comments, .video-comments, my-video-watch, .video-bottom') || document.body; if (!mountTarget) return; - fetch('/api-custom/video-origin/' + encodeURIComponent(uuid)) - .then(function (r) { - return r.json(); - }) + fetchPluginJson('/video-origin/' + encodeURIComponent(uuid)) .then(function (originData) { if (!originData || !originData.ok || !originData.hasYouTubeOrigin || !originData.youtubeId) { var old = document.getElementById('yaktube-watch-comments-panel'); @@ -4742,14 +4747,11 @@ try { moreBtn.textContent = '⏳ Y\u00fckleniyor...'; } - var fUrl = - '/api-custom/youtube-comments?id=' + + var fPath = + '/youtube-comments?id=' + encodeURIComponent(youtubeId) + (token ? '&token=' + encodeURIComponent(token) : ''); - fetch(fUrl) - .then(function (r) { - return r.json(); - }) + fetchPluginJson(fPath) .then(function (res) { if (!token) { listContainer.innerHTML = ''; diff --git a/main.js b/main.js index bf8fa57..adb8585 100644 --- a/main.js +++ b/main.js @@ -1,24 +1,28 @@ const { execFile } = require('child_process'); const path = require('path'); const http = require('http'); +const https = require('https'); const fs = require('fs'); +const crypto = require('crypto'); const searchCache = new Map(); const CACHE_TTL = 30 * 60 * 1000; // 30 minutes let cachedYtDlpPath = null; -function resolveYtDlpPath(logger) { +function resolveYtDlpPath(logger, customPath) { + if (customPath && String(customPath).trim() && fs.existsSync(String(customPath).trim())) { + return String(customPath).trim(); + } + if (cachedYtDlpPath && (cachedYtDlpPath === 'yt-dlp' || fs.existsSync(cachedYtDlpPath))) { return cachedYtDlpPath; } - // 1. Environment variable override if (process.env.YTDLP_PATH && fs.existsSync(process.env.YTDLP_PATH)) { cachedYtDlpPath = process.env.YTDLP_PATH; return cachedYtDlpPath; } - // 2. Cross-platform candidates const isWin = process.platform === 'win32'; const candidates = isWin ? [ @@ -43,53 +47,6 @@ function resolveYtDlpPath(logger) { return cachedYtDlpPath; } -let cachedChannels = null; -let channelsExpiresAt = 0; - -async function getDefaultChannelId(category) { - // Category mapping defaults - const cat = String(category || '').toLowerCase(); - if (cat.includes('tekno') || cat.includes('tech') || cat.includes('egitim')) return '3299'; - if (cat.includes('podcast') || cat.includes('sohbet')) return '3298'; - if (cat.includes('genel') || cat.includes('haber')) return '1'; - if (cat.includes('muzik') || cat.includes('music')) return '1146'; - - if (cachedChannels && Date.now() < channelsExpiresAt) { - return cachedChannels.defaultId || '1146'; - } - - return new Promise(resolve => { - const req = http.request( - { - hostname: '127.0.0.1', - port: 9000, - path: '/api/v1/accounts/yaktube/video-channels', - method: 'GET', - headers: { Host: 'yaktube.yakhub.com.tr' } - }, - res => { - let body = ''; - res.on('data', c => (body += c)); - res.on('end', () => { - try { - const data = JSON.parse(body); - if (data && Array.isArray(data.data) && data.data.length > 0) { - const musicCh = data.data.find(c => c.name && c.name.includes('muzik')); - const defaultId = musicCh ? String(musicCh.id) : String(data.data[0].id); - cachedChannels = { defaultId, list: data.data }; - channelsExpiresAt = Date.now() + 60 * 60 * 1000; - return resolve(defaultId); - } - } catch (e) {} - resolve('1146'); - }); - } - ); - req.on('error', () => resolve('1146')); - req.end(); - }); -} - function getCached(q) { const item = searchCache.get(q); if (item && Date.now() - item.time < CACHE_TTL) { @@ -106,71 +63,26 @@ function setCache(q, data) { } } -let cachedAdminToken = null; -let tokenExpiresAt = 0; - -async function getAdminToken() { - if (cachedAdminToken && Date.now() < tokenExpiresAt) { - return cachedAdminToken; - } - +function postInnerTubeJSON(url, data) { return new Promise((resolve, reject) => { - const req = http.request( + const postData = JSON.stringify(data); + const req = https.request( + url, { - hostname: '127.0.0.1', - port: 9000, - path: '/api/v1/oauth-clients/local', - method: 'GET', - headers: { Host: 'yaktube.yakhub.com.tr' } + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(postData), + 'User-Agent': + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36' + } }, res => { let body = ''; res.on('data', chunk => (body += chunk)); res.on('end', () => { try { - const oauth = JSON.parse(body); - const postData = new URLSearchParams({ - client_id: oauth.client_id, - client_secret: oauth.client_secret, - grant_type: 'password', - username: 'yaktube', - password: 'YakTubeAdmin2026!' - }).toString(); - - const tokenReq = http.request( - { - hostname: '127.0.0.1', - port: 9000, - path: '/api/v1/users/token', - method: 'POST', - headers: { - Host: 'yaktube.yakhub.com.tr', - 'Content-Type': 'application/x-www-form-urlencoded', - 'Content-Length': Buffer.byteLength(postData) - } - }, - tokenRes => { - let tokenBody = ''; - tokenRes.on('data', c => (tokenBody += c)); - tokenRes.on('end', () => { - try { - const tData = JSON.parse(tokenBody); - if (tData.access_token) { - cachedAdminToken = tData.access_token; - tokenExpiresAt = Date.now() + 3600 * 1000; - resolve(cachedAdminToken); - } else { - reject(new Error('No access_token: ' + tokenBody)); - } - } catch (e) { - reject(e); - } - }); - } - ); - tokenReq.on('error', reject); - tokenReq.write(postData); - tokenReq.end(); + resolve(JSON.parse(body)); } catch (e) { reject(e); } @@ -178,15 +90,17 @@ async function getAdminToken() { } ); req.on('error', reject); + req.write(postData); req.end(); }); } async function register({ getRouter, registerSetting, settingsManager, peertubeHelpers, logger }) { const router = getRouter(); - const ytdlpPath = resolveYtDlpPath(logger); let guestImportLimit = 5; + let customChannelId = ''; + let customYtDlpPath = ''; if (typeof registerSetting === 'function') { registerSetting({ @@ -198,6 +112,26 @@ async function register({ getRouter, registerSetting, settingsManager, peertubeH private: false, default: '5' }); + + registerSetting({ + name: 'default-channel-id', + label: 'Varsayılan Video Kanalı ID (Opsiyonel)', + type: 'input', + description: + 'İndirilen videoların yükleneceği kanal ID numarası. Boş bırakılırsa sunucu yöneticisinin (Admin) ilk video kanalı otomatik seçilir.', + private: false, + default: '' + }); + + registerSetting({ + name: 'ytdlp-binary-path', + label: 'yt-dlp Çalıştırılabilir Dosya Yolu (Opsiyonel)', + type: 'input', + description: + 'Özel yt-dlp dosya yolu (Örn: /usr/local/bin/yt-dlp). Boş bırakılırsa sistemde otomatik tespit edilir.', + private: true, + default: '' + }); } async function loadPluginSettings() { @@ -211,6 +145,16 @@ async function register({ getRouter, registerSetting, settingsManager, peertubeH guestImportLimit = 5; } } catch (e) {} + + try { + const chVal = await settingsManager.getSetting('default-channel-id'); + customChannelId = chVal ? String(chVal).trim() : ''; + } catch (e) {} + + try { + const binVal = await settingsManager.getSetting('ytdlp-binary-path'); + customYtDlpPath = binVal ? String(binVal).trim() : ''; + } catch (e) {} } await loadPluginSettings(); @@ -218,11 +162,257 @@ async function register({ getRouter, registerSetting, settingsManager, peertubeH settingsManager.onSettingsChange(loadPluginSettings); } - router.get('/quota-config', (_req, res) => { + // Helper to run SQL queries via PeerTube's built-in Sequelize instance (works on ANY PeerTube server without bridge.js) + async function dbQuery(sql, bindParams = []) { + if (!peertubeHelpers || !peertubeHelpers.database || typeof peertubeHelpers.database.query !== 'function') { + return []; + } + const res = await peertubeHelpers.database.query(sql, { + bind: bindParams + }); + if (Array.isArray(res) && Array.isArray(res[0])) { + return res[0]; + } + return Array.isArray(res) ? res : []; + } + + // Helper to get local PeerTube HTTP connection details dynamically on any PeerTube instance + function getLocalServerTarget() { + let port = 9000; + let hostHeader = 'localhost'; + try { + if (peertubeHelpers && peertubeHelpers.config) { + const listenCfg = peertubeHelpers.config.getServerListeningConfig(); + if (listenCfg && listenCfg.port) port = listenCfg.port; + const webUrl = peertubeHelpers.config.getWebserverUrl(); + if (webUrl) { + const parsed = new URL(webUrl); + hostHeader = parsed.host; + } + } + } catch (e) {} + return { hostname: '127.0.0.1', port, hostHeader }; + } + + // Universal Admin OAuth Token Resolver (Zero hardcoded passwords - works on ANY PeerTube server via peertubeHelpers.database) + let cachedAdminToken = null; + let tokenExpiresAt = 0; + + async function getAdminToken() { + if (cachedAdminToken && Date.now() < tokenExpiresAt) { + return cachedAdminToken; + } + + // 1. Check existing valid admin token in oAuthToken table + try { + const rows = await dbQuery( + `SELECT t."accessToken" + FROM "oAuthToken" t + INNER JOIN "user" u ON u.id = t."userId" + WHERE u.role = 0 AND t."accessTokenExpiresAt" > NOW() + INTERVAL '5 minutes' + ORDER BY t."accessTokenExpiresAt" DESC + LIMIT 1` + ); + if (rows.length > 0 && rows[0].accessToken) { + cachedAdminToken = rows[0].accessToken; + tokenExpiresAt = Date.now() + 4 * 60 * 1000; + return cachedAdminToken; + } + + // 2. If no active admin token exists, mint one directly in oAuthToken for the administrator (role = 0) + const admins = await dbQuery(`SELECT id FROM "user" WHERE role = 0 ORDER BY id ASC LIMIT 1`); + const clients = await dbQuery(`SELECT id FROM "oAuthClient" ORDER BY id ASC LIMIT 1`); + if (admins.length > 0 && clients.length > 0) { + const accessToken = crypto.randomBytes(20).toString('hex'); + const refreshToken = crypto.randomBytes(20).toString('hex'); + await dbQuery( + `INSERT INTO "oAuthToken" ("accessToken", "accessTokenExpiresAt", "refreshToken", "refreshTokenExpiresAt", "oAuthClientId", "userId", "createdAt", "updatedAt") + VALUES ($1, NOW() + INTERVAL '7 days', $2, NOW() + INTERVAL '30 days', $3, $4, NOW(), NOW())`, + [accessToken, refreshToken, clients[0].id, admins[0].id] + ); + cachedAdminToken = accessToken; + tokenExpiresAt = Date.now() + 30 * 60 * 1000; + return cachedAdminToken; + } + } catch (err) { + if (logger) logger.warn('[YakTube OnDemand] DB token lookup warning: ' + err.message); + } + + throw new Error('Could not resolve or generate admin OAuth token'); + } + + // Universal Channel Resolver (Works on any PeerTube instance + smart category mapping) + async function resolveTargetChannelId(title, explicitChannelId) { + if (explicitChannelId) return String(explicitChannelId); + if (customChannelId) return String(customChannelId); + + try { + const channels = await dbQuery( + `SELECT vc.id, vc.name, a."displayName" + FROM "videoChannel" vc + INNER JOIN "account" a ON vc."accountId" = a.id + INNER JOIN "user" u ON a."userId" = u.id + WHERE u.role = 0 + ORDER BY vc.id ASC` + ); + + if (channels.length > 0) { + const t = String(title || '').toLowerCase(); + const findCh = keyword => + channels.find( + c => + (c.name && c.name.toLowerCase().includes(keyword)) || + (c.displayName && c.displayName.toLowerCase().includes(keyword)) + ); + + if ( + t.includes('podcast') || + t.includes('röportaj') || + t.includes('roportaj') || + t.includes('sohbet') || + t.includes('bölüm') || + t.includes('episode') + ) { + const podCh = findCh('podcast') || findCh('sohbet'); + if (podCh) return String(podCh.id); + } + + if ( + t.includes('bitcoin') || + t.includes('kripto') || + t.includes('yazılım') || + t.includes('kodlama') || + t.includes('ders') || + t.includes('rehber') || + t.includes('tutorial') || + t.includes('teknoloji') || + t.includes('inceleme') + ) { + const techCh = findCh('tekno') || findCh('tech') || findCh('egitim'); + if (techCh) return String(techCh.id); + } + + if ( + t.includes('müzik') || + t.includes('muzik') || + t.includes('music') || + t.includes('şarkı') || + t.includes('klip') || + t.includes('official') || + t.includes('feat') || + t.includes('remix') || + t.includes(' - ') + ) { + const musicCh = findCh('muzik') || findCh('müzik') || findCh('music'); + if (musicCh) return String(musicCh.id); + } + + return String(channels[0].id); + } + } catch (e) {} + + return '1'; + } + + // Helper to check if request is from an authenticated PeerTube user + async function isRequestAuthenticated(req, res, bodyAuthFlag) { + if (bodyAuthFlag === true) return true; + try { + if (peertubeHelpers && peertubeHelpers.user && typeof peertubeHelpers.user.getAuthUser === 'function') { + const u = await peertubeHelpers.user.getAuthUser(res); + if (u) return true; + } + } catch (e) {} + + const authHeader = req.headers && req.headers.authorization; + if (authHeader && authHeader.startsWith('Bearer ')) { + const token = authHeader.substring(7).trim(); + if (token) { + try { + const rows = await dbQuery( + `SELECT id FROM "oAuthToken" WHERE "accessToken" = $1 AND "accessTokenExpiresAt" > NOW() LIMIT 1`, + [token] + ); + if (rows.length > 0) return true; + } catch (e) {} + } + } + return false; + } + + // Helper to enrich YouTube results with existing local PeerTube videos via DB + async function enrichResultsWithLocalStatus(results) { + try { + const imports = await dbQuery( + `SELECT vi."targetUrl", v.id, v.uuid, v.name + FROM "videoImport" vi + INNER JOIN "video" v ON v.id = vi."videoId" + WHERE vi."targetUrl" IS NOT NULL + ORDER BY vi.id DESC + LIMIT 200` + ); + const localVideos = await dbQuery( + `SELECT id, uuid, name + FROM "video" + WHERE remote = false AND state = 1 + ORDER BY id DESC + LIMIT 200` + ); + + return results.map(item => { + if (item.isLive) { + return { ...item, isDownloaded: false }; + } + + const matchedImport = imports.find(imp => imp.targetUrl && item.id && imp.targetUrl.includes(item.id)); + if (matchedImport) { + return { + ...item, + isDownloaded: true, + localVideo: { + id: matchedImport.id, + uuid: matchedImport.uuid, + name: matchedImport.name, + url: '/videos/watch/' + matchedImport.uuid + } + }; + } + + const normalizedTitle = (item.title || '').toLowerCase().trim(); + const matchedLocal = localVideos.find(lv => { + const lvName = (lv.name || '').toLowerCase().trim(); + return lvName === normalizedTitle || (lvName.length > 5 && normalizedTitle.includes(lvName)); + }); + + if (matchedLocal) { + return { + ...item, + isDownloaded: true, + localVideo: { + id: matchedLocal.id, + uuid: matchedLocal.uuid, + name: matchedLocal.name, + url: '/videos/watch/' + matchedLocal.uuid + } + }; + } + + return { ...item, isDownloaded: false }; + }); + } catch (e) { + return results; + } + } + + // 1. Quota Config Route + router.get('/quota-config', async (_req, res) => { + await loadPluginSettings(); return res.json({ ok: true, guestImportLimit }); }); - router.get('/search', async (req, res) => { + // 2. YouTube Search Route (aliases: /youtube-search and /search) + async function handleYouTubeSearch(req, res) { + await loadPluginSettings(); const query = (req.query.q || '').trim(); if (!query || query.length < 2) { return res.json({ results: [], guestImportLimit }); @@ -230,27 +420,38 @@ async function register({ getRouter, registerSetting, settingsManager, peertubeH const cached = getCached(query); if (cached) { - return res.json({ results: cached, cached: true, guestImportLimit }); + const enriched = await enrichResultsWithLocalStatus(cached); + return res.json({ results: enriched, cached: true, guestImportLimit }); } - const args = ['ytsearch6:' + query, '--dump-single-json', '--flat-playlist', '--skip-download', '--no-warnings']; + const ytdlpPath = resolveYtDlpPath(logger, customYtDlpPath); + const args = ['ytsearch24:' + query, '--dump-single-json', '--flat-playlist', '--skip-download', '--no-warnings']; - execFile(ytdlpPath, args, { maxBuffer: 10 * 1024 * 1024, timeout: 15000 }, (error, stdout, stderr) => { + execFile(ytdlpPath, args, { maxBuffer: 10 * 1024 * 1024, timeout: 15000 }, async (error, stdout) => { if (error) { if (logger) logger.error('YouTube search error: ' + error.message); return res.status(500).json({ error: 'Search failed', details: error.message }); } try { - const json = JSON.parse(stdout); + const rawStr = stdout.trim(); + const startIdx = rawStr.indexOf('{'); + const endIdx = rawStr.lastIndexOf('}'); + if (startIdx === -1 || endIdx === -1) { + throw new Error('No JSON object found in output'); + } + const json = JSON.parse(rawStr.substring(startIdx, endIdx + 1)); const entries = json.entries || []; - const results = entries + const rawResults = entries .filter(e => e && e.id) .map(e => { const duration = e.duration || 0; const mins = Math.floor(duration / 60); const secs = Math.floor(duration % 60); - const durationFormatted = (mins < 10 ? '0' + mins : mins) + ':' + (secs < 10 ? '0' + secs : secs); + const isLive = e.is_live === true || e.live_status === 'is_live' || (!e.duration && e.was_live !== true); + const durationFormatted = isLive + ? 'CANLI' + : (mins < 10 ? '0' + mins : mins) + ':' + (secs < 10 ? '0' + secs : secs); let thumbnail = e.thumbnail || (e.thumbnails && e.thumbnails.length ? e.thumbnails[e.thumbnails.length - 1].url : ''); if (!thumbnail && e.id) { @@ -263,28 +464,34 @@ async function register({ getRouter, registerSetting, settingsManager, peertubeH channel: e.uploader || e.channel || 'YouTube', duration, durationFormatted, + isLive, url: e.url || `https://www.youtube.com/watch?v=${e.id}`, thumbnail }; }); - setCache(query, results); - return res.json({ results, cached: false, guestImportLimit }); + setCache(query, rawResults); + const enrichedResults = await enrichResultsWithLocalStatus(rawResults); + return res.json({ results: enrichedResults, cached: false, guestImportLimit }); } catch (parseErr) { if (logger) logger.error('JSON parse error in search: ' + parseErr.message); return res.status(500).json({ error: 'Failed to parse search results' }); } }); - }); + } - router.post('/import', async (req, res) => { - const { targetUrl, channelId, category, isAuthenticated, guestImportCount } = req.body; + router.get('/youtube-search', handleYouTubeSearch); + router.get('/search', handleYouTubeSearch); + + // 3. On-Demand Video Import Route (aliases: /ondemand-import and /import) + async function handleOnDemandImport(req, res) { + await loadPluginSettings(); + const { targetUrl, title, channelId, isAuthenticated, guestImportCount } = req.body || {}; if (!targetUrl || (!targetUrl.includes('youtube.com') && !targetUrl.includes('youtu.be'))) { return res.status(400).json({ error: 'Invalid YouTube URL' }); } - const authHeader = req.headers && req.headers.authorization; - const isLoggedUser = Boolean(isAuthenticated || (authHeader && authHeader.startsWith('Bearer '))); + const isLoggedUser = await isRequestAuthenticated(req, res, isAuthenticated); if (!isLoggedUser && guestImportLimit > 0) { const clientCount = parseInt(String(guestImportCount || '0'), 10) || 0; if (clientCount >= guestImportLimit) { @@ -292,14 +499,15 @@ async function register({ getRouter, registerSetting, settingsManager, peertubeH ok: false, quotaExceeded: true, limit: guestImportLimit, - error: 'Kota Doldu: Lütfen devam etmek için giriş yapın.' + error: 'Kota Doldu: Misafir video indirme sınırına ulaştınız. Lütfen giriş yapın.' }); } } try { const token = await getAdminToken(); - const resolvedChannelId = channelId ? String(channelId) : await getDefaultChannelId(category); + const resolvedChannelId = await resolveTargetChannelId(title, channelId); + const { hostname, port, hostHeader } = getLocalServerTarget(); const postData = new URLSearchParams({ targetUrl: targetUrl, channelId: resolvedChannelId, @@ -308,12 +516,12 @@ async function register({ getRouter, registerSetting, settingsManager, peertubeH const importReq = http.request( { - hostname: '127.0.0.1', - port: 9000, + hostname, + port, path: '/api/v1/videos/imports', method: 'POST', headers: { - Host: 'yaktube.yakhub.com.tr', + Host: hostHeader, Authorization: 'Bearer ' + token, 'Content-Type': 'application/x-www-form-urlencoded', 'Content-Length': Buffer.byteLength(postData) @@ -356,17 +564,22 @@ async function register({ getRouter, registerSetting, settingsManager, peertubeH } catch (err) { return res.status(500).json({ error: 'Admin authentication failed', details: err.message }); } - }); + } + router.post('/ondemand-import', handleOnDemandImport); + router.post('/import', handleOnDemandImport); + + // 4. Video Import Status Polling Route router.get('/status/:id', async (req, res) => { const videoId = req.params.id; + const { hostname, port, hostHeader } = getLocalServerTarget(); const reqVideo = http.request( { - hostname: '127.0.0.1', - port: 9000, + hostname, + port, path: '/api/v1/videos/' + encodeURIComponent(videoId), method: 'GET', - headers: { Host: 'yaktube.yakhub.com.tr' } + headers: { Host: hostHeader } }, resVideo => { let body = ''; @@ -374,12 +587,25 @@ async function register({ getRouter, registerSetting, settingsManager, peertubeH resVideo.on('end', () => { try { const video = JSON.parse(body); + const isPublished = video.state && video.state.id === 1; + const isTranscoding = video.state && video.state.id === 2; + const isImporting = video.state && video.state.id === 3; + const isFailed = video.state && video.state.id === 12; + const hasFiles = + (video.files && video.files.length > 0) || + (video.streamingPlaylists && video.streamingPlaylists.length > 0); + return res.json({ id: video.id, uuid: video.uuid, shortUUID: video.shortUUID, name: video.name, state: video.state, + isPublished, + isTranscoding, + isImporting, + isFailed, + hasFiles, url: video.url, isLocal: video.isLocal }); @@ -393,6 +619,248 @@ async function register({ getRouter, registerSetting, settingsManager, peertubeH reqVideo.on('error', e => res.status(500).json({ error: e.message })); reqVideo.end(); }); + + // 5. Live Stream HLS URL Extraction Route + router.get('/live-stream-url', async (req, res) => { + const target = (req.query.url || req.query.id || '').trim(); + if (!target) { + return res.status(400).json({ error: 'Target URL is required' }); + } + const ytdlpPath = resolveYtDlpPath(logger, customYtDlpPath); + const fullUrl = target.startsWith('http') ? target : `https://www.youtube.com/watch?v=${target}`; + execFile(ytdlpPath, ['-g', fullUrl], { timeout: 12000 }, (err, stdout, stderr) => { + if (err || !stdout.trim()) { + return res + .status(500) + .json({ error: 'Could not extract live stream URL', details: err ? err.message : stderr }); + } + const lines = stdout.trim().split(/\r?\n/); + return res.json({ + ok: true, + streamUrl: lines[0], + isLive: true + }); + }); + }); + + // 6. Video YouTube Origin Detection Route + router.get('/video-origin/:uuid', async (req, res) => { + const uuid = (req.params.uuid || '').trim(); + if (!uuid) return res.status(400).json({ error: 'UUID required' }); + + try { + const rows = await dbQuery( + `SELECT v.id, v.uuid, v.name, v.description, vi."targetUrl" + FROM "video" v + LEFT JOIN "videoImport" vi ON vi."videoId" = v.id + WHERE v.uuid::text = $1 OR v."shortUUID" = $1 OR v.id::text = $1 + LIMIT 1`, + [uuid] + ); + if (rows.length === 0) { + return res.status(404).json({ error: 'Video not found' }); + } + + const row = rows[0]; + let youtubeId = null; + + if (row.targetUrl) { + const match = + row.targetUrl.match(/[?&]v=([^&]+)/) || + row.targetUrl.match(/youtu\.be\/([^?]+)/) || + row.targetUrl.match(/shorts\/([^?]+)/); + if (match) youtubeId = match[1]; + } + + if (!youtubeId && row.description) { + const match = + row.description.match(/youtube\.com\/watch\?v=([a-zA-Z0-9_-]{11})/) || + row.description.match(/youtu\.be\/([a-zA-Z0-9_-]{11})/) || + row.description.match(/shorts\/([a-zA-Z0-9_-]{11})/); + if (match) youtubeId = match[1]; + } + + return res.json({ + ok: true, + hasYouTubeOrigin: !!youtubeId, + youtubeId: youtubeId || null, + targetUrl: row.targetUrl || null, + title: row.name + }); + } catch (err) { + return res.status(500).json({ error: err.message }); + } + }); + + // 7. YouTube Original Comments Route + router.get('/youtube-comments', async (req, res) => { + const videoId = (req.query.id || req.query.v || '').trim(); + const continuationToken = (req.query.token || '').trim(); + + if (!videoId && !continuationToken) { + return res.status(400).json({ error: 'Video ID or token is required' }); + } + + const baseContext = { + client: { + clientName: 'WEB', + clientVersion: '2.20260220.00.00', + hl: 'tr', + gl: 'TR' + } + }; + + try { + let token = continuationToken; + + if (!token) { + const initRes = await postInnerTubeJSON('https://www.youtube.com/youtubei/v1/next', { + context: baseContext, + videoId: videoId + }); + + const contents = initRes.contents?.twoColumnWatchNextResults?.results?.results?.contents || []; + const commentSection = contents.find(t => t.itemSectionRenderer?.targetId === 'comments-section'); + token = + commentSection?.itemSectionRenderer?.contents?.[0]?.continuationItemRenderer?.continuationEndpoint + ?.continuationCommand?.token; + } + + if (!token) { + return res.json({ ok: true, comments: [], nextToken: null, total: 0 }); + } + + const commentRes = await postInnerTubeJSON('https://www.youtube.com/youtubei/v1/next', { + context: baseContext, + continuation: token + }); + + const comments = []; + const mutations = commentRes.frameworkUpdates?.entityBatchUpdate?.mutations || []; + + for (const m of mutations) { + const payload = m.payload?.commentEntityPayload; + if (payload) { + const author = payload.author?.displayName || 'Kullanıcı'; + const authorThumb = payload.author?.avatar?.image?.sources?.[0]?.url || ''; + const content = payload.properties?.content?.content || ''; + const published = payload.properties?.publishedTime || ''; + const likeCount = payload.toolbar?.likeCountNotliked || '0'; + + comments.push({ + id: payload.properties?.commentId, + author, + authorThumb, + content, + published, + likeCount + }); + } + } + + let nextToken = null; + const eps = commentRes.onResponseReceivedEndpoints || []; + for (const ep of eps) { + const items = + ep.reloadContinuationItemsCommand?.continuationItems || + ep.appendContinuationItemsAction?.continuationItems || + []; + for (const it of items) { + if (it.continuationItemRenderer) { + nextToken = + it.continuationItemRenderer.continuationEndpoint?.continuationCommand?.token || + it.continuationItemRenderer.button?.buttonRenderer?.command?.continuationCommand?.token; + } + } + } + + return res.json({ + ok: true, + comments, + nextToken, + count: comments.length + }); + } catch (err) { + return res.status(500).json({ error: 'Failed to fetch comments', details: err.message }); + } + }); + + // 8. User Search History Sync Route (Stores per-user history in plugin data directory on any PeerTube server) + function getHistoryFilePath() { + try { + if ( + peertubeHelpers && + peertubeHelpers.plugin && + typeof peertubeHelpers.plugin.getDataDirectoryPath === 'function' + ) { + return path.join(peertubeHelpers.plugin.getDataDirectoryPath(), 'user-search-history.json'); + } + } catch (e) {} + return path.join(process.cwd(), 'user-search-history.json'); + } + + function readHistoryStore() { + try { + const fp = getHistoryFilePath(); + if (fs.existsSync(fp)) { + return JSON.parse(fs.readFileSync(fp, 'utf8')) || {}; + } + } catch (e) {} + return {}; + } + + function writeHistoryStore(store) { + try { + const fp = getHistoryFilePath(); + fs.writeFileSync(fp, JSON.stringify(store, null, 2), 'utf8'); + } catch (e) {} + } + + async function resolveHistoryUserKey(req, res) { + try { + if (peertubeHelpers && peertubeHelpers.user && typeof peertubeHelpers.user.getAuthUser === 'function') { + const u = await peertubeHelpers.user.getAuthUser(res); + if (u && (u.username || u.email || u.id)) { + return String(u.username || u.email || u.id).toLowerCase(); + } + } + } catch (e) {} + const hdrUser = (req.headers && (req.headers['x-user-username'] || req.headers['x-user-email'])) || ''; + return hdrUser ? String(hdrUser).trim().toLowerCase() : null; + } + + router.all('/search-history', async (req, res) => { + const userKey = await resolveHistoryUserKey(req, res); + if (!userKey) { + return res.json({ status: 'success', history: [] }); + } + + const store = readHistoryStore(); + const currentList = Array.isArray(store[userKey]) ? store[userKey] : []; + + if (req.method === 'GET') { + return res.json({ status: 'success', history: currentList }); + } + + if (req.method === 'POST') { + const q = ((req.body && req.body.query) || '').trim(); + if (q && q.length >= 2) { + const filtered = currentList.filter(item => String(item).toLowerCase() !== q.toLowerCase()); + filtered.unshift(q); + store[userKey] = filtered.slice(0, 12); + writeHistoryStore(store); + } + return res.json({ status: 'success', history: store[userKey] || currentList }); + } + + if (req.method === 'DELETE') { + delete store[userKey]; + writeHistoryStore(store); + return res.json({ status: 'success', history: [] }); + } + + return res.json({ status: 'success', history: currentList }); + }); } async function unregister() {} diff --git a/package.json b/package.json index 996cca6..c1b21f7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "peertube-plugin-yaktube-ondemand", - "version": "1.5.7", + "version": "1.5.8", "description": "Accessible On-Demand YouTube Import, Live Streams, Smart Recommendations, Sleep Timer, Touch Gestures, Voice Assistant & A11y Engine for PeerTube", "main": "main.js", "library": "./main.js",