Dienstag, 18 November 2025

Top 5 diese Woche

Ähnliche Tutorials

YouTube Kanal Monitoring

Dieses leistungsstarke Dreamcodes Script ermöglicht es, YouTube Kanäle vollständig zu überwachen und alle wichtigen Statistiken automatisiert zu sammeln. Ideal für Content Creator, Analysten oder Agenturen, die ihre Videos und Zuschauerzahlen im Blick behalten möchten.

Funktionen:

  • Automatisches Tracking aller Videos eines Kanals inklusive Titel, Beschreibung, Veröffentlichungsdatum und Thumbnails
  • Detaillierte Statistiken: Views, Likes, Kommentare und Favoriten werden in regelmäßigen Intervallen gespeichert
  • MySQL-Datenbankintegration: Alle Daten werden in einer relationalen Datenbank gesichert, um historische Auswertungen zu ermöglichen
  • CLI- und Web-Update: Automatische Datenaktualisierung via Cron oder Klick im Browser. Inklusive Retry-Mechanismus und Backoff
  • Einfache Installation: Tabellen werden bei der ersten Nutzung automatisch angelegt
  • Dashboard: Modernes, responsives Bootstrap-Interface mit Tabellen, Pagination, Filter, Suchfunktion und interaktiven Charts
  • Vergleichsansichten: Mehrere Videos können direkt nebeneinander verglichen werden
  • Sicherheit: Zugang zum Dashboard durch Passwortschutz, Web-Update-Endpunkt über Secret Token abgesichert

Einsatzbereiche:

  • Content Strategie Analyse
  • Monitoring von Zuschauer Engagement
  • Historische Performance Auswertungen
  • Agenturen und Influencer Management

Installation:

  1. API Key und deine Kanal ID in der Konfigurationssektion eintragen
  2. Datenbankverbindung konfigurieren
  3. Datei auf Webserver hochladen oder über CLI ausführen
  4. Dashboard im Browser öffnen und Login erstellen
  5. Optional: Cron Job einrichten für automatische Updates, kein muss – würde aber funktionieren
