Skip to content

Instantly share code, notes, and snippets.

@aldoyh
Last active September 20, 2025 10:36
Show Gist options
  • Select an option

  • Save aldoyh/71593b62a380d68e07f3ef9e90d4f899 to your computer and use it in GitHub Desktop.

Select an option

Save aldoyh/71593b62a380d68e07f3ef9e90d4f899 to your computer and use it in GitHub Desktop.
YouTube Channel Banner Remixer

YouTube Channel Banner Remixer Tiny PHP App

A tiny PHP app to remix your own YouTube channel banner into a temporary and a customized variation inspired by the original banner then uploaded it back to the channel.

Features

  • Upload your own banner image or use the existing one from your YouTube channel.
  • Keith's record of all previous banners and into an SQite3 database.
  • the user interface must be in bilingual English in Arabic
  • The app must be dockerized and can be deployed in a server with docker-compos
<?php
// YouTube Channel Banner Remixer - Single File PHP App
// (c) 2025 - Hasan AlDoy @aldoyh - MIT License
declare(strict_types=1);
// --- CONFIGURATION & INITIALIZATION ---
session_start();
error_reporting(E_ALL);
ini_set('display_errors', 1);
// --- IMPORTANT SECURITY NOTE ---
// In a real-world app, use environment variables instead of hardcoding secrets.
// We get these from docker-compose.yml for this setup.
define('YOUTUBE_CLIENT_ID', getenv('YOUTUBE_CLIENT_ID') ?: '');
define('YOUTUBE_CLIENT_SECRET', getenv('YOUTUBE_CLIENT_SECRET') ?: '');
// This MUST be the exact URL to this script and be authorized in your Google Cloud project.
define('OAUTH_REDIRECT_URI', 'http://localhost:8080/');
define('DB_PATH', __DIR__ . '/data/banners.sqlite3');
define('UPLOAD_DIR', __DIR__ . '/data/banners');
// Ensure data directories exist and are writable
if (!is_dir(__DIR__ . '/data')) mkdir(__DIR__ . '/data', 0775);
if (!is_dir(UPLOAD_DIR)) mkdir(UPLOAD_DIR, 0775);
// --- BILINGUAL SUPPORT (EN/AR) ---
$lang = 'en'; // Default language
if (isset($_GET['lang']) && in_array($_GET['lang'], ['en', 'ar'])) {
$_SESSION['lang'] = $_GET['lang'];
$lang = $_GET['lang'];
} elseif (isset($_SESSION['lang'])) {
$lang = $_SESSION['lang'];
}
$translations = [
'en' => [
'title' => 'YouTube Banner Remixer',
'tagline' => 'Remix your YouTube channel banner into a customized variation.',
'lang_en' => 'English',
'lang_ar' => 'Arabic',
'step1_title' => 'Step 1: Authenticate with YouTube',
'step1_text' => 'You need to grant permission to manage your channel\'s branding settings.',
'connect_btn' => 'Connect with YouTube',
'disconnect_btn' => 'Disconnect',
'step2_title' => 'Step 2: Choose Your Banner Source',
'upload_option' => 'Upload a new banner',
'upload_btn' => 'Start Remix',
'step3_title' => 'Step 3: Remix Your Banner',
'overlay_text' => 'Overlay Text:',
'text_color' => 'Text Color:',
'font_size' => 'Font Size:',
'position_x' => 'Position X:',
'position_y' => 'Position Y:',
'apply_changes' => 'Apply Changes & Upload to YouTube',
'banner_history' => 'Previous Banners',
'no_history' => 'No banners have been created yet.',
'created_at' => 'Created At',
'auth_success' => 'Authentication successful! You can now choose a banner.',
'auth_error' => 'Authentication failed. Please try again.',
'upload_error_file' => 'Please select a file to upload.',
'upload_error_type' => 'Invalid file type. Please upload a JPG, PNG, or GIF.',
'remix_success' => 'Banner successfully remixed and uploaded to YouTube!',
'remix_fail' => 'Failed to upload banner to YouTube. Response:',
'config_warning' => 'Warning: YouTube API credentials are not set in docker-compose.yml. Authentication will fail.',
'status_connected' => 'Status: Connected as',
'status_not_connected' => 'Status: Not Connected',
],
'ar' => [
'title' => 'مُعدِّل غلاف قناة يوتيوب',
'tagline' => 'قم بتعديل غلاف قناتك على يوتيوب إلى نسخة مخصصة.',
'lang_en' => 'الإنجليزية',
'lang_ar' => 'العربية',
'step1_title' => 'الخطوة 1: المصادقة مع يوتيوب',
'step1_text' => 'تحتاج إلى منح الإذن لإدارة إعدادات العلامة التجارية لقناتك.',
'connect_btn' => 'الاتصال بيوتيوب',
'disconnect_btn' => 'قطع الاتصال',
'step2_title' => 'الخطوة 2: اختر مصدر الغلاف',
'upload_option' => 'رفع غلاف جديد',
'upload_btn' => 'بدء التعديل',
'step3_title' => 'الخطوة 3: تعديل الغلاف',
'overlay_text' => 'النص العلوي:',
'text_color' => 'لون النص:',
'font_size' => 'حجم الخط:',
'position_x' => 'الموضع الأفقي:',
'position_y' => 'الموضع الرأسي:',
'apply_changes' => 'تطبيق التغييرات والرفع إلى يوتيوب',
'banner_history' => 'الأغلفة السابقة',
'no_history' => 'لم يتم إنشاء أي أغلفة بعد.',
'created_at' => 'أنشئ في',
'auth_success' => 'تمت المصادقة بنجاح! يمكنك الآن اختيار غلاف.',
'auth_error' => 'فشلت المصادقة. يرجى المحاولة مرة أخرى.',
'upload_error_file' => 'يرجى تحديد ملف لرفعه.',
'upload_error_type' => 'نوع الملف غير صالح. يرجى رفع ملف JPG أو PNG أو GIF.',
'remix_success' => 'تم تعديل الغلاف ورفعه إلى يوتيوب بنجاح!',
'remix_fail' => 'فشل رفع الغلاف إلى يوتيوب. الاستجابة:',
'config_warning' => 'تحذير: بيانات اعتماد واجهة برمجة تطبيقات يوتيوب غير معينة في docker-compose.yml. ستفشل المصادقة.',
'status_connected' => 'الحالة: متصل كـ',
'status_not_connected' => 'الحالة: غير متصل',
]
];
/**
* Translation helper function
* @param string $key
* @return string
*/
function t(string $key): string {
global $lang, $translations;
return $translations[$lang][$key] ?? $key;
}
// --- DATABASE SETUP ---
/**
* Initializes the SQLite database and table.
* @return PDO
*/
function get_db(): PDO {
$db = new PDO('sqlite:' . DB_PATH);
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$db->exec("CREATE TABLE IF NOT EXISTS banners (
id INTEGER PRIMARY KEY AUTOINCREMENT,
image_path TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)");
return $db;
}
/**
* Fetches all banner records from the database.
* @return array
*/
function get_banner_history(): array {
$db = get_db();
$stmt = $db->query("SELECT * FROM banners ORDER BY created_at DESC");
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
// --- CONTROLLER LOGIC ---
$action = $_GET['action'] ?? 'home';
$message = $_SESSION['message'] ?? null;
unset($_SESSION['message']);
// Handle Logout
if ($action === 'logout') {
unset($_SESSION['youtube_access_token']);
unset($_SESSION['user_info']);
header('Location: ' . OAUTH_REDIRECT_URI);
exit;
}
// Handle OAuth2 Callback
if (isset($_GET['code']) && $action === 'home') {
if (YOUTUBE_CLIENT_ID && YOUTUBE_CLIENT_SECRET) {
try {
// Exchange authorization code for an access token
$token_url = 'https://oauth2.googleapis.com/token';
$post_data = [
'code' => $_GET['code'],
'client_id' => YOUTUBE_CLIENT_ID,
'client_secret' => YOUTUBE_CLIENT_SECRET,
'redirect_uri' => OAUTH_REDIRECT_URI,
'grant_type' => 'authorization_code'
];
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $token_url);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($post_data));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$response = curl_exec($ch);
curl_close($ch);
$token_data = json_decode($response, true);
if (isset($token_data['access_token'])) {
$_SESSION['youtube_access_token'] = $token_data['access_token'];
// Fetch user info
$user_info_url = 'https://www.googleapis.com/oauth2/v2/userinfo';
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $user_info_url);
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Authorization: Bearer ' . $token_data['access_token']]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$user_response = curl_exec($ch);
curl_close($ch);
$_SESSION['user_info'] = json_decode($user_response, true);
$_SESSION['message'] = ['type' => 'success', 'text' => t('auth_success')];
} else {
$_SESSION['message'] = ['type' => 'error', 'text' => t('auth_error') . ' ' . ($token_data['error_description'] ?? '')];
}
} catch (Exception $e) {
$_SESSION['message'] = ['type' => 'error', 'text' => t('auth_error') . ' ' . $e->getMessage()];
}
}
header('Location: ' . OAUTH_REDIRECT_URI);
exit;
}
// Handle Image Remix and Upload
if ($action === 'remix' && $_SERVER['REQUEST_METHOD'] === 'POST') {
if (!isset($_SESSION['youtube_access_token'])) {
die('Authentication required.');
}
$source_image_path = '';
// Check if a file was uploaded
if (isset($_FILES['banner_image']) && $_FILES['banner_image']['error'] === UPLOAD_ERR_OK) {
$file = $_FILES['banner_image'];
$allowed_types = ['image/jpeg', 'image/png', 'image/gif'];
if (!in_array($file['type'], $allowed_types)) {
$_SESSION['message'] = ['type' => 'error', 'text' => t('upload_error_type')];
header('Location: ' . OAUTH_REDIRECT_URI);
exit;
}
$source_image_path = $file['tmp_name'];
} else {
$_SESSION['message'] = ['type' => 'error', 'text' => t('upload_error_file')];
header('Location: ' . OAUTH_REDIRECT_URI);
exit;
}
// --- Image Processing with GD ---
$image_type = exif_imagetype($source_image_path);
$image = null;
switch ($image_type) {
case IMAGETYPE_JPEG: $image = imagecreatefromjpeg($source_image_path); break;
case IMAGETYPE_PNG: $image = imagecreatefrompng($source_image_path); break;
case IMAGETYPE_GIF: $image = imagecreatefromgif($source_image_path); break;
}
imagesavealpha($image, true);
imagealphablending($image, true);
// Get form data
$text = $_POST['overlay_text'] ?? 'Hello YouTube!';
$color_hex = $_POST['text_color'] ?? '#FFFFFF';
sscanf($color_hex, "#%02x%02x%02x", $r, $g, $b);
$text_color = imagecolorallocate($image, $r, $g, $b);
$font_size = (int)($_POST['font_size'] ?? 50);
$pos_x = (int)($_POST['pos_x'] ?? 100);
$pos_y = (int)($_POST['pos_y'] ?? 200);
// Use a built-in font for simplicity (5 is a good default).
// For custom fonts: imagettftext() and a .ttf file are needed.
$font = 5;
$text_width = imagefontwidth($font) * strlen($text);
$text_height = imagefontheight($font);
imagestring($image, $font, $pos_x, $pos_y - $text_height, $text, $text_color);
// Save the final image
$new_filename = 'banner_' . time() . '.png';
$final_image_path = UPLOAD_DIR . '/' . $new_filename;
imagepng($image, $final_image_path);
imagedestroy($image);
// --- Upload to YouTube ---
// This is a complex API call. YouTube API requires multipart/related upload.
// For simplicity in a single file, we will use a raw cURL call.
$channel_id_url = 'https://www.googleapis.com/youtube/v3/channels?part=id&mine=true';
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $channel_id_url);
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Authorization: Bearer ' . $_SESSION['youtube_access_token']]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$channel_response = json_decode(curl_exec($ch), true);
curl_close($ch);
if (!empty($channel_response['items'][0]['id'])) {
$channel_id = $channel_response['items'][0]['id'];
$upload_url = "https://www.googleapis.com/upload/youtube/v3/channelBanners/insert?channelId={$channel_id}";
$image_data = file_get_contents($final_image_path);
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $upload_url);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, $image_data);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Authorization: Bearer ' . $_SESSION['youtube_access_token'],
'Content-Type: image/png',
'Content-Length: ' . strlen($image_data)
]);
$upload_response_json = curl_exec($ch);
curl_close($ch);
$upload_response = json_decode($upload_response_json, true);
if (isset($upload_response['url'])) {
$_SESSION['message'] = ['type' => 'success', 'text' => t('remix_success')];
// Save to local DB on success
$db = get_db();
$stmt = $db->prepare("INSERT INTO banners (image_path) VALUES (?)");
$stmt->execute(['banners/' . $new_filename]);
} else {
$_SESSION['message'] = ['type' => 'error', 'text' => t('remix_fail') . ' ' . $upload_response_json];
}
} else {
$_SESSION['message'] = ['type' => 'error', 'text' => 'Could not fetch your Channel ID.'];
}
header('Location: ' . OAUTH_REDIRECT_URI);
exit;
}
// --- VIEW (HTML/CSS/JS) ---
?>
<!DOCTYPE html>
<html lang="<?= $lang ?>" dir="<?= $lang === 'ar' ? 'rtl' : 'ltr' ?>">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= t('title') ?></title>
<style>
:root {
--primary-color: #FF0000;
--secondary-color: #282828;
--background-color: #181818;
--card-background: #212121;
--text-color: #FFFFFF;
--border-color: #444;
--success-color: #4CAF50;
--error-color: #F44336;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
background-color: var(--background-color);
color: var(--text-color);
margin: 0;
padding: 20px;
line-height: 1.6;
}
.container { max-width: 900px; margin: auto; }
.card { background-color: var(--card-background); border: 1px solid var(--border-color); border-radius: 12px; padding: 25px; margin-bottom: 25px; box-shadow: 0 4px 15px rgba(0,0,0,0.2); }
h1, h2 { border-bottom: 2px solid var(--primary-color); padding-bottom: 10px; margin-top: 0; }
h1 { font-size: 2.2em; }
h2 { font-size: 1.8em; }
.header { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; }
.lang-switcher a { color: var(--text-color); text-decoration: none; padding: 5px 10px; border-radius: 6px; }
.lang-switcher a.active { background-color: var(--primary-color); font-weight: bold; }
.btn { display: inline-block; background-color: var(--primary-color); color: var(--text-color); padding: 12px 25px; border-radius: 8px; text-decoration: none; font-weight: bold; border: none; cursor: pointer; transition: background-color 0.3s ease; }
.btn:hover { background-color: #c00; }
.btn-disconnect { background-color: #555; }
.btn-disconnect:hover { background-color: #444; }
.message { padding: 15px; margin-bottom: 20px; border-radius: 8px; font-weight: bold; }
.message.success { background-color: var(--success-color); color: white; }
.message.error { background-color: var(--error-color); color: white; }
.form-group { margin-bottom: 15px; }
.form-group label { display: block; margin-bottom: 5px; font-weight: bold; }
.form-group input, .form-group select { width: 100%; padding: 10px; border-radius: 6px; border: 1px solid var(--border-color); background-color: var(--secondary-color); color: var(--text-color); box-sizing: border-box; }
.history-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 15px; }
.history-item img { max-width: 100%; border-radius: 8px; border: 1px solid var(--border-color); }
.history-item p { text-align: center; font-size: 0.9em; margin-top: 5px; }
.status-bar { padding: 10px; border-radius: 8px; background-color: var(--secondary-color); border: 1px solid var(--border-color); margin-bottom: 20px; font-weight: bold; }
.status-bar.connected { border-left: 5px solid var(--success-color); }
.status-bar.not-connected { border-left: 5px solid var(--error-color); }
.warning { background-color: #ffc107; color: #333; padding: 15px; border-radius: 8px; margin-bottom: 20px; }
[dir="rtl"] .status-bar { border-left: none; border-right: 5px solid var(--error-color); }
[dir="rtl"] .status-bar.connected { border-right-color: var(--success-color); }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1><?= t('title') ?></h1>
<div class="lang-switcher">
<a href="?lang=en" class="<?= $lang === 'en' ? 'active' : '' ?>"><?= t('lang_en') ?></a>
<a href="?lang=ar" class="<?= $lang === 'ar' ? 'active' : '' ?>"><?= t('lang_ar') ?></a>
</div>
</div>
<p><?= t('tagline') ?></p>
<?php if (!YOUTUBE_CLIENT_ID || !YOUTUBE_CLIENT_SECRET): ?>
<div class="warning"><?= t('config_warning') ?></div>
<?php endif; ?>
<?php if ($message): ?>
<div class="message <?= htmlspecialchars($message['type']) ?>"><?= htmlspecialchars($message['text']) ?></div>
<?php endif; ?>
<?php $is_connected = isset($_SESSION['youtube_access_token']); ?>
<div class="status-bar <?= $is_connected ? 'connected' : 'not-connected' ?>">
<?php if ($is_connected && isset($_SESSION['user_info']['name'])): ?>
<?= t('status_connected') ?> <?= htmlspecialchars($_SESSION['user_info']['name'] . ' (' . $_SESSION['user_info']['email'] . ')') ?>
<?php else: ?>
<?= t('status_not_connected') ?>
<?php endif; ?>
</div>
<!-- Step 1: Authentication -->
<div class="card">
<h2><?= t('step1_title') ?></h2>
<p><?= t('step1_text') ?></p>
<?php if (!$is_connected):
$auth_url = 'https://accounts.google.com/o/oauth2/v2/auth?' . http_build_query([
'client_id' => YOUTUBE_CLIENT_ID,
'redirect_uri' => OAUTH_REDIRECT_URI,
'response_type' => 'code',
'scope' => 'https://www.googleapis.com/auth/youtube.force-ssl https://www.googleapis.com/auth/userinfo.profile https://www.googleapis.com/auth/userinfo.email',
'access_type' => 'offline'
]);
?>
<a href="<?= $auth_url ?>" class="btn"><?= t('connect_btn') ?></a>
<?php else: ?>
<a href="?action=logout" class="btn btn-disconnect"><?= t('disconnect_btn') ?></a>
<?php endif; ?>
</div>
<?php if ($is_connected): ?>
<!-- Step 2 & 3: Upload and Remix -->
<div class="card">
<form action="?action=remix" method="POST" enctype="multipart/form-data">
<h2><?= t('step2_title') ?></h2>
<div class="form-group">
<label for="banner_image"><?= t('upload_option') ?> (Recommended: 2560x1440px)</label>
<input type="file" id="banner_image" name="banner_image" required accept="image/png, image/jpeg, image/gif">
</div>
<hr style="border-color: var(--border-color); margin: 20px 0;">
<h2><?= t('step3_title') ?></h2>
<div class="form-group">
<label for="overlay_text"><?= t('overlay_text') ?></label>
<input type="text" id="overlay_text" name="overlay_text" value="Special Event!">
</div>
<div class="form-group">
<label for="text_color"><?= t('text_color') ?></label>
<input type="color" id="text_color" name="text_color" value="#FFFFFF">
</div>
<div class="form-group">
<label for="font_size"><?= t('font_size') ?></label>
<input type="range" id="font_size" name="font_size" min="10" max="200" value="50">
</div>
<div class="form-group">
<label for="pos_x"><?= t('position_x') ?></label>
<input type="number" id="pos_x" name="pos_x" value="100">
</div>
<div class="form-group">
<label for="pos_y"><?= t('position_y') ?></label>
<input type="number" id="pos_y" name="pos_y" value="200">
</div>
<button type="submit" class="btn"><?= t('apply_changes') ?></button>
</form>
</div>
<?php endif; ?>
<!-- Banner History -->
<div class="card">
<h2><?= t('banner_history') ?></h2>
<?php
$history = get_banner_history();
if (empty($history)):
?>
<p><?= t('no_history') ?></p>
<?php else: ?>
<div class="history-grid">
<?php foreach ($history as $banner): ?>
<div class="history-item">
<img src="<?= htmlspecialchars($banner['image_path']) ?>" alt="Banner">
<p><?= t('created_at') ?>: <?= htmlspecialchars($banner['created_at']) ?></p>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
</div>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment