Git Komut Rehberi & Terminal Asistanı
Sıfırdan Yeni Bir Depo Başlatın:
Mevcut Bir Git Deposu veya Klasörü Gönderin:
Hataları Geri Alma, İptal & Temizlik:
Son commit'i geri al (yapılan değişiklikleri korur):
git reset --soft HEAD~1
Yapılan yerel değişiklikleri iptal et (çalışma ağacını temizler):
git restore .
Takip edilmeyen gereksiz dosyaları sil:
git clean -fd
Değişiklikleri geçici sakla ve geri getir (stash):
git stash git stash pop
Dal (Branch) ve Sürüm Etiketleri:
Yeni dal aç ve geçiş yap:
git checkout -b yeni-ozellik git push -u origin yeni-ozellik
Sürüm etiketi (tag) oluştur ve gönder:
git tag -a v1.0.0 -m "Sürüm 1.0.0" git push origin --tags
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3bd5c41d97 | ||
|
|
75666442c8 | ||
|
|
6d2b069978 | ||
|
|
bd4fd4e5b4 | ||
|
|
5c3c22999f | ||
|
|
24a81d92b4 | ||
|
|
74b36ec6ba | ||
|
|
96e6750f99 | ||
|
|
2d4dcafe82 | ||
|
|
8c3d49ec76 | ||
|
|
3950d91070 | ||
|
|
eef5bd85ac | ||
|
|
9ad6f12223 |
@@ -1,10 +1,52 @@
|
||||
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, 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;
|
||||
}
|
||||
|
||||
if (process.env.YTDLP_PATH && fs.existsSync(process.env.YTDLP_PATH)) {
|
||||
cachedYtDlpPath = process.env.YTDLP_PATH;
|
||||
return cachedYtDlpPath;
|
||||
}
|
||||
|
||||
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'
|
||||
]
|
||||
: ['/usr/local/bin/yt-dlp', '/usr/bin/yt-dlp', '/bin/yt-dlp', path.join(process.cwd(), 'bin', 'yt-dlp'), 'yt-dlp'];
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function getCached(q) {
|
||||
const item = searchCache.get(q);
|
||||
if (item && Date.now() - item.time < CACHE_TTL) {
|
||||
@@ -21,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);
|
||||
}
|
||||
@@ -93,43 +90,368 @@ async function getAdminToken() {
|
||||
}
|
||||
);
|
||||
req.on('error', reject);
|
||||
req.write(postData);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
async function register({ getRouter, peertubeHelpers, logger }) {
|
||||
async function register({ getRouter, registerSetting, settingsManager, peertubeHelpers, logger }) {
|
||||
const router = getRouter();
|
||||
const ytdlpPath = 'D:\\yaktube_storage\\bin\\yt-dlp.exe';
|
||||
|
||||
router.get('/search', async (req, res) => {
|
||||
let guestImportLimit = 5;
|
||||
let customChannelId = '';
|
||||
let customYtDlpPath = '';
|
||||
|
||||
if (typeof registerSetting === 'function') {
|
||||
registerSetting({
|
||||
name: 'guest-import-limit',
|
||||
label: 'Misafir Video İndirme Sınırı (Kota)',
|
||||
type: 'input',
|
||||
description:
|
||||
'Giriş yapmamış (misafir) kullanıcıların YouTube üzerinden sunucuya aktarabileceği maksimum video sayısı (Örn: 5). Sınır istemiyorsanız (sınırsız) 0 yazın.',
|
||||
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() {
|
||||
if (!settingsManager || typeof settingsManager.getSetting !== 'function') return;
|
||||
try {
|
||||
const val = await settingsManager.getSetting('guest-import-limit');
|
||||
if (val !== undefined && val !== null && String(val).trim() !== '') {
|
||||
const parsed = parseInt(String(val).trim(), 10);
|
||||
guestImportLimit = isNaN(parsed) || parsed < 0 ? 5 : parsed;
|
||||
} else {
|
||||
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();
|
||||
if (settingsManager && typeof settingsManager.onSettingsChange === 'function') {
|
||||
settingsManager.onSettingsChange(loadPluginSettings);
|
||||
}
|
||||
|
||||
// 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 });
|
||||
});
|
||||
|
||||
// 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: [] });
|
||||
return res.json({ results: [], guestImportLimit });
|
||||
}
|
||||
|
||||
const cached = getCached(query);
|
||||
if (cached) {
|
||||
return res.json({ results: cached, cached: true });
|
||||
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) {
|
||||
@@ -142,42 +464,64 @@ async function register({ getRouter, peertubeHelpers, logger }) {
|
||||
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 });
|
||||
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 } = 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 isLoggedUser = await isRequestAuthenticated(req, res, isAuthenticated);
|
||||
if (!isLoggedUser && guestImportLimit > 0) {
|
||||
const clientCount = parseInt(String(guestImportCount || '0'), 10) || 0;
|
||||
if (clientCount >= guestImportLimit) {
|
||||
return res.status(429).json({
|
||||
ok: false,
|
||||
quotaExceeded: true,
|
||||
limit: guestImportLimit,
|
||||
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 = await resolveTargetChannelId(title, channelId);
|
||||
const { hostname, port, hostHeader } = getLocalServerTarget();
|
||||
const postData = new URLSearchParams({
|
||||
targetUrl: targetUrl,
|
||||
channelId: '1146', // Türkçe Müzik channel
|
||||
channelId: resolvedChannelId,
|
||||
privacy: '1'
|
||||
}).toString();
|
||||
|
||||
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)
|
||||
@@ -220,17 +564,22 @@ async function register({ getRouter, peertubeHelpers, logger }) {
|
||||
} 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 = '';
|
||||
@@ -238,12 +587,25 @@ async function register({ getRouter, peertubeHelpers, logger }) {
|
||||
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
|
||||
});
|
||||
@@ -257,6 +619,248 @@ async function register({ getRouter, peertubeHelpers, logger }) {
|
||||
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() {}
|
||||
|
||||
+7
-3
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "peertube-plugin-yaktube-ondemand",
|
||||
"version": "1.4.31",
|
||||
"description": "Accessible On-Demand YouTube Import, Live Streams, Smart Recommendations and A11y Engine for PeerTube",
|
||||
"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",
|
||||
"staticDirs": {},
|
||||
@@ -30,6 +30,10 @@
|
||||
"accessibility",
|
||||
"a11y",
|
||||
"screen-reader",
|
||||
"sleep-timer",
|
||||
"touch-gestures",
|
||||
"voice-assistant",
|
||||
"qr-transfer",
|
||||
"fediverse",
|
||||
"voice-search",
|
||||
"speech-recognition",
|
||||
@@ -39,7 +43,7 @@
|
||||
"author": "Enes Yakıştır <[email protected]>",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/yakistir98/peertube-plugin-yaktube-ondemand.git"
|
||||
"url": "git+https://github.com/yakistir98/peertube-plugin-yaktube-ondemand.git"
|
||||
},
|
||||
"homepage": "https://yaktube.yakhub.com.tr",
|
||||
"bugs": "https://github.com/yakistir98/peertube-plugin-yaktube-ondemand/issues",
|
||||
|
||||
@@ -1097,3 +1097,466 @@ body:has(#yaktube-ondemand-container) my-error-page {
|
||||
outline: 2px solid #ff8f37;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* YAKTUBE TOUCH & MOBILE DOUBLE-TAP TO SEEK (v1.5.0) */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
.yaktube-doubletap-overlay {
|
||||
position: absolute !important;
|
||||
top: 0 !important;
|
||||
bottom: 0 !important;
|
||||
width: 40% !important;
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
pointer-events: none !important;
|
||||
z-index: 9999 !important;
|
||||
opacity: 0;
|
||||
transition: opacity 0.25s ease-out !important;
|
||||
}
|
||||
|
||||
.yaktube-doubletap-left {
|
||||
left: 0 !important;
|
||||
background: radial-gradient(circle at left center, rgba(255, 143, 55, 0.35) 0%, transparent 70%) !important;
|
||||
border-top-left-radius: 16px;
|
||||
border-bottom-left-radius: 16px;
|
||||
}
|
||||
|
||||
.yaktube-doubletap-right {
|
||||
right: 0 !important;
|
||||
background: radial-gradient(circle at right center, rgba(255, 143, 55, 0.35) 0%, transparent 70%) !important;
|
||||
border-top-right-radius: 16px;
|
||||
border-bottom-right-radius: 16px;
|
||||
}
|
||||
|
||||
.yaktube-doubletap-overlay.yaktube-ripple-active {
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
.yaktube-ripple-content {
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
background: rgba(0, 0, 0, 0.75) !important;
|
||||
border: 1px solid rgba(255, 143, 55, 0.6) !important;
|
||||
border-radius: 50px !important;
|
||||
padding: 12px 24px !important;
|
||||
color: #fff !important;
|
||||
font-size: 16px !important;
|
||||
font-weight: 700 !important;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.6) !important;
|
||||
transform: scale(0.85);
|
||||
animation: yaktube-ripple-bounce 0.45s cubic-bezier(0.175, 0.885, 0.32, 1.275) forwards !important;
|
||||
}
|
||||
|
||||
@keyframes yaktube-ripple-bounce {
|
||||
0% {
|
||||
transform: scale(0.7);
|
||||
opacity: 0.5;
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.1);
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* YAKTUBE SLEEP TIMER & QR CODE MODALS (v1.5.0) */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
.yaktube-feature-modal-backdrop {
|
||||
position: fixed !important;
|
||||
top: 0 !important;
|
||||
left: 0 !important;
|
||||
width: 100vw !important;
|
||||
height: 100vh !important;
|
||||
background: rgba(0, 0, 0, 0.82) !important;
|
||||
backdrop-filter: blur(10px) !important;
|
||||
z-index: 999999 !important;
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
padding: 16px !important;
|
||||
animation: yaktube-fade-in 0.2s ease-out !important;
|
||||
}
|
||||
|
||||
.yaktube-feature-modal-card {
|
||||
background: #18181f !important;
|
||||
border: 1px solid rgba(255, 143, 55, 0.4) !important;
|
||||
border-radius: 20px !important;
|
||||
width: 100% !important;
|
||||
max-width: 440px !important;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.85) !important;
|
||||
color: #fff !important;
|
||||
overflow: hidden !important;
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
animation: yaktube-slide-up 0.25s cubic-bezier(0.16, 1, 0.3, 1) !important;
|
||||
}
|
||||
|
||||
.yaktube-feature-modal-header {
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: space-between !important;
|
||||
padding: 18px 24px !important;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1) !important;
|
||||
background: rgba(255, 143, 55, 0.08) !important;
|
||||
}
|
||||
|
||||
.yaktube-feature-modal-title {
|
||||
margin: 0 !important;
|
||||
font-size: 18px !important;
|
||||
font-weight: 700 !important;
|
||||
color: #ff8f37 !important;
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
gap: 8px !important;
|
||||
}
|
||||
|
||||
.yaktube-feature-modal-close {
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
color: #94a3b8 !important;
|
||||
font-size: 22px !important;
|
||||
cursor: pointer !important;
|
||||
padding: 4px 8px !important;
|
||||
border-radius: 8px !important;
|
||||
transition: all 0.2s ease !important;
|
||||
}
|
||||
|
||||
.yaktube-feature-modal-close:hover,
|
||||
.yaktube-feature-modal-close:focus {
|
||||
color: #fff !important;
|
||||
background: rgba(255, 255, 255, 0.1) !important;
|
||||
outline: 2px solid #ff8f37 !important;
|
||||
}
|
||||
|
||||
.yaktube-feature-modal-body {
|
||||
padding: 24px !important;
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
gap: 16px !important;
|
||||
}
|
||||
|
||||
.yaktube-timer-options {
|
||||
display: grid !important;
|
||||
grid-template-columns: 1fr 1fr !important;
|
||||
gap: 10px !important;
|
||||
}
|
||||
|
||||
.yaktube-timer-btn {
|
||||
background: #23232c !important;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12) !important;
|
||||
color: #f1f5f9 !important;
|
||||
border-radius: 12px !important;
|
||||
padding: 14px 12px !important;
|
||||
font-size: 14px !important;
|
||||
font-weight: 600 !important;
|
||||
cursor: pointer !important;
|
||||
transition: all 0.2s ease !important;
|
||||
text-align: center !important;
|
||||
}
|
||||
|
||||
.yaktube-timer-btn:hover,
|
||||
.yaktube-timer-btn:focus-visible {
|
||||
background: #2d2d38 !important;
|
||||
border-color: #ff8f37 !important;
|
||||
color: #ff8f37 !important;
|
||||
transform: translateY(-2px) !important;
|
||||
outline: none !important;
|
||||
box-shadow: 0 4px 12px rgba(255, 143, 55, 0.25) !important;
|
||||
}
|
||||
|
||||
.yaktube-timer-btn.active {
|
||||
background: linear-gradient(135deg, #ff8f37 0%, #ff5e3a 100%) !important;
|
||||
color: #fff !important;
|
||||
border-color: transparent !important;
|
||||
font-weight: 700 !important;
|
||||
box-shadow: 0 4px 15px rgba(255, 143, 55, 0.4) !important;
|
||||
}
|
||||
|
||||
.yaktube-timer-badge {
|
||||
display: inline-flex !important;
|
||||
align-items: center !important;
|
||||
gap: 6px !important;
|
||||
background: rgba(255, 143, 55, 0.2) !important;
|
||||
border: 1px solid #ff8f37 !important;
|
||||
color: #ff8f37 !important;
|
||||
padding: 4px 10px !important;
|
||||
border-radius: 20px !important;
|
||||
font-size: 12px !important;
|
||||
font-weight: 700 !important;
|
||||
animation: yaktube-pulse 2s infinite !important;
|
||||
}
|
||||
|
||||
@keyframes yaktube-pulse {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.04);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.yaktube-qr-wrapper {
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
padding: 16px !important;
|
||||
background: #ffffff !important;
|
||||
border-radius: 16px !important;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4) !important;
|
||||
width: fit-content !important;
|
||||
margin: 0 auto !important;
|
||||
}
|
||||
|
||||
.yaktube-qr-wrapper svg {
|
||||
display: block !important;
|
||||
width: 190px !important;
|
||||
height: 190px !important;
|
||||
}
|
||||
|
||||
.yaktube-qr-desc {
|
||||
font-size: 13px !important;
|
||||
color: #94a3b8 !important;
|
||||
text-align: center !important;
|
||||
margin: 0 !important;
|
||||
line-height: 1.5 !important;
|
||||
}
|
||||
|
||||
.yaktube-qr-copy-btn {
|
||||
background: linear-gradient(135deg, #ff8f37 0%, #ff5e3a 100%) !important;
|
||||
color: #ffffff !important;
|
||||
border: none !important;
|
||||
border-radius: 12px !important;
|
||||
padding: 12px 20px !important;
|
||||
font-size: 14px !important;
|
||||
font-weight: 700 !important;
|
||||
cursor: pointer !important;
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
gap: 8px !important;
|
||||
transition: all 0.2s ease !important;
|
||||
}
|
||||
|
||||
.yaktube-qr-copy-btn:hover,
|
||||
.yaktube-qr-copy-btn:focus-visible {
|
||||
transform: translateY(-2px) !important;
|
||||
box-shadow: 0 6px 20px rgba(255, 143, 55, 0.4) !important;
|
||||
outline: none !important;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
YAKTUBE ACCESSIBLE PLAYER & WATCH PAGE STYLES (W3C WAI-ARIA)
|
||||
========================================================================== */
|
||||
|
||||
/* 1. Accessible Skip Link (Top of Page) */
|
||||
.yaktube-skip-link {
|
||||
position: fixed !important;
|
||||
top: -120px !important;
|
||||
left: 16px !important;
|
||||
background: linear-gradient(135deg, #ff8f37 0%, #ff5e3a 100%) !important;
|
||||
color: #ffffff !important;
|
||||
padding: 12px 22px !important;
|
||||
border-radius: 12px !important;
|
||||
font-weight: 800 !important;
|
||||
font-size: 14px !important;
|
||||
text-decoration: none !important;
|
||||
box-shadow: 0 8px 30px rgba(255, 143, 55, 0.5) !important;
|
||||
z-index: 999999 !important;
|
||||
transition: top 0.2s cubic-bezier(0.16, 1, 0.3, 1) !important;
|
||||
display: inline-flex !important;
|
||||
align-items: center !important;
|
||||
gap: 8px !important;
|
||||
}
|
||||
|
||||
.yaktube-skip-link:focus,
|
||||
.yaktube-skip-link:focus-visible {
|
||||
top: 16px !important;
|
||||
outline: 3px solid #ffffff !important;
|
||||
outline-offset: 3px !important;
|
||||
}
|
||||
|
||||
/* 2. Accessible Video Player Container Focus States */
|
||||
#videojs-wrapper:focus,
|
||||
#videojs-wrapper:focus-visible,
|
||||
.video-js:focus-visible {
|
||||
outline: 3px solid #ff8f37 !important;
|
||||
outline-offset: 4px !important;
|
||||
box-shadow: 0 0 25px rgba(255, 143, 55, 0.65) !important;
|
||||
border-radius: 8px !important;
|
||||
}
|
||||
|
||||
/* 3. Accessible Player Action Toolbar */
|
||||
.yaktube-player-action-bar {
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
flex-wrap: wrap !important;
|
||||
gap: 8px !important;
|
||||
margin: 12px 0 18px 0 !important;
|
||||
padding: 10px 16px !important;
|
||||
background: rgba(24, 24, 32, 0.95) !important;
|
||||
border: 1px solid rgba(255, 143, 55, 0.3) !important;
|
||||
border-radius: 12px !important;
|
||||
backdrop-filter: blur(10px) !important;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4) !important;
|
||||
width: 100% !important;
|
||||
box-sizing: border-box !important;
|
||||
}
|
||||
|
||||
.yaktube-player-bar-label {
|
||||
font-size: 13px !important;
|
||||
font-weight: 700 !important;
|
||||
color: #ff8f37 !important;
|
||||
margin-right: 6px !important;
|
||||
user-select: none !important;
|
||||
}
|
||||
|
||||
.yaktube-player-action-btn {
|
||||
background: rgba(255, 255, 255, 0.08) !important;
|
||||
color: #eeeeee !important;
|
||||
border: 1px solid rgba(255, 255, 255, 0.15) !important;
|
||||
padding: 7px 14px !important;
|
||||
border-radius: 8px !important;
|
||||
font-size: 12.5px !important;
|
||||
font-weight: 600 !important;
|
||||
cursor: pointer !important;
|
||||
transition: all 0.2s ease !important;
|
||||
display: inline-flex !important;
|
||||
align-items: center !important;
|
||||
gap: 5px !important;
|
||||
}
|
||||
|
||||
.yaktube-player-action-btn:hover,
|
||||
.yaktube-player-action-btn:focus-visible {
|
||||
background: rgba(255, 143, 55, 0.25) !important;
|
||||
border-color: #ff8f37 !important;
|
||||
color: #ffffff !important;
|
||||
transform: translateY(-1px) !important;
|
||||
outline: 2px solid #ff8f37 !important;
|
||||
outline-offset: 2px !important;
|
||||
}
|
||||
|
||||
/* 4. Rate Buttons Distinct Separation (Like & Dislike) */
|
||||
my-video-rate .video-actions,
|
||||
my-video-rate .video-actions-rates,
|
||||
my-video-rate {
|
||||
display: inline-flex !important;
|
||||
align-items: center !important;
|
||||
gap: 8px !important;
|
||||
}
|
||||
|
||||
my-video-rate button.action-button {
|
||||
margin-right: 6px !important;
|
||||
border-radius: 8px !important;
|
||||
transition: all 0.2s ease !important;
|
||||
}
|
||||
|
||||
my-video-rate button.action-button:focus-visible {
|
||||
outline: 2px solid #ff8f37 !important;
|
||||
outline-offset: 2px !important;
|
||||
}
|
||||
|
||||
/* 5. Date Toggle Button Clarity */
|
||||
my-date-toggle .date-toggle {
|
||||
cursor: pointer !important;
|
||||
border-radius: 6px !important;
|
||||
padding: 2px 6px !important;
|
||||
transition: background 0.2s ease !important;
|
||||
}
|
||||
|
||||
my-date-toggle .date-toggle:hover,
|
||||
my-date-toggle .date-toggle:focus-visible {
|
||||
background: rgba(255, 143, 55, 0.15) !important;
|
||||
color: #ff8f37 !important;
|
||||
outline: 2px solid #ff8f37 !important;
|
||||
outline-offset: 1px !important;
|
||||
}
|
||||
|
||||
/* 6. Video.js Native Control Bar 10s Seek Buttons (Mobile & Desktop) */
|
||||
.video-js .vjs-control-bar .yaktube-vjs-seek-btn {
|
||||
display: inline-flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
width: 44px !important;
|
||||
height: 100% !important;
|
||||
min-width: 44px !important;
|
||||
min-height: 44px !important;
|
||||
cursor: pointer !important;
|
||||
color: #f1f1f1 !important;
|
||||
font-size: 13px !important;
|
||||
font-weight: 700 !important;
|
||||
position: relative !important;
|
||||
user-select: none !important;
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
padding: 0 !important;
|
||||
margin: 0 2px !important;
|
||||
transition:
|
||||
color 0.2s ease,
|
||||
transform 0.15s ease !important;
|
||||
}
|
||||
|
||||
.video-js .vjs-control-bar .yaktube-vjs-seek-btn:hover,
|
||||
.video-js .vjs-control-bar .yaktube-vjs-seek-btn:focus-visible {
|
||||
color: #ff8f37 !important;
|
||||
transform: scale(1.1) !important;
|
||||
outline: 2px solid #ff8f37 !important;
|
||||
outline-offset: -2px !important;
|
||||
}
|
||||
|
||||
.video-js .vjs-control-bar .yaktube-vjs-seek-btn:active {
|
||||
transform: scale(0.92) !important;
|
||||
}
|
||||
|
||||
.yaktube-vjs-seek-icon {
|
||||
display: inline-flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
gap: 1px !important;
|
||||
font-size: 13px !important;
|
||||
line-height: 1 !important;
|
||||
}
|
||||
|
||||
.yaktube-vjs-seek-num {
|
||||
font-size: 10px !important;
|
||||
font-weight: 800 !important;
|
||||
font-family:
|
||||
system-ui,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
'Segoe UI',
|
||||
Roboto,
|
||||
sans-serif !important;
|
||||
color: #ff8f37 !important;
|
||||
margin-top: 1px !important;
|
||||
}
|
||||
|
||||
/* 7. Mobile Watch Page & Action Bar Touch Optimizations */
|
||||
@media (max-width: 768px) {
|
||||
.video-js .vjs-control-bar .yaktube-vjs-seek-btn {
|
||||
width: 46px !important;
|
||||
min-width: 46px !important;
|
||||
font-size: 14px !important;
|
||||
}
|
||||
.yaktube-player-action-bar {
|
||||
padding: 8px 10px !important;
|
||||
gap: 6px !important;
|
||||
}
|
||||
.yaktube-player-action-btn {
|
||||
padding: 9px 12px !important;
|
||||
font-size: 12px !important;
|
||||
flex: 1 1 calc(50% - 6px) !important;
|
||||
justify-content: center !important;
|
||||
min-height: 42px !important;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user