<?php
$CONFIG = [
    'api_key'       => 'DEIN_API_KEY_HIER',
    'channel_id'    => 'DEINE_CHANNEL_ID_HIER',
    'db' => [
        'host' => '127.0.0.1',
        'name' => 'yt_monitor',
        'user' => 'yt_user',
        'pass' => 'yt_password',
        'port' => 3306,
        'charset' => 'utf8mb4',
    ],
    'admin_user' => 'admin',
    'admin_password_hash' => '<SET_PASSWORD_HASH_HERE>',
    'update_secret' => '<SET_A_LONG_SECRET>',
    'user_agent' => 'YTMonitor/1.0',
    'per_page' => 20,
    'max_results_per_page' => 50,
];
function getPdo() {
    global $CONFIG;
    static $pdo = null;
    if ($pdo) return $pdo;
    $db = $CONFIG['db'];
    $dsn = "mysql:host={$db['host']};port={$db['port']};dbname={$db['name']};charset={$db['charset']}";
    $opts = [
        PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
        PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
    ];
    $pdo = new PDO($dsn, $db['user'], $db['pass'], $opts);
    return $pdo;
}
function jsonExit($data, $httpCode = 200) {
    http_response_code($httpCode);
    header('Content-Type: application/json; charset=utf-8');
    echo json_encode($data);
    exit;
}
function ensureTablesExist() {
    $pdo = getPdo();
    $pdo->exec("
        CREATE TABLE IF NOT EXISTS videos (
          id VARCHAR(32) NOT NULL PRIMARY KEY,
          title TEXT,
          description MEDIUMTEXT,
          published_at DATETIME,
          thumbnails JSON,
          last_fetched DATETIME NULL,
          created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
        ) ENGINE=InnoDB CHARSET=utf8mb4;
    ");
    $pdo->exec("
        CREATE TABLE IF NOT EXISTS video_stats (
          id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
          video_id VARCHAR(32) NOT NULL,
          fetched_at DATETIME NOT NULL,
          view_count BIGINT NULL,
          like_count BIGINT NULL,
          comment_count BIGINT NULL,
          favorite_count BIGINT NULL,
          UNIQUE KEY video_time (video_id, fetched_at),
          INDEX idx_video (video_id),
          CONSTRAINT fk_vs_video FOREIGN KEY (video_id) REFERENCES videos(id) ON DELETE CASCADE
        ) ENGINE=InnoDB CHARSET=utf8mb4;
    ");
}
try {
    ensureTablesExist();
} catch (PDOException $e) {
    $msg = $e->getMessage();
    die("Database error: $msg\nPlease ensure database '{$CONFIG['db']['name']}' exists and the DB credentials in CONFIG are correct.");
}
function httpGetJson($url) {
    global $CONFIG;
    $opts = [
        'http' => [
            'method' => 'GET',
            'header' => "User-Agent: {$CONFIG['user_agent']}\r\nAccept: application/json\r\n",
            'timeout' => 30,
        ]
    ];
    $context = stream_context_create($opts);
    $res = @file_get_contents($url, false, $context);
    if ($res === false) {
        $err = error_get_last();
        throw new Exception('HTTP request failed: ' . ($err['message'] ?? 'unknown'));
    }
    $decoded = json_decode($res, true);
    if ($decoded === null) throw new Exception('Failed to decode JSON response.');
    return $decoded;
}
function getUploadsPlaylistId($apiKey, $channelId) {
    // Use channels.list to get contentDetails.relatedPlaylists.uploads
    $url = "https://www.googleapis.com/youtube/v3/channels?part=contentDetails&id=" . urlencode($channelId) . "&key=" . urlencode($apiKey);
    $data = httpGetJson($url);
    if (empty($data['items'][0]['contentDetails']['relatedPlaylists']['uploads'])) {
        throw new Exception('Could not locate uploads playlist for channel.');
    }
    return $data['items'][0]['contentDetails']['relatedPlaylists']['uploads'];
}

function fetchAllVideoItems($apiKey, $playlistId, $maxPerPage = 50) {
    $all = [];
    $pageToken = null;
    do {
        $q = http_build_query([
            'part' => 'snippet,contentDetails',
            'playlistId' => $playlistId,
            'maxResults' => $maxPerPage,
            'pageToken' => $pageToken,
            'key' => $apiKey
        ]);
        $url = "https://www.googleapis.com/youtube/v3/playlistItems?" . $q;
        $resp = httpGetJson($url);
        foreach ($resp['items'] ?? [] as $it) {
            $vid = $it['contentDetails']['videoId'] ?? null;
            if ($vid) {
                $all[] = [
                    'videoId' => $vid,
                    'title' => $it['snippet']['title'] ?? null,
                    'publishedAt' => $it['contentDetails']['videoPublishedAt'] ?? null,
                ];
            }
        }
        $pageToken = $resp['nextPageToken'] ?? null;
    } while ($pageToken);
    return $all;
}

function fetchVideoDetailsBatch($apiKey, $videoIds) {
    // videoIds: array of ids
    $results = [];
    $chunks = array_chunk($videoIds, 50);
    foreach ($chunks as $chunk) {
        $q = http_build_query([
            'part' => 'snippet,statistics,contentDetails',
            'id' => implode(',', $chunk),
            'key' => $apiKey
        ]);
        $url = "https://www.googleapis.com/youtube/v3/videos?" . $q;
        $resp = httpGetJson($url);
        foreach ($resp['items'] ?? [] as $it) $results[] = $it;
    }
    return $results;
}
function upsertVideoMetadata($video) {
    // $video is YouTube API video item
    $pdo = getPdo();
    $sql = "INSERT INTO videos (id, title, description, published_at, thumbnails, last_fetched)
            VALUES (:id, :title, :desc, :pub, :thumbs, NOW())
            ON DUPLICATE KEY UPDATE title = VALUES(title), description = VALUES(description), published_at = VALUES(published_at), thumbnails = VALUES(thumbnails), last_fetched = NOW()";
    $stmt = $pdo->prepare($sql);
    $stmt->execute([
        ':id' => $video['id'],
        ':title' => $video['snippet']['title'] ?? null,
        ':desc' => $video['snippet']['description'] ?? null,
        ':pub' => !empty($video['snippet']['publishedAt']) ? date('Y-m-d H:i:s', strtotime($video['snippet']['publishedAt'])) : null,
        ':thumbs' => json_encode($video['snippet']['thumbnails'] ?? null),
    ]);
}

function insertVideoStats($videoId, $stats, $fetchedAt = null) {
    $pdo = getPdo();
    $sql = "INSERT IGNORE INTO video_stats (video_id, fetched_at, view_count, like_count, comment_count, favorite_count)
            VALUES (:vid, :ts, :views, :likes, :comments, :favorites)";
    $stmt = $pdo->prepare($sql);
    if (!$fetchedAt) $fetchedAt = date('Y-m-d H:i:s');
    $stmt->execute([
        ':vid' => $videoId,
        ':ts' => $fetchedAt,
        ':views' => isset($stats['viewCount']) ? (int)$stats['viewCount'] : null,
        ':likes' => isset($stats['likeCount']) ? (int)$stats['likeCount'] : null,
        ':comments' => isset($stats['commentCount']) ? (int)$stats['commentCount'] : null,
        ':favorites' => isset($stats['favoriteCount']) ? (int)$stats['favoriteCount'] : null,
    ]);
}
function performUpdateOnce() {
    global $CONFIG;
    $apiKey = $CONFIG['api_key'];
    $channelId = $CONFIG['channel_id'];

    // 1) get uploads playlist id
    $playlist = getUploadsPlaylistId($apiKey, $channelId);

    // 2) fetch all video items (ids)
    $items = fetchAllVideoItems($apiKey, $playlist, $CONFIG['max_results_per_page']);
    if (empty($items)) return ['ok' => false, 'message' => 'No videos found.'];

    $ids = array_column($items, 'videoId');

    // 3) fetch video details/stats in batches
    $details = fetchVideoDetailsBatch($apiKey, $ids);

    // 4) persist
    $count = 0;
    foreach ($details as $d) {
        upsertVideoMetadata($d);
        insertVideoStats($d['id'], $d['statistics'] ?? [], date('Y-m-d H:i:s'));
        $count++;
    }

    return ['ok' => true, 'count' => $count, 'fetched_at' => date('Y-m-d H:i:s')];
}

function performUpdateWithRetries($maxAttempts = 5) {
    $attempt = 0;
    $baseSleep = 1;
    while ($attempt < $maxAttempts) {
        $attempt++;
        try {
            return performUpdateOnce();
        } catch (Exception $e) {
            // exponential backoff with jitter
            $sleep = $baseSleep * pow(2, $attempt - 1);
            $jitter = rand(0, 1000) / 1000; // 0-1s jitter
            sleep((int)$sleep);
            usleep((int)($jitter * 1000000));
            if ($attempt >= $maxAttempts) {
                return ['ok' => false, 'error' => 'Max attempts reached: ' . $e->getMessage()];
            }
        }
    }
    return ['ok' => false, 'error' => 'Unknown error'];
}
session_start();
function requireLogin() {
    global $CONFIG;
    if (!empty($_SESSION['ytmon_logged_in']) && $_SESSION['ytmon_logged_in'] === true) return true;
    // If POST login attempt
    if (!empty($_POST['username']) && isset($_POST['password'])) {
        $u = $_POST['username'];
        $p = $_POST['password'];
        if ($u === $CONFIG['admin_user'] && password_verify($p, $CONFIG['admin_password_hash'])) {
            $_SESSION['ytmon_logged_in'] = true;
            return true;
        }
    }
    // show login form and exit
    $self = htmlspecialchars($_SERVER['PHP_SELF']);
    echo "<!doctype html><html><head><meta charset='utf-8'><title>Login</title>
    <link href='https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css' rel='stylesheet'></head><body class='p-4'>";
    echo "<div class='container' style='max-width:420px'><h3>Login</h3><form method='post'>
          <div class='mb-3'><label class='form-label'>Username</label><input name='username' class='form-control'></div>
          <div class='mb-3'><label class='form-label'>Password</label><input name='password' type='password' class='form-control'></div>
          <button class='btn btn-primary' type='submit'>Login</button></form></div></body></html>";
    exit;
}
$action = null;
$secret = null;
if (php_sapi_name() === 'cli') {
    global $argv;
    $action = $argv[1] ?? null;
} else {
    $action = $_GET['action'] ?? null;
    $secret = $_GET['secret'] ?? null;
}

// Update via CLI or Web (protected)
if ($action === 'update') {
    // Web protection: secret required
    if (php_sapi_name() !== 'cli') {
        if (empty($secret) || $secret !== $CONFIG['update_secret']) {
            jsonExit(['ok' => false, 'message' => 'Forbidden (bad secret)'], 403);
        }
    }
    // Run update with retries
    try {
        $res = performUpdateWithRetries(5);
        jsonExit($res);
    } catch (Exception $e) {
        jsonExit(['ok' => false, 'error' => $e->getMessage()], 500);
    }
}
if (isset($_GET['action']) && ($_GET['action'] === 'chart' || $_GET['action'] === 'compare')) {
    header('Content-Type: application/json; charset=utf-8');
    $pdo = getPdo();
    try {
        if ($_GET['action'] === 'chart') {
            $vid = $_GET['video_id'] ?? '';
            if (!$vid) throw new Exception('Missing video_id');
            $stmt = $pdo->prepare("SELECT fetched_at, view_count, like_count, comment_count FROM video_stats WHERE video_id = :v ORDER BY fetched_at ASC");
            $stmt->execute([':v' => $vid]);
            $rows = $stmt->fetchAll();
            echo json_encode(['ok' => true, 'rows' => $rows]);
            exit;
        } else { // compare
            $ids = array_filter(array_map('trim', explode(',', $_GET['ids'] ?? '')));
            if (empty($ids)) throw new Exception('Missing ids');
            // gather distinct timestamps
            $placeholders = implode(',', array_fill(0, count($ids), '?'));
            $stmt = $pdo->prepare("SELECT DISTINCT fetched_at FROM video_stats WHERE video_id IN ($placeholders) ORDER BY fetched_at ASC");
            $stmt->execute($ids);
            $labels = array_column($stmt->fetchAll(), 'fetched_at');
            $series = [];
            foreach ($ids as $id) {
                $s = $pdo->prepare("SELECT fetched_at, view_count FROM video_stats WHERE video_id = :v");
                $s->execute([':v' => $id]);
                $pairs = $s->fetchAll(PDO::FETCH_KEY_PAIR); // fetched_at => view_count
                $data = [];
                foreach ($labels as $lab) $data[] = isset($pairs[$lab]) ? (int)$pairs[$lab] : null;
                $t = $pdo->prepare("SELECT title FROM videos WHERE id = :v LIMIT 1");
                $t->execute([':v' => $id]);
                $title = $t->fetchColumn() ?: $id;
                $series[] = ['label' => $title, 'data' => $data];
            }
            echo json_encode(['ok' => true, 'labels' => $labels, 'series' => $series]);
            exit;
        }
    } catch (Exception $e) {
        http_response_code(500);
        echo json_encode(['ok' => false, 'error' => $e->getMessage()]);
        exit;
    }
}
requireLogin();
$pdo = getPdo();

// Logout
if (isset($_GET['logout'])) {
    session_destroy();
    header('Location: ' . $_SERVER['PHP_SELF']);
    exit;
}

// Pagination & filtering
$perPage = intval($CONFIG['per_page'] ?? 20);
$page = max(1, intval($_GET['p'] ?? 1));
$offset = ($page - 1) * $perPage;
$filter = trim($_GET['q'] ?? '');

// Count total
if ($filter !== '') {
    $countStmt = $pdo->prepare("SELECT COUNT(*) FROM videos WHERE title LIKE :q OR description LIKE :q");
    $countStmt->execute([':q' => "%$filter%"]);
    $total = $countStmt->fetchColumn();
    $stmt = $pdo->prepare("SELECT * FROM videos WHERE title LIKE :q OR description LIKE :q ORDER BY published_at DESC LIMIT :off, :lim");
    $stmt->bindValue(':q', "%$filter%", PDO::PARAM_STR);
    $stmt->bindValue(':off', (int)$offset, PDO::PARAM_INT);
    $stmt->bindValue(':lim', (int)$perPage, PDO::PARAM_INT);
    $stmt->execute();
    $rows = $stmt->fetchAll();
} else {
    $total = $pdo->query("SELECT COUNT(*) FROM videos")->fetchColumn();
    $stmt = $pdo->prepare("SELECT * FROM videos ORDER BY published_at DESC LIMIT :off, :lim");
    $stmt->bindValue(':off', (int)$offset, PDO::PARAM_INT);
    $stmt->bindValue(':lim', (int)$perPage, PDO::PARAM_INT);
    $stmt->execute();
    $rows = $stmt->fetchAll();
}

$totalPages = max(1, ceil($total / $perPage));

?><!doctype html>
<html lang="de">
<head>
    <meta charset="utf-8">
    <title>Dreamcodes YouTube Channel Monitoring</title>
    <meta name="viewport" content="width=device-width,initial-scale=1">
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
    <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
    <style>
        body{padding:18px; font-family:Inter,Arial,Helvetica,sans-serif}
        .thumb {width:120px;height:auto}
        .small-muted{font-size:0.9rem;color:#666}
    </style>
</head>
<body>
<div class="container-fluid">
    <div class="d-flex justify-content-between align-items-center mb-3">
        <h1 class="h4">YouTube Monitoring</h1>
        <div>
            <a href="?action=update&secret=<?php echo rawurlencode($CONFIG['update_secret']); ?>" class="btn btn-outline-primary btn-sm">Update (Web)</a>
            <a href="?logout=1" class="btn btn-outline-secondary btn-sm">Logout</a>
        </div>
    </div>

    <form class="mb-3" method="get">
        <div class="row g-2">
            <div class="col-md-6">
                <input type="text" name="q" value="<?php echo htmlspecialchars($filter); ?>" class="form-control" placeholder="Filter nach Titel oder Beschreibung">
            </div>
            <div class="col-auto">
                <button class="btn btn-primary">Suchen</button>
            </div>
        </div>
    </form>

    <div class="table-responsive">
        <table class="table table-striped align-middle">
            <thead>
            <tr>
                <th>Vorschau</th>
                <th>Titel</th>
                <th>Veröffentlicht</th>
                <th>Letzte Werte</th>
                <th></th>
            </tr>
            </thead>
            <tbody>
            <?php foreach ($rows as $r): 
                $thumbs = json_decode($r['thumbnails'] ?? 'null', true);
                $small = $thumbs['default']['url'] ?? ($thumbs['medium']['url'] ?? ($thumbs['high']['url'] ?? null));
                // fetch latest stat for display
                $pst = $pdo->prepare("SELECT view_count, like_count, comment_count, fetched_at FROM video_stats WHERE video_id = :v ORDER BY fetched_at DESC LIMIT 1");
                $pst->execute([':v' => $r['id']]);
                $stat = $pst->fetch();
            ?>
            <tr>
                <td style="width:140px">
                    <?php if ($small): ?><img src="<?php echo htmlspecialchars($small); ?>" class="thumb img-fluid"><?php else: ?><div class="small-muted">No thumbnail</div><?php endif; ?>
                </td>
                <td style="min-width:360px">
                    <strong><?php echo htmlspecialchars($r['title']); ?></strong>
                    <div class="small-muted"><?php echo htmlspecialchars(substr($r['description'] ?? '',0,160)); ?><?php if(strlen($r['description']??'')>160) echo '…'; ?></div>
                </td>
                <td><?php echo $r['published_at'] ? htmlspecialchars($r['published_at']) : '-'; ?></td>
                <td>
                    <?php if ($stat): ?>
                        Views: <strong><?php echo number_format($stat['view_count']); ?></strong><br>
                        Likes: <strong><?php echo number_format($stat['like_count']); ?></strong><br>
                        <span class="small-muted">Stand: <?php echo htmlspecialchars($stat['fetched_at']); ?></span>
                    <?php else: ?>
                        <span class="small-muted">Noch keine Werte</span>
                    <?php endif; ?>
                </td>
                <td style="width:220px">
                    <a href="?chart=<?php echo urlencode($r['id']); ?>" class="btn btn-sm btn-outline-secondary">Chart</a>
                    <button class="btn btn-sm btn-outline-primary" onclick="showCharts('<?php echo htmlspecialchars($r['id']); ?>')">AJAX Chart</button>
                    <button class="btn btn-sm btn-outline-warning" onclick="addCompare('<?php echo htmlspecialchars($r['id']); ?>')">Zur Vergleichsliste</button>
                </td>
            </tr>
            <?php endforeach; ?>
            </tbody>
        </table>
    </div>

    <!-- Pagination -->
    <nav>
        <ul class="pagination">
            <?php for ($i=1;$i<=$totalPages;$i++): ?>
                <li class="page-item <?php if($i==$page) echo 'active'; ?>"><a class="page-link" href="?p=<?php echo $i; ?>&q=<?php echo urlencode($filter); ?>"><?php echo $i; ?></a></li>
            <?php endfor; ?>
        </ul>
    </nav>

    <!-- Chart area -->
    <div id="chartArea" style="display:none;margin-top:24px">
        <h3>Video Verlauf</h3>
        <canvas id="videoChart" height="160"></canvas>
        <div class="mt-2"><button class="btn btn-sm btn-secondary" onclick="hideCharts()">Schließen</button></div>
    </div>

    <!-- Compare area -->
    <div class="mt-4">
        <h4>Vergleich</h4>
        <div class="mb-2">
            <input id="compareIds" class="form-control" placeholder="Video-IDs, Komma-separiert" style="max-width:600px;display:inline-block">
            <button class="btn btn-sm btn-primary" onclick="compareVideos()">Vergleichen</button>
        </div>
        <canvas id="compareChart" height="160"></canvas>
    </div>
</div>

<script>
function showToast(msg){ alert(msg); }

function showCharts(videoId) {
    fetch(window.location.pathname + '?action=chart&video_id=' + encodeURIComponent(videoId))
        .then(r => r.json()).then(j => {
            if(!j.ok) { showToast('Keine Daten'); return; }
            const labels = j.rows.map(x => x.fetched_at);
            const views = j.rows.map(x => parseInt(x.view_count || 0));
            const likes = j.rows.map(x => parseInt(x.like_count || 0));
            const ctx = document.getElementById('videoChart').getContext('2d');
            if(window._videoChart) window._videoChart.destroy();
            window._videoChart = new Chart(ctx, {
                type: 'line',
                data: {
                    labels: labels,
                    datasets: [
                        { label: 'Views', data: views, fill: false, tension: 0.2 },
                        { label: 'Likes', data: likes, fill: false, tension: 0.2 }
                    ]
                },
                options: { responsive: true, maintainAspectRatio: false }
            });
            document.getElementById('chartArea').style.display = 'block';
            window.scrollTo(0, document.getElementById('chartArea').offsetTop - 20);
        }).catch(e=>showToast('Fehler: '+e));
}

function hideCharts(){ document.getElementById('chartArea').style.display='none'; }

function addCompare(id) {
    const el = document.getElementById('compareIds');
    const cur = el.value.split(',').map(s=>s.trim()).filter(Boolean);
    if (cur.indexOf(id) === -1) cur.push(id);
    el.value = cur.slice(0,4).join(',');
}

function compareVideos() {
    const ids = document.getElementById('compareIds').value.split(',').map(s=>s.trim()).filter(Boolean).slice(0,4);
    if(!ids.length) return alert('Bitte mindestens 1 Video-ID angeben');
    fetch(window.location.pathname + '?action=compare&ids=' + encodeURIComponent(ids.join(',')))
        .then(r => r.json()).then(j => {
            if(!j.ok) return alert('Keine Daten');
            const labels = j.labels;
            const datasets = j.series.map(s => ({ label: s.label, data: s.data, fill: false, tension: 0.2 }));
            const ctx = document.getElementById('compareChart').getContext('2d');
            if(window._compareChart) window._compareChart.destroy();
            window._compareChart = new Chart(ctx, { type: 'line', data: { labels: labels, datasets: datasets }, options: {responsive:true, maintainAspectRatio:false} });
            window.scrollTo(0, document.getElementById('compareChart').offsetTop - 20);
        }).catch(e=>alert('Fehler: '+e));
}
</script>
<footer class="mt-4 py-2 text-center text-muted">
Powered by <a href="http://www.dreamcodes.net" target="_blank">Dreamcodes</a>
</footer>
</body>
</html>
Vorheriges Tutorial

Lesetipps für dich