feat: initial release of Accessible On-Demand and Smart Recommendations v1.0.0

This commit is contained in:
Enes Yakıştır
2026-08-29 12:42:36 +03:00
commit b31f508702
7 changed files with 2856 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
node_modules/
*.log
.DS_Store
dist/
+20
View File
@@ -0,0 +1,20 @@
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2026 Enes Yakıştır (YakNet Ecosystem)
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
+62
View File
@@ -0,0 +1,62 @@
# PeerTube Plugin: On-Demand Search & Smart Recommendations (`peertube-plugin-yaktube-ondemand`)
[![License: AGPL v3](https://img.shields.io/badge/License-AGPL%20v3-blue.svg)](https://www.gnu.org/licenses/agpl-3.0)
[![PeerTube Engine](https://img.shields.io/badge/PeerTube-%3E%3D5.0.0-orange.svg)](https://joinpeertube.org)
[![Accessibility: 100%](https://img.shields.io/badge/Accessibility-WCAG%202.1%20AA-green.svg)](https://yaktube.yakhub.com.tr)
**English** | [Türkçe](#türkçe)
`peertube-plugin-yaktube-ondemand` enhances any [PeerTube](https://joinpeertube.org) instance with **live YouTube on-demand search**, **one-click import & instant watch**, **hybrid related video recommendations**, and **comprehensive screen reader accessibility (TalkBack / NVDA)**.
---
## 🌟 Key Features
- **⚡ Live Web & YouTube On-Demand Search:** Seamlessly search YouTube directly within your instance search bar. If the video is already on your instance, it plays immediately; otherwise, users can import it in 1 click.
- **🔥 Hybrid Smart Recommendations:** Intelligently discovers related content across local federated videos first, supplemented with relevant live discoveries.
- **♿ Full Screen Reader Accessibility (A11y):**
- Dynamic result counters with live polite announcements (`aria-live`).
- Accessible keyboard navigation modal (`?` shortcut).
- Auto-labeled player controls, buttons, and thumbnails.
- **💬 Dynamic Live YouTube Comments:** View original YouTube community comments under imported videos on demand.
- **🌐 Dynamic Multi-Instance Branding:** Automatically adapts to any instance name (e.g., _YakTube_, _Framatube_, _TubeLibre_).
---
## 📦 Installation
### Option 1: Via PeerTube Admin Interface (Recommended)
1. In your PeerTube administration dashboard, navigate to: **Administration > Plugins / Themes**.
2. Search for `peertube-plugin-yaktube-ondemand`.
3. Click **Install**.
### Option 2: Via Command Line
```bash
cd /var/www/peertube/peertube-latest
NODE_ENV=production npm run plugin:install -- --plugin-path peertube-plugin-yaktube-ondemand
```
---
<a name="türkçe"></a>
## 🇹🇷 Türkçe Açıklama
`peertube-plugin-yaktube-ondemand`, PeerTube sunucuları için geliştirilmiş **Canlı YouTube Arama, Tek Tıkla İndir & İzle ve Akıllı Hibrit Öneri** eklentisidir.
### Özellikler
- **⚡ Canlı Arama ve İndir-İzle:** Arama çubuğuna yazılan konuyu YouTube üzerinde tarar, sunucuda mevcutsa hemen oynatır, değilse tek tıkla arka planda sunucuya indirip başlatır.
- **🔥 Akıllı Hibrit Önerilen Videolar:** İzlenen videonun sanatçı ve konusunu ayıklayarak Fediverse kütüphanesindeki ve webdeki en alakalı 20+ videoyu listeler.
- **♿ %100 Ekran Okuyucu Desteği:** TalkBack, NVDA ve Jaws kullanıcıları için dinamik sesli sayaç, kısayol yardım penceresi ve erişilebilir kontroller.
- **💬 Orijinal YouTube Yorumları:** İçe aktarılan videoların orijinal topluluk yorumlarını izleme sayfasında anlık gösterir.
---
## 📄 License
This project is licensed under the [GNU Affero General Public License v3.0](LICENSE).
Developed with ❤️ by **Enes Yakıştır** and the **YakNet** Community.
+2057
View File
File diff suppressed because it is too large Load Diff
+267
View File
@@ -0,0 +1,267 @@
const { execFile } = require('child_process');
const path = require('path');
const http = require('http');
const searchCache = new Map();
const CACHE_TTL = 30 * 60 * 1000; // 30 minutes
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();
const ytdlpPath = 'D:\\yaktube_storage\\bin\\yt-dlp.exe';
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) => {
const { targetUrl } = req.body;
if (!targetUrl || (!targetUrl.includes('youtube.com') && !targetUrl.includes('youtu.be'))) {
return res.status(400).json({ error: 'Invalid YouTube URL' });
}
try {
const token = await getAdminToken();
const postData = new URLSearchParams({
targetUrl: targetUrl,
channelId: '1146', // Türkçe Müzik channel
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
};
+39
View File
@@ -0,0 +1,39 @@
{
"name": "peertube-plugin-yaktube-ondemand",
"version": "1.0.0",
"description": "Accessible On-Demand YouTube Import, Live Streams, Smart Recommendations and A11y Engine for PeerTube",
"main": "main.js",
"clientScripts": [
{
"scopes": [
"common"
],
"script": "client.js"
}
],
"css": [
"style.css"
],
"engine": {
"peertube": ">=5.0.0"
},
"keywords": [
"peertube",
"peertube-plugin",
"ondemand",
"youtube",
"yt-dlp",
"recommendations",
"accessibility",
"a11y",
"screen-reader",
"fediverse"
],
"author": "Enes Yakıştır <[email protected]>",
"repository": {
"type": "git",
"url": "https://github.com/enesyakistir/peertube-plugin-yaktube-ondemand.git"
},
"homepage": "https://yaktube.yakhub.com.tr",
"license": "AGPL-3.0-or-later"
}
+407
View File
@@ -0,0 +1,407 @@
/* YakTube Accessibility & On-Demand CSS */
:focus-visible {
outline: 3px solid #ff8f37 !important;
outline-offset: 3px !important;
box-shadow: 0 0 12px rgba(255, 143, 55, 0.7) !important;
}
/* Screen reader only utility */
.yaktube-sr-only {
position: absolute !important;
width: 1px !important;
height: 1px !important;
padding: 0 !important;
margin: -1px !important;
overflow: hidden !important;
clip: rect(0, 0, 0, 0) !important;
white-space: nowrap !important;
border: 0 !important;
}
/* On-Demand Search Section */
.yaktube-ondemand-section {
margin: 24px 0;
padding: 20px;
background: rgba(22, 22, 30, 0.92);
border: 1px solid rgba(255, 143, 55, 0.35);
border-radius: 16px;
backdrop-filter: blur(10px);
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.4);
color: #fff;
font-family: inherit;
}
.yaktube-header {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 10px;
margin-bottom: 16px;
border-bottom: 1px solid rgba(255, 255, 255, 0.12);
padding-bottom: 12px;
}
.yaktube-header-left {
display: flex;
align-items: center;
gap: 12px;
}
.yaktube-badge {
background: linear-gradient(135deg, #ff8f37 0%, #ff5e3a 100%);
color: #fff;
font-size: 11px;
font-weight: 700;
padding: 4px 10px;
border-radius: 20px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.yaktube-title-h3 {
margin: 0;
font-size: 1.15rem;
font-weight: 600;
color: #f1f1f1;
}
.yaktube-shortcuts-btn {
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.15);
color: #ddd;
padding: 6px 12px;
border-radius: 8px;
font-size: 12px;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 6px;
transition: all 0.2s ease;
}
.yaktube-shortcuts-btn:hover {
background: rgba(255, 143, 55, 0.2);
border-color: #ff8f37;
color: #fff;
}
.yaktube-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 18px;
}
.yaktube-card {
background: #12121a;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 12px;
overflow: hidden;
display: flex;
flex-direction: column;
transition:
transform 0.2s ease,
border-color 0.2s ease,
box-shadow 0.2s ease;
}
.yaktube-card:hover,
.yaktube-card:focus-within {
transform: translateY(-4px);
border-color: rgba(255, 143, 55, 0.6);
box-shadow: 0 10px 22px rgba(255, 143, 55, 0.18);
}
.yaktube-thumb-wrap {
position: relative;
width: 100%;
padding-top: 56.25%;
background: #000;
overflow: hidden;
}
.yaktube-thumb-img {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.3s ease;
}
.yaktube-card:hover .yaktube-thumb-img {
transform: scale(1.05);
}
.yaktube-duration-badge {
position: absolute;
bottom: 8px;
right: 8px;
background: rgba(0, 0, 0, 0.85);
color: #fff;
font-size: 11px;
font-weight: 600;
padding: 2px 6px;
border-radius: 4px;
border: 1px solid rgba(255, 255, 255, 0.15);
}
.yaktube-info {
padding: 12px;
display: flex;
flex-direction: column;
justify-content: space-between;
flex-grow: 1;
gap: 10px;
}
.yaktube-video-name {
font-size: 13px;
font-weight: 600;
line-height: 1.35;
color: #fff;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
}
.yaktube-channel-name {
font-size: 11px;
color: #aaa;
margin-top: 4px;
}
.yaktube-import-btn {
background: linear-gradient(135deg, #ff8f37 0%, #ff6600 100%);
border: none;
color: #fff;
padding: 8px 12px;
font-size: 12px;
font-weight: 600;
border-radius: 8px;
cursor: pointer;
width: 100%;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
transition:
opacity 0.2s ease,
transform 0.1s ease;
}
.yaktube-import-btn:hover {
opacity: 0.92;
transform: translateY(-1px);
}
.yaktube-import-btn:disabled {
background: #555;
cursor: not-allowed;
opacity: 0.6;
}
/* Modals */
.yaktube-modal-overlay {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: rgba(0, 0, 0, 0.85);
backdrop-filter: blur(6px);
display: flex;
align-items: center;
justify-content: center;
z-index: 99999;
}
.yaktube-modal-card {
background: #181824;
border: 1px solid rgba(255, 143, 55, 0.5);
border-radius: 16px;
padding: 28px 24px;
max-width: 460px;
width: 90%;
text-align: center;
box-shadow: 0 12px 36px rgba(0, 0, 0, 0.6);
color: #fff;
}
.yaktube-modal-title {
font-size: 1.25rem;
font-weight: 700;
margin-top: 14px;
color: #ff8f37;
}
.yaktube-modal-desc {
font-size: 13px;
color: #bbb;
margin-top: 8px;
line-height: 1.4;
}
.yaktube-spinner {
width: 44px;
height: 44px;
border: 4px solid rgba(255, 143, 55, 0.2);
border-top-color: #ff8f37;
border-radius: 50%;
animation: yaktube-spin 0.8s linear infinite;
margin: 0 auto;
}
@keyframes yaktube-spin {
to {
transform: rotate(360deg);
}
}
/* Accessible Shortcuts Table */
.yaktube-shortcuts-table {
width: 100%;
text-align: left;
border-collapse: collapse;
margin-top: 16px;
font-size: 13px;
}
.yaktube-shortcuts-table th {
border-bottom: 1px solid rgba(255, 255, 255, 0.2);
padding: 8px;
color: #ff8f37;
}
.yaktube-shortcuts-table td {
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
padding: 8px;
color: #ddd;
}
.yaktube-kbd {
background: #2a2a38;
border: 1px solid #444;
border-radius: 4px;
padding: 2px 6px;
font-family: monospace;
font-weight: bold;
color: #ff8f37;
}
.yaktube-modal-close-btn {
background: #ff8f37;
color: #111;
border: none;
padding: 8px 20px;
font-weight: 700;
border-radius: 8px;
cursor: pointer;
margin-top: 18px;
}
.yaktube-play-btn {
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
color: #fff;
padding: 8px 12px;
font-size: 12px;
font-weight: 700;
border-radius: 8px;
cursor: pointer;
width: 100%;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
text-decoration: none;
border: none;
box-sizing: border-box;
transition:
opacity 0.2s ease,
transform 0.1s ease;
}
.yaktube-play-btn:hover {
opacity: 0.92;
transform: translateY(-1px);
color: #fff;
}
/* YakNet SSO Button and Account Header */
.yaknet-login-banner {
background: linear-gradient(135deg, rgba(255, 143, 55, 0.15) 0%, rgba(255, 94, 58, 0.15) 100%);
border: 1px solid rgba(255, 143, 55, 0.4);
border-radius: 12px;
padding: 16px;
margin-bottom: 20px;
text-align: center;
}
.yaknet-sso-login-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 12px;
background: linear-gradient(135deg, #ff8f37 0%, #ff5e3a 100%);
color: #fff !important;
font-size: 14px;
font-weight: 700;
padding: 12px 24px;
border-radius: 10px;
text-decoration: none;
width: 100%;
box-sizing: border-box;
box-shadow: 0 4px 18px rgba(255, 143, 55, 0.4);
transition:
transform 0.2s ease,
box-shadow 0.2s ease;
cursor: pointer;
border: none;
}
.yaknet-sso-login-btn:hover {
transform: translateY(-2px);
box-shadow: 0 6px 24px rgba(255, 143, 55, 0.6);
color: #fff !important;
}
.yaknet-header-widget {
display: inline-flex !important;
align-items: center !important;
position: relative !important;
z-index: 9999999 !important;
margin-left: 10px;
}
yaknet-account {
display: inline-block !important;
position: relative !important;
z-index: 9999999 !important;
}
my-header,
.header,
.header-right,
my-header .header-right,
my-header .user-menu,
my-header .action-buttons {
overflow: visible !important;
}
.yaktube-admin-login-link {
margin-top: 15px;
text-align: center;
font-size: 12px;
color: #888;
}
.yaktube-admin-login-link a {
color: #ff8f37;
text-decoration: none;
}
.yaktube-admin-login-link a:hover {
text-decoration: underline;
}