pub / yt2rss

Transforms a youtube channel into a podcast RSS feed to insert into a podcatcher
git clone https://src.jayvii.de/pub/yt2rss.git
Home | Log | Files | Exports | Refs | Submodules | README | LICENSE | RSS

index.php (11578B)


      1 <?php
      2 /*
      3 * SPDX-FileCopyrightText: 2024 JayVii <jayvii[AT]posteo[DOT]de>
      4 * SPDX-License-Identifier: AGPL-3.0-or-later
      5 */
      6 
      7 if (file_exists("./env.php")) {
      8     include "./env.php";
      9 }
     10 
     11 function analyze_video($video_file) {
     12     include_once("3rdparty/getid3/getid3/getid3.php");
     13     $getID3 = new getID3;
     14     $video_info = $getID3->analyze($video_file);
     15     return $video_info;
     16 }
     17 
     18 function ytdl_status($video_id) {
     19     $pid = exec("ps ax | grep -v grep | grep " . $video_id);
     20     return strlen($pid) > 0;
     21 }
     22 
     23 function get_yt_profilepic($channel_id) {
     24     // set fallback to NULL
     25     $profile_pic_link = null;
     26     // fetch HTML for given channel ID
     27     $html = explode(
     28         PHP_EOL,
     29         file_get_contents("https://www.youtube.com/channel/" . $channel_id)
     30     );
     31     // If HTML could be fetched, extract lines with probable profile picture
     32     if (!empty($html)) {
     33         $profile_pic_lines = preg_grep(
     34             '/yt3\.googleusercontent\.com\//',
     35             $html
     36         );
     37         $profile_pic_links = preg_replace(
     38             '/^.*\"(https:\/\/yt3\.googleusercontent\.com\/[^\"]+).*$/',
     39             '${1}',
     40             $profile_pic_lines
     41         );
     42         // If probably pictures were found, get the first one and strip
     43         // unnecessary parameters from the link
     44         if (count($profile_pic_links) > 0) {
     45             $key = array_keys($profile_pic_links)[0];
     46             $profile_pic_link = preg_replace(
     47                 '/=.*$/',
     48                 '',
     49                 $profile_pic_links[$key]
     50             );
     51         }
     52     }
     53     // return the profile picture that was found (or null otherwise)
     54     return $profile_pic_link;
     55 }
     56 
     57 // Authentification
     58 if ($_GET["auth"] != $auth_key && !is_null($auth_key)) {
     59     $auth = false;
     60 } else {
     61     $auth = true;
     62 }
     63 
     64 if (array_key_exists("video", $_GET)) {
     65     $video = basename($_GET["video"]);
     66 } else {
     67     $video = null;
     68 }
     69 if (array_key_exists("channel", $_GET)) {
     70     $channel = basename($_GET["channel"]);
     71 } else {
     72     $channel = null;
     73 }
     74 
     75 
     76 
     77 if (!empty($channel) && $auth) {
     78     
     79     // Fetch Youtube XML Feed
     80     $channel_xml = file(
     81         "https://www.youtube.com/feeds/videos.xml?channel_id=" . $channel
     82     );
     83 
     84     // Replace un-parsable items
     85     $channel_xml = str_replace(
     86         array("yt:", "media:"),
     87         array("yt_", "media_"),
     88         $channel_xml
     89     );
     90 
     91     // Cast Array to string
     92     $channel_xml = implode(PHP_EOL, $channel_xml);
     93 
     94     // Parse XML
     95     $channel_xml = simplexml_load_string($channel_xml);
     96     $channel_xml = json_encode($channel_xml);
     97     $channel_xml = json_decode($channel_xml, true);
     98 
     99     // Construct Podcatcher XML
    100     $rss_xml = "<rss " .
    101         "version=\"2.0\" " .
    102         "xmlns:atom=\"http://www.w3.org/2005/Atom\" " .
    103         "xmlns:content=\"http://purl.org/rss/1.0/modules/content/\" " .
    104         "xmlns:itunes=\"http://www.itunes.com/dtds/podcast-1.0.dtd\" " .
    105         "xmlns:dc=\"http://purl.org/dc/elements/1.1/\" " .
    106         "xmlns:podcast=\"https://podcastindex.org/namespace/1.0\"" .
    107         ">\n<channel>\n";
    108     $rss_xml = $rss_xml .
    109         "<docs>http://www.rssboard.org/rss-specification</docs>\n";
    110     $rss_xml = $rss_xml . "<title>" .
    111         str_replace(
    112             array("&"),
    113             "&amp;",
    114             $channel_xml["title"]
    115         ) . "</title>\n";
    116     $channel_id = str_replace(
    117         array("yt_channel:"),
    118         "",
    119         $channel_xml["id"]
    120     );
    121     $rss_xml = $rss_xml . "<link>https://www.youtube.com/channel/" . $channel .
    122         "</link>\n";
    123     $rss_xml = $rss_xml . "<description>" . 
    124         str_replace(
    125             array("&"),
    126             "&amp;",
    127             $channel_xml["title"]
    128         ) .
    129         "</description>\n";
    130     $rss_xml = $rss_xml . "<pubDate>" . $channel_xml["published"] .
    131         "</pubDate>\n";
    132 
    133     // fetch channel image from HTML of channel page
    134     // As fallback, use latest video thumbnail
    135     $profile_pic_link = get_yt_profilepic($channel);
    136     if (empty($profile_pic_link)) {
    137         $video_id = str_replace(
    138             array("yt_video:"),
    139             "",
    140             $channel_xml["entry"][0]["id"]
    141         );
    142         $profile_pic_link = "https://i4.ytimg.com/vi/" . $video_id .
    143             "/hqdefault.jpg";
    144     }
    145     $rss_xml = $rss_xml . "<itunes:image href=\"" .
    146         $profile_pic_link .
    147         "\"/>\n";
    148     $rss_xml = $rss_xml . "<atom:link href=\"https://" .
    149         $_SERVER["SERVER_NAME"] .
    150         "/?channel=" . $channel;
    151     // Inject auth key
    152     if (!is_null($auth_key)) {
    153         $rss_xml = $rss_xml . "&amp;auth=" . $auth_key;
    154     }
    155     $rss_xml = $rss_xml . "\"" .
    156         " rel=\"self\" type=\"application/rss+xml\"/>\n";
    157 
    158     // Add media items
    159     foreach ($channel_xml["entry"] as $entry) {
    160 
    161         // Skip if title matches "exclude"
    162         if (!is_null($_GET["exclude"])) {
    163             $excluded = strstr($entry["title"], rawurldecode($_GET["exclude"]));
    164             if ($excluded) continue;
    165         }
    166 
    167         // Skip if title does not match "include"
    168         if (!is_null($_GET["include"])) {
    169             $included = strstr($entry["title"], rawurldecode($_GET["include"]));
    170             if (!$included) continue;
    171         }
    172 
    173         $video_id = str_replace(array("yt_video:"), "", $entry["id"]);
    174 
    175         // Get Video Length, size and type
    176         if (file_exists($video_id . ".opus")) {
    177             $video_info = analyze_video($video_id . ".opus");
    178             $video_size = $video_info["filesize"];
    179             $video_duration = $video_info["playtime_string"];
    180             $video_ftype = $video_info["mime_type"];
    181         } else {
    182             $video_size = 0;
    183             $video_duration = "00:00:00";
    184             $video_ftype = "audio/ogg";
    185         }
    186         $rss_xml = $rss_xml . "<item>\n";
    187         $rss_xml = $rss_xml . "<title>" .
    188             str_replace(array("&"), "&amp;", $entry["title"]) . "</title>\n";
    189 
    190         // Add description
    191         $rss_xml = $rss_xml . "<description>" .
    192             "Video-Link:" . PHP_EOL .
    193             $entry["link"]["@attributes"]["href"] . PHP_EOL . PHP_EOL .
    194             str_replace(
    195                 array("&"),
    196                 "&amp;",
    197                 $entry["media_group"]["media_description"]
    198             ) . "</description>\n";
    199 
    200         // Add author
    201         $rss_xml = $rss_xml . "<itunes:author>" . 
    202             str_replace(
    203                 array("&"),
    204                 "&amp;",
    205                 $entry["author"]["name"]
    206             ) .
    207             "</itunes:author>\n";
    208 
    209         $rss_xml = $rss_xml . "<pubDate>" . $entry["published"] .
    210             "</pubDate>\n";
    211         $rss_xml = $rss_xml . "<itunes:image href=\"https://i1.ytimg.com/vi/" .
    212             $video_id . "/hqdefault.jpg\"/>\n";
    213         $rss_xml = $rss_xml . "<enclosure url=\"https://" .
    214             $_SERVER["SERVER_NAME"] .
    215             "/?video=" . $video_id;
    216         // Add auth key
    217         if (!is_null($auth_key)) {
    218             $rss_xml = $rss_xml . "&amp;auth=" . $auth_key;
    219         }
    220         $rss_xml = $rss_xml . "\"".
    221             " type=\"" . $video_ftype . "\" length=\"" . $video_size . "\"/>\n";
    222         $rss_xml = $rss_xml . "<itunes:duration>" . $video_duration .
    223             "</itunes:duration>\n";
    224         $rss_xml = $rss_xml . "</item>\n";
    225     }
    226 
    227     $rss_xml = $rss_xml . "</channel>\n</rss>\n";
    228     header("Content-type: application/xml");
    229     print_r($rss_xml);
    230     die();
    231 
    232 } else if (!empty($video) && $auth) {
    233 
    234     // Re-try downloading as long as the file does not exist and ytdl does not
    235     // run on the current video
    236     $download_retry = 0;
    237     while (
    238         !file_exists($video . ".opus") &&
    239         !ytdl_status($video_id) &&
    240         $download_retry <= 3
    241     ) {
    242         $download_retry++;
    243         passthru(
    244             "yt-dlp " .
    245             "-x " .
    246             "--audio-format opus " .
    247             "-o '%(id)s.%(ext)s' " .
    248             "https://www.youtube.com/watch?v=" . $video
    249         );
    250     }
    251 
    252     // Check whether ytdl is still running on current video ID
    253     $ytdl_running = ytdl_status($video);
    254 
    255     // If file has been downloaded properly, check whether the file is valid
    256     if (
    257         !(analyze_video($video . ".opus")["playtime_seconds"] > 0) &&
    258         $ytdl_running !== true
    259     ) {
    260         unlink(basename($_GET["video"]) . ".opus");
    261     }
    262 
    263     // If file still exists, return to user
    264     if (file_exists($video . ".opus") && $ytdl_running !== true) {
    265         header("content-type: audio/ogg; codec=opus");
    266         header("content-length: " . filesize($video . ".opus"));
    267         header("content-disposition: inline; filename=" . $video . ".opus");
    268         readfile($video . ".opus");
    269     } else {
    270         // otherwise return error and exit
    271         http_response_code(404);
    272     }
    273     die();
    274 } else {
    275 
    276 ?>
    277 
    278 <html>
    279     <head>
    280         <title>yt2rss</title>
    281         <link
    282             rel="stylesheet"
    283             type="text/css"
    284             href="/assets/css/simple.min.css"
    285         />
    286     </head>
    287 
    288     <body>
    289         <h1>yt2rss</h1>
    290         <p><a href="https://src.jayvii.de/pub/yt2rss">Learn more</a></p>
    291 
    292         <p>Usage:</p>
    293         <ul>
    294             <li>
    295                 <?php echo "https://" .
    296                     $_SERVER["SERVER_NAME"] .
    297                     "/?channel=UCt_Y3j8CpXx366et1RaHACA";
    298                 ?>
    299             </li>
    300             <li>
    301                 <?php echo "https://" .
    302                     $_SERVER["SERVER_NAME"] .
    303                     "/?video=TV8tEq56vHI";
    304                 ?>
    305             </li>
    306         </ul>
    307 
    308         <p>Videos can be included or excluded based on title-matches:</p>
    309         <ul>
    310             <li>
    311                 Include only videos whose title contain "vlog time":<br>
    312                 <?php
    313                     echo "https://" .
    314                         $_SERVER["SERVER_NAME"] .
    315                         "/?channel=UCt_Y3j8CpXx366et1RaHACA&include=vlog%20time";
    316                 ?>
    317             </li>
    318             <li>
    319                 Exclude all videos whose title contain "vlog time":<br>
    320                 <?php
    321                     echo "https://" .
    322                         $_SERVER["SERVER_NAME"] .
    323                         "/?channel=UCt_Y3j8CpXx366et1RaHACA&exclude=vlog%20time";
    324                 ?>
    325             </li>
    326         </ul>
    327 
    328         <?php
    329             // Authentification
    330             if (!is_null($auth_key)) {
    331         ?>
    332             <p>
    333                 This Service requires an authentification key. If you have one,
    334                 add it to the request URLs with:
    335             </p>
    336             <ul>
    337                 <li>
    338                     <?php
    339                         echo "https://" .
    340                             $_SERVER["SERVER_NAME"] .
    341                             "/?auth=MY_KEY&channel=UCt_Y3j8CpXx366et1RaHACA";
    342                     ?>
    343                 </li>
    344                 <li>
    345                     <?php
    346                         echo "https://" .
    347                             $_SERVER["SERVER_NAME"] .
    348                             "/?auth=MY_KEY&video=TV8tEq56vHI";
    349                     ?>
    350                 </li>
    351             </ul>
    352             <p>
    353                 If you do not have an authentification key, please contact the
    354                 server admin.
    355             </p>
    356             <?php
    357                 if ($auth) {
    358             ?>
    359                 <p style="color:white;background-color:green;">
    360                     You are authenticated :)
    361                 </p>
    362             <?php
    363                 } else {
    364             ?>
    365                 <p style='color:white;background-color:red;'>
    366                     You are NOT authenticated :(
    367                 </p>
    368         <?php
    369                 }
    370             }
    371         ?>
    372         </body>
    373     </html>
    374 <?php
    375 }
    376 ?>