v1.5.8: 100% standalone backend router via peertubeHelpers (no bridge.js needed on other PeerTube servers)

This commit is contained in:
Enes Yakıştır
2026-10-03 10:45:58 +03:00
parent 75666442c8
commit 3bd5c41d97
3 changed files with 667 additions and 197 deletions
+66 -64
View File
@@ -2772,14 +2772,9 @@ try {
moreCommentsBtn.textContent = '⏳ Y\u00fckleniyor...'; moreCommentsBtn.textContent = '⏳ Y\u00fckleniyor...';
} }
var fetchUrl = var commentPath =
'/api-custom/youtube-comments?id=' + '/youtube-comments?id=' + encodeURIComponent(videoId) + (token ? '&token=' + encodeURIComponent(token) : '');
encodeURIComponent(videoId) + fetchPluginJson(commentPath)
(token ? '&token=' + encodeURIComponent(token) : '');
fetch(fetchUrl)
.then(function (r) {
return r.json();
})
.then(function (res) { .then(function (res) {
if (!token) { if (!token) {
commentsList.innerHTML = ''; commentsList.innerHTML = '';
@@ -2871,6 +2866,42 @@ try {
} catch (e) {} } 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 --- // --- Guest Video Import Quota & PeerTube Login Redirect Engine ---
var cachedGuestImportLimit = null; var cachedGuestImportLimit = null;
@@ -2878,10 +2909,7 @@ try {
if (cachedGuestImportLimit !== null) { if (cachedGuestImportLimit !== null) {
return Promise.resolve(cachedGuestImportLimit); return Promise.resolve(cachedGuestImportLimit);
} }
return fetch('/api-custom/quota-config') return fetchPluginJson('/quota-config')
.then(function (r) {
return r.json();
})
.then(function (d) { .then(function (d) {
if (d && typeof d.guestImportLimit === 'number') { if (d && typeof d.guestImportLimit === 'number') {
cachedGuestImportLimit = d.guestImportLimit; cachedGuestImportLimit = d.guestImportLimit;
@@ -2890,20 +2918,7 @@ try {
return 5; return 5;
}) })
.catch(function () { .catch(function () {
return fetch('/plugins/yaktube-ondemand/router/quota-config') return 5;
.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;
});
}); });
} }
@@ -2984,7 +2999,6 @@ try {
cfg.plugin.registeredExternalAuths.length === 1 cfg.plugin.registeredExternalAuths.length === 1
) { ) {
var ext = cfg.plugin.registeredExternalAuths[0]; 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; window.location.href = '/plugins/' + ext.name + '/' + ext.version + '/auth/' + ext.authName;
return; return;
} }
@@ -3084,7 +3098,7 @@ try {
} }
} catch (e) {} } catch (e) {}
fetch('/api-custom/ondemand-import', { fetchPluginJson('/ondemand-import', {
method: 'POST', method: 'POST',
headers: headers, headers: headers,
body: JSON.stringify({ body: JSON.stringify({
@@ -3094,9 +3108,6 @@ try {
guestImportCount: currentGuestCount guestImportCount: currentGuestCount
}) })
}) })
.then(function (res) {
return res.json();
})
.then(function (data) { .then(function (data) {
if (data && data.quotaExceeded) { if (data && data.quotaExceeded) {
hideModal(); hideModal();
@@ -3123,10 +3134,7 @@ try {
var attempts = 0; var attempts = 0;
var pollInterval = setInterval(function () { var pollInterval = setInterval(function () {
attempts++; attempts++;
fetch('/api-custom/status/' + videoId) fetchPluginJson('/status/' + videoId)
.then(function (sRes) {
return sRes.json();
})
.then(function (sData) { .then(function (sData) {
if (sData.isImporting) { if (sData.isImporting) {
showModal( showModal(
@@ -3301,15 +3309,11 @@ try {
var fetchOpts = abortCtrl ? { signal: abortCtrl.signal } : {}; var fetchOpts = abortCtrl ? { signal: abortCtrl.signal } : {};
Promise.all([ Promise.all([
fetch('/api-custom/youtube-search?q=' + encodeURIComponent(query), fetchOpts) fetchPluginJson('/youtube-search?q=' + encodeURIComponent(query), fetchOpts).catch(function (err) {
.then(function (res) { if (err && err.name === 'AbortError') return { results: [] };
return res.json(); console.warn('[YakTube] YouTube search fetch error:', err);
}) return { results: [] };
.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) fetch('/api/v1/search/videos?search=' + encodeURIComponent(query) + '&isLocal=true&count=12', fetchOpts)
.then(function (res) { .then(function (res) {
return res.json(); return res.json();
@@ -3712,7 +3716,7 @@ try {
if (username) headers['X-User-Username'] = username; if (username) headers['X-User-Username'] = username;
var authUrl = 'https://developer-console.yakhub.com.tr/api/yaktube/search-history' + (queryParams || ''); 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 = { var options = {
method: method, method: method,
@@ -3721,6 +3725,17 @@ try {
}; };
if (body) options.body = JSON.stringify(body); 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) fetch(authUrl, options)
.then(function (res) { .then(function (res) {
if (!res.ok) throw new Error('Auth server status ' + res.status); if (!res.ok) throw new Error('Auth server status ' + res.status);
@@ -3730,11 +3745,7 @@ try {
if (typeof onSuccess === 'function') onSuccess(data); if (typeof onSuccess === 'function') onSuccess(data);
}) })
.catch(function () { .catch(function () {
// Fallback to local PeerTube bridge proxy fetchPluginJson(historyPath, options)
fetch(bridgeUrl, options)
.then(function (res) {
return res.json();
})
.then(function (data) { .then(function (data) {
if (typeof onSuccess === 'function') onSuccess(data); if (typeof onSuccess === 'function') onSuccess(data);
}) })
@@ -4592,10 +4603,7 @@ try {
announce(searchQuery + ' için ilgili video önerileri yükleniyor.'); announce(searchQuery + ' için ilgili video önerileri yükleniyor.');
fetch('/api-custom/youtube-search?q=' + encodeURIComponent(searchQuery)) fetchPluginJson('/youtube-search?q=' + encodeURIComponent(searchQuery))
.then(function (res) {
return res.json();
})
.then(function (data) { .then(function (data) {
if (data && typeof data.guestImportLimit === 'number') { if (data && typeof data.guestImportLimit === 'number') {
cachedGuestImportLimit = data.guestImportLimit; cachedGuestImportLimit = data.guestImportLimit;
@@ -4673,10 +4681,7 @@ try {
document.querySelector('my-video-comments, .video-comments, my-video-watch, .video-bottom') || document.body; document.querySelector('my-video-comments, .video-comments, my-video-watch, .video-bottom') || document.body;
if (!mountTarget) return; if (!mountTarget) return;
fetch('/api-custom/video-origin/' + encodeURIComponent(uuid)) fetchPluginJson('/video-origin/' + encodeURIComponent(uuid))
.then(function (r) {
return r.json();
})
.then(function (originData) { .then(function (originData) {
if (!originData || !originData.ok || !originData.hasYouTubeOrigin || !originData.youtubeId) { if (!originData || !originData.ok || !originData.hasYouTubeOrigin || !originData.youtubeId) {
var old = document.getElementById('yaktube-watch-comments-panel'); var old = document.getElementById('yaktube-watch-comments-panel');
@@ -4742,14 +4747,11 @@ try {
moreBtn.textContent = '⏳ Y\u00fckleniyor...'; moreBtn.textContent = '⏳ Y\u00fckleniyor...';
} }
var fUrl = var fPath =
'/api-custom/youtube-comments?id=' + '/youtube-comments?id=' +
encodeURIComponent(youtubeId) + encodeURIComponent(youtubeId) +
(token ? '&token=' + encodeURIComponent(token) : ''); (token ? '&token=' + encodeURIComponent(token) : '');
fetch(fUrl) fetchPluginJson(fPath)
.then(function (r) {
return r.json();
})
.then(function (res) { .then(function (res) {
if (!token) { if (!token) {
listContainer.innerHTML = ''; listContainer.innerHTML = '';
+600 -132
View File
@@ -1,24 +1,28 @@
const { execFile } = require('child_process'); const { execFile } = require('child_process');
const path = require('path'); const path = require('path');
const http = require('http'); const http = require('http');
const https = require('https');
const fs = require('fs'); const fs = require('fs');
const crypto = require('crypto');
const searchCache = new Map(); const searchCache = new Map();
const CACHE_TTL = 30 * 60 * 1000; // 30 minutes const CACHE_TTL = 30 * 60 * 1000; // 30 minutes
let cachedYtDlpPath = null; 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))) { if (cachedYtDlpPath && (cachedYtDlpPath === 'yt-dlp' || fs.existsSync(cachedYtDlpPath))) {
return cachedYtDlpPath; return cachedYtDlpPath;
} }
// 1. Environment variable override
if (process.env.YTDLP_PATH && fs.existsSync(process.env.YTDLP_PATH)) { if (process.env.YTDLP_PATH && fs.existsSync(process.env.YTDLP_PATH)) {
cachedYtDlpPath = process.env.YTDLP_PATH; cachedYtDlpPath = process.env.YTDLP_PATH;
return cachedYtDlpPath; return cachedYtDlpPath;
} }
// 2. Cross-platform candidates
const isWin = process.platform === 'win32'; const isWin = process.platform === 'win32';
const candidates = isWin const candidates = isWin
? [ ? [
@@ -43,53 +47,6 @@ function resolveYtDlpPath(logger) {
return cachedYtDlpPath; 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) { function getCached(q) {
const item = searchCache.get(q); const item = searchCache.get(q);
if (item && Date.now() - item.time < CACHE_TTL) { if (item && Date.now() - item.time < CACHE_TTL) {
@@ -106,71 +63,26 @@ function setCache(q, data) {
} }
} }
let cachedAdminToken = null; function postInnerTubeJSON(url, data) {
let tokenExpiresAt = 0;
async function getAdminToken() {
if (cachedAdminToken && Date.now() < tokenExpiresAt) {
return cachedAdminToken;
}
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const req = http.request( const postData = JSON.stringify(data);
const req = https.request(
url,
{ {
hostname: '127.0.0.1', method: 'POST',
port: 9000, headers: {
path: '/api/v1/oauth-clients/local', 'Content-Type': 'application/json',
method: 'GET', 'Content-Length': Buffer.byteLength(postData),
headers: { Host: 'yaktube.yakhub.com.tr' } '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 => { res => {
let body = ''; let body = '';
res.on('data', chunk => (body += chunk)); res.on('data', chunk => (body += chunk));
res.on('end', () => { res.on('end', () => {
try { try {
const oauth = JSON.parse(body); resolve(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();
} catch (e) { } catch (e) {
reject(e); reject(e);
} }
@@ -178,15 +90,17 @@ async function getAdminToken() {
} }
); );
req.on('error', reject); req.on('error', reject);
req.write(postData);
req.end(); req.end();
}); });
} }
async function register({ getRouter, registerSetting, settingsManager, peertubeHelpers, logger }) { async function register({ getRouter, registerSetting, settingsManager, peertubeHelpers, logger }) {
const router = getRouter(); const router = getRouter();
const ytdlpPath = resolveYtDlpPath(logger);
let guestImportLimit = 5; let guestImportLimit = 5;
let customChannelId = '';
let customYtDlpPath = '';
if (typeof registerSetting === 'function') { if (typeof registerSetting === 'function') {
registerSetting({ registerSetting({
@@ -198,6 +112,26 @@ async function register({ getRouter, registerSetting, settingsManager, peertubeH
private: false, private: false,
default: '5' 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() { async function loadPluginSettings() {
@@ -211,6 +145,16 @@ async function register({ getRouter, registerSetting, settingsManager, peertubeH
guestImportLimit = 5; guestImportLimit = 5;
} }
} catch (e) {} } 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(); await loadPluginSettings();
@@ -218,11 +162,257 @@ async function register({ getRouter, registerSetting, settingsManager, peertubeH
settingsManager.onSettingsChange(loadPluginSettings); 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 }); 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(); const query = (req.query.q || '').trim();
if (!query || query.length < 2) { if (!query || query.length < 2) {
return res.json({ results: [], guestImportLimit }); return res.json({ results: [], guestImportLimit });
@@ -230,27 +420,38 @@ async function register({ getRouter, registerSetting, settingsManager, peertubeH
const cached = getCached(query); const cached = getCached(query);
if (cached) { 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 (error) {
if (logger) logger.error('YouTube search error: ' + error.message); if (logger) logger.error('YouTube search error: ' + error.message);
return res.status(500).json({ error: 'Search failed', details: error.message }); return res.status(500).json({ error: 'Search failed', details: error.message });
} }
try { 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 entries = json.entries || [];
const results = entries const rawResults = entries
.filter(e => e && e.id) .filter(e => e && e.id)
.map(e => { .map(e => {
const duration = e.duration || 0; const duration = e.duration || 0;
const mins = Math.floor(duration / 60); const mins = Math.floor(duration / 60);
const secs = 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 = let thumbnail =
e.thumbnail || (e.thumbnails && e.thumbnails.length ? e.thumbnails[e.thumbnails.length - 1].url : ''); e.thumbnail || (e.thumbnails && e.thumbnails.length ? e.thumbnails[e.thumbnails.length - 1].url : '');
if (!thumbnail && e.id) { if (!thumbnail && e.id) {
@@ -263,28 +464,34 @@ async function register({ getRouter, registerSetting, settingsManager, peertubeH
channel: e.uploader || e.channel || 'YouTube', channel: e.uploader || e.channel || 'YouTube',
duration, duration,
durationFormatted, durationFormatted,
isLive,
url: e.url || `https://www.youtube.com/watch?v=${e.id}`, url: e.url || `https://www.youtube.com/watch?v=${e.id}`,
thumbnail thumbnail
}; };
}); });
setCache(query, results); setCache(query, rawResults);
return res.json({ results, cached: false, guestImportLimit }); const enrichedResults = await enrichResultsWithLocalStatus(rawResults);
return res.json({ results: enrichedResults, cached: false, guestImportLimit });
} catch (parseErr) { } catch (parseErr) {
if (logger) logger.error('JSON parse error in search: ' + parseErr.message); if (logger) logger.error('JSON parse error in search: ' + parseErr.message);
return res.status(500).json({ error: 'Failed to parse search results' }); return res.status(500).json({ error: 'Failed to parse search results' });
} }
}); });
}); }
router.post('/import', async (req, res) => { router.get('/youtube-search', handleYouTubeSearch);
const { targetUrl, channelId, category, isAuthenticated, guestImportCount } = req.body; 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'))) { if (!targetUrl || (!targetUrl.includes('youtube.com') && !targetUrl.includes('youtu.be'))) {
return res.status(400).json({ error: 'Invalid YouTube URL' }); return res.status(400).json({ error: 'Invalid YouTube URL' });
} }
const authHeader = req.headers && req.headers.authorization; const isLoggedUser = await isRequestAuthenticated(req, res, isAuthenticated);
const isLoggedUser = Boolean(isAuthenticated || (authHeader && authHeader.startsWith('Bearer ')));
if (!isLoggedUser && guestImportLimit > 0) { if (!isLoggedUser && guestImportLimit > 0) {
const clientCount = parseInt(String(guestImportCount || '0'), 10) || 0; const clientCount = parseInt(String(guestImportCount || '0'), 10) || 0;
if (clientCount >= guestImportLimit) { if (clientCount >= guestImportLimit) {
@@ -292,14 +499,15 @@ async function register({ getRouter, registerSetting, settingsManager, peertubeH
ok: false, ok: false,
quotaExceeded: true, quotaExceeded: true,
limit: guestImportLimit, 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 { try {
const token = await getAdminToken(); 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({ const postData = new URLSearchParams({
targetUrl: targetUrl, targetUrl: targetUrl,
channelId: resolvedChannelId, channelId: resolvedChannelId,
@@ -308,12 +516,12 @@ async function register({ getRouter, registerSetting, settingsManager, peertubeH
const importReq = http.request( const importReq = http.request(
{ {
hostname: '127.0.0.1', hostname,
port: 9000, port,
path: '/api/v1/videos/imports', path: '/api/v1/videos/imports',
method: 'POST', method: 'POST',
headers: { headers: {
Host: 'yaktube.yakhub.com.tr', Host: hostHeader,
Authorization: 'Bearer ' + token, Authorization: 'Bearer ' + token,
'Content-Type': 'application/x-www-form-urlencoded', 'Content-Type': 'application/x-www-form-urlencoded',
'Content-Length': Buffer.byteLength(postData) 'Content-Length': Buffer.byteLength(postData)
@@ -356,17 +564,22 @@ async function register({ getRouter, registerSetting, settingsManager, peertubeH
} catch (err) { } catch (err) {
return res.status(500).json({ error: 'Admin authentication failed', details: err.message }); 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) => { router.get('/status/:id', async (req, res) => {
const videoId = req.params.id; const videoId = req.params.id;
const { hostname, port, hostHeader } = getLocalServerTarget();
const reqVideo = http.request( const reqVideo = http.request(
{ {
hostname: '127.0.0.1', hostname,
port: 9000, port,
path: '/api/v1/videos/' + encodeURIComponent(videoId), path: '/api/v1/videos/' + encodeURIComponent(videoId),
method: 'GET', method: 'GET',
headers: { Host: 'yaktube.yakhub.com.tr' } headers: { Host: hostHeader }
}, },
resVideo => { resVideo => {
let body = ''; let body = '';
@@ -374,12 +587,25 @@ async function register({ getRouter, registerSetting, settingsManager, peertubeH
resVideo.on('end', () => { resVideo.on('end', () => {
try { try {
const video = JSON.parse(body); 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({ return res.json({
id: video.id, id: video.id,
uuid: video.uuid, uuid: video.uuid,
shortUUID: video.shortUUID, shortUUID: video.shortUUID,
name: video.name, name: video.name,
state: video.state, state: video.state,
isPublished,
isTranscoding,
isImporting,
isFailed,
hasFiles,
url: video.url, url: video.url,
isLocal: video.isLocal 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.on('error', e => res.status(500).json({ error: e.message }));
reqVideo.end(); 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() {} async function unregister() {}
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "peertube-plugin-yaktube-ondemand", "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", "description": "Accessible On-Demand YouTube Import, Live Streams, Smart Recommendations, Sleep Timer, Touch Gestures, Voice Assistant & A11y Engine for PeerTube",
"main": "main.js", "main": "main.js",
"library": "./main.js", "library": "./main.js",