2026-08-29 12:42:36 +03:00
|
|
|
const { execFile } = require('child_process');
|
|
|
|
|
const path = require('path');
|
|
|
|
|
const http = require('http');
|
2026-09-19 05:14:11 +03:00
|
|
|
const fs = require('fs');
|
2026-08-29 12:42:36 +03:00
|
|
|
|
|
|
|
|
const searchCache = new Map();
|
|
|
|
|
const CACHE_TTL = 30 * 60 * 1000; // 30 minutes
|
|
|
|
|
|
2026-09-19 05:14:11 +03:00
|
|
|
let cachedYtDlpPath = null;
|
|
|
|
|
function resolveYtDlpPath(logger) {
|
|
|
|
|
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
|
|
|
|
|
? [
|
|
|
|
|
'D:\\yaktube_storage\\bin\\yt-dlp.exe',
|
|
|
|
|
'C:\\laragon\\bin\\yt-dlp.exe',
|
|
|
|
|
path.join(process.cwd(), 'bin', 'yt-dlp.exe'),
|
|
|
|
|
'yt-dlp.exe'
|
|
|
|
|
]
|
2026-09-19 05:20:33 +03:00
|
|
|
: ['/usr/local/bin/yt-dlp', '/usr/bin/yt-dlp', '/bin/yt-dlp', path.join(process.cwd(), 'bin', 'yt-dlp'), 'yt-dlp'];
|
2026-09-19 05:14:11 +03:00
|
|
|
|
|
|
|
|
for (const candidate of candidates) {
|
|
|
|
|
try {
|
|
|
|
|
if (candidate.includes(path.sep) && fs.existsSync(candidate)) {
|
|
|
|
|
cachedYtDlpPath = candidate;
|
|
|
|
|
if (logger) logger.info(`[YakTube OnDemand] Auto-detected yt-dlp binary at: ${cachedYtDlpPath}`);
|
|
|
|
|
return cachedYtDlpPath;
|
|
|
|
|
}
|
|
|
|
|
} catch (e) {}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
cachedYtDlpPath = isWin ? 'D:\\yaktube_storage\\bin\\yt-dlp.exe' : 'yt-dlp';
|
|
|
|
|
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';
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-19 05:20:33 +03:00
|
|
|
return new Promise(resolve => {
|
2026-09-19 05:14:11 +03:00
|
|
|
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();
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-29 12:42:36 +03:00
|
|
|
function getCached(q) {
|
|
|
|
|
const item = searchCache.get(q);
|
|
|
|
|
if (item && Date.now() - item.time < CACHE_TTL) {
|
|
|
|
|
return item.data;
|
|
|
|
|
}
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function setCache(q, data) {
|
|
|
|
|
searchCache.set(q, { time: Date.now(), data });
|
|
|
|
|
if (searchCache.size > 200) {
|
|
|
|
|
const firstKey = searchCache.keys().next().value;
|
|
|
|
|
searchCache.delete(firstKey);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let cachedAdminToken = null;
|
|
|
|
|
let tokenExpiresAt = 0;
|
|
|
|
|
|
|
|
|
|
async function getAdminToken() {
|
|
|
|
|
if (cachedAdminToken && Date.now() < tokenExpiresAt) {
|
|
|
|
|
return cachedAdminToken;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return new Promise((resolve, reject) => {
|
|
|
|
|
const req = http.request(
|
|
|
|
|
{
|
|
|
|
|
hostname: '127.0.0.1',
|
|
|
|
|
port: 9000,
|
|
|
|
|
path: '/api/v1/oauth-clients/local',
|
|
|
|
|
method: 'GET',
|
|
|
|
|
headers: { Host: 'yaktube.yakhub.com.tr' }
|
|
|
|
|
},
|
|
|
|
|
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();
|
|
|
|
|
} catch (e) {
|
|
|
|
|
reject(e);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
);
|
|
|
|
|
req.on('error', reject);
|
|
|
|
|
req.end();
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function register({ getRouter, peertubeHelpers, logger }) {
|
|
|
|
|
const router = getRouter();
|
2026-09-19 05:14:11 +03:00
|
|
|
const ytdlpPath = resolveYtDlpPath(logger);
|
2026-08-29 12:42:36 +03:00
|
|
|
|
|
|
|
|
router.get('/search', async (req, res) => {
|
|
|
|
|
const query = (req.query.q || '').trim();
|
|
|
|
|
if (!query || query.length < 2) {
|
|
|
|
|
return res.json({ results: [] });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const cached = getCached(query);
|
|
|
|
|
if (cached) {
|
|
|
|
|
return res.json({ results: cached, cached: true });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const args = ['ytsearch6:' + query, '--dump-single-json', '--flat-playlist', '--skip-download', '--no-warnings'];
|
|
|
|
|
|
|
|
|
|
execFile(ytdlpPath, args, { maxBuffer: 10 * 1024 * 1024, timeout: 15000 }, (error, stdout, stderr) => {
|
|
|
|
|
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 entries = json.entries || [];
|
|
|
|
|
const results = 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);
|
|
|
|
|
let thumbnail =
|
|
|
|
|
e.thumbnail || (e.thumbnails && e.thumbnails.length ? e.thumbnails[e.thumbnails.length - 1].url : '');
|
|
|
|
|
if (!thumbnail && e.id) {
|
|
|
|
|
thumbnail = `https://i.ytimg.com/vi/${e.id}/hqdefault.jpg`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
id: e.id,
|
|
|
|
|
title: e.title,
|
|
|
|
|
channel: e.uploader || e.channel || 'YouTube',
|
|
|
|
|
duration,
|
|
|
|
|
durationFormatted,
|
|
|
|
|
url: e.url || `https://www.youtube.com/watch?v=${e.id}`,
|
|
|
|
|
thumbnail
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
setCache(query, results);
|
|
|
|
|
return res.json({ results, cached: false });
|
|
|
|
|
} 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) => {
|
2026-09-19 05:14:11 +03:00
|
|
|
const { targetUrl, channelId, category } = req.body;
|
2026-08-29 12:42:36 +03:00
|
|
|
if (!targetUrl || (!targetUrl.includes('youtube.com') && !targetUrl.includes('youtu.be'))) {
|
|
|
|
|
return res.status(400).json({ error: 'Invalid YouTube URL' });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const token = await getAdminToken();
|
2026-09-19 05:14:11 +03:00
|
|
|
const resolvedChannelId = channelId ? String(channelId) : await getDefaultChannelId(category);
|
2026-08-29 12:42:36 +03:00
|
|
|
const postData = new URLSearchParams({
|
|
|
|
|
targetUrl: targetUrl,
|
2026-09-19 05:14:11 +03:00
|
|
|
channelId: resolvedChannelId,
|
2026-08-29 12:42:36 +03:00
|
|
|
privacy: '1'
|
|
|
|
|
}).toString();
|
|
|
|
|
|
|
|
|
|
const importReq = http.request(
|
|
|
|
|
{
|
|
|
|
|
hostname: '127.0.0.1',
|
|
|
|
|
port: 9000,
|
|
|
|
|
path: '/api/v1/videos/imports',
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers: {
|
|
|
|
|
Host: 'yaktube.yakhub.com.tr',
|
|
|
|
|
Authorization: 'Bearer ' + token,
|
|
|
|
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
|
|
|
'Content-Length': Buffer.byteLength(postData)
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
importRes => {
|
|
|
|
|
let body = '';
|
|
|
|
|
importRes.on('data', c => (body += c));
|
|
|
|
|
importRes.on('end', () => {
|
|
|
|
|
try {
|
|
|
|
|
const data = JSON.parse(body);
|
|
|
|
|
if (data && data.video) {
|
|
|
|
|
return res.json({
|
|
|
|
|
ok: true,
|
|
|
|
|
importId: data.id,
|
|
|
|
|
video: {
|
|
|
|
|
id: data.video.id,
|
|
|
|
|
uuid: data.video.uuid,
|
|
|
|
|
shortUUID: data.video.shortUUID,
|
|
|
|
|
name: data.video.name,
|
|
|
|
|
url: data.video.url
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
} else {
|
|
|
|
|
return res.status(400).json({ error: 'Import initiation failed', details: data });
|
|
|
|
|
}
|
|
|
|
|
} catch (e) {
|
|
|
|
|
return res.status(500).json({ error: 'Error parsing import response', raw: body });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
importReq.on('error', err => {
|
|
|
|
|
return res.status(500).json({ error: 'HTTP error connecting to PeerTube API', details: err.message });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
importReq.write(postData);
|
|
|
|
|
importReq.end();
|
|
|
|
|
} catch (err) {
|
|
|
|
|
return res.status(500).json({ error: 'Admin authentication failed', details: err.message });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
router.get('/status/:id', async (req, res) => {
|
|
|
|
|
const videoId = req.params.id;
|
|
|
|
|
const reqVideo = http.request(
|
|
|
|
|
{
|
|
|
|
|
hostname: '127.0.0.1',
|
|
|
|
|
port: 9000,
|
|
|
|
|
path: '/api/v1/videos/' + encodeURIComponent(videoId),
|
|
|
|
|
method: 'GET',
|
|
|
|
|
headers: { Host: 'yaktube.yakhub.com.tr' }
|
|
|
|
|
},
|
|
|
|
|
resVideo => {
|
|
|
|
|
let body = '';
|
|
|
|
|
resVideo.on('data', c => (body += c));
|
|
|
|
|
resVideo.on('end', () => {
|
|
|
|
|
try {
|
|
|
|
|
const video = JSON.parse(body);
|
|
|
|
|
return res.json({
|
|
|
|
|
id: video.id,
|
|
|
|
|
uuid: video.uuid,
|
|
|
|
|
shortUUID: video.shortUUID,
|
|
|
|
|
name: video.name,
|
|
|
|
|
state: video.state,
|
|
|
|
|
url: video.url,
|
|
|
|
|
isLocal: video.isLocal
|
|
|
|
|
});
|
|
|
|
|
} catch (e) {
|
|
|
|
|
return res.status(500).json({ error: 'Failed to parse video info' });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
reqVideo.on('error', e => res.status(500).json({ error: e.message }));
|
|
|
|
|
reqVideo.end();
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function unregister() {}
|
|
|
|
|
|
|
|
|
|
module.exports = {
|
|
|
|
|
register,
|
|
|
|
|
unregister
|
|
|
|
|
};
|