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 (11764B)


      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 entry if it is a short
    162           $uri = $entry["link"]["@attributes"]["href"];
    163           if (preg_match('/\/shorts\//', $uri) > 0) {
    164             continue;
    165           }
    166 
    167         // Skip if title matches "exclude"
    168         if (!is_null($_GET["exclude"])) {
    169             $excluded = strstr($entry["title"], rawurldecode($_GET["exclude"]));
    170             if ($excluded) continue;
    171         }
    172 
    173         // Skip if title does not match "include"
    174         if (!is_null($_GET["include"])) {
    175             $included = strstr($entry["title"], rawurldecode($_GET["include"]));
    176             if (!$included) continue;
    177         }
    178 
    179         $video_id = str_replace(array("yt_video:"), "", $entry["id"]);
    180 
    181         // Get Video Length, size and type
    182         if (file_exists($video_id . ".opus")) {
    183             $video_info = analyze_video($video_id . ".opus");
    184             $video_size = $video_info["filesize"];
    185             $video_duration = $video_info["playtime_string"];
    186             $video_ftype = $video_info["mime_type"];
    187         } else {
    188             $video_size = 0;
    189             $video_duration = "00:00:00";
    190             $video_ftype = "audio/ogg";
    191         }
    192         $rss_xml = $rss_xml . "<item>\n";
    193         $rss_xml = $rss_xml . "<title>" .
    194             str_replace(array("&"), "&amp;", $entry["title"]) . "</title>\n";
    195 
    196         // Add description
    197         $rss_xml = $rss_xml . "<description>" .
    198             "Video-Link:" . PHP_EOL .
    199             $entry["link"]["@attributes"]["href"] . PHP_EOL . PHP_EOL .
    200             str_replace(
    201                 array("&"),
    202                 "&amp;",
    203                 $entry["media_group"]["media_description"]
    204             ) . "</description>\n";
    205 
    206         // Add author
    207         $rss_xml = $rss_xml . "<itunes:author>" . 
    208             str_replace(
    209                 array("&"),
    210                 "&amp;",
    211                 $entry["author"]["name"]
    212             ) .
    213             "</itunes:author>\n";
    214 
    215         $rss_xml = $rss_xml . "<pubDate>" . $entry["published"] .
    216             "</pubDate>\n";
    217         $rss_xml = $rss_xml . "<itunes:image href=\"https://i1.ytimg.com/vi/" .
    218             $video_id . "/hqdefault.jpg\"/>\n";
    219         $rss_xml = $rss_xml . "<enclosure url=\"https://" .
    220             $_SERVER["SERVER_NAME"] .
    221             "/?video=" . $video_id;
    222         // Add auth key
    223         if (!is_null($auth_key)) {
    224             $rss_xml = $rss_xml . "&amp;auth=" . $auth_key;
    225         }
    226         $rss_xml = $rss_xml . "\"".
    227             " type=\"" . $video_ftype . "\" length=\"" . $video_size . "\"/>\n";
    228         $rss_xml = $rss_xml . "<itunes:duration>" . $video_duration .
    229             "</itunes:duration>\n";
    230         $rss_xml = $rss_xml . "</item>\n";
    231     }
    232 
    233     $rss_xml = $rss_xml . "</channel>\n</rss>\n";
    234     header("Content-type: application/xml");
    235     print_r($rss_xml);
    236     die();
    237 
    238 } else if (!empty($video) && $auth) {
    239 
    240     // Re-try downloading as long as the file does not exist and ytdl does not
    241     // run on the current video
    242     $download_retry = 0;
    243     while (
    244         !file_exists($video . ".opus") &&
    245         !ytdl_status($video_id) &&
    246         $download_retry <= 3
    247     ) {
    248         $download_retry++;
    249         passthru(
    250             "yt-dlp " .
    251             "-x " .
    252             "--audio-format opus " .
    253             "-o '%(id)s.%(ext)s' " .
    254             "https://www.youtube.com/watch?v=" . $video
    255         );
    256     }
    257 
    258     // Check whether ytdl is still running on current video ID
    259     $ytdl_running = ytdl_status($video);
    260 
    261     // If file has been downloaded properly, check whether the file is valid
    262     if (
    263         !(analyze_video($video . ".opus")["playtime_seconds"] > 0) &&
    264         $ytdl_running !== true
    265     ) {
    266         unlink(basename($_GET["video"]) . ".opus");
    267     }
    268 
    269     // If file still exists, return to user
    270     if (file_exists($video . ".opus") && $ytdl_running !== true) {
    271         header("content-type: audio/ogg; codec=opus");
    272         header("content-length: " . filesize($video . ".opus"));
    273         header("content-disposition: inline; filename=" . $video . ".opus");
    274         readfile($video . ".opus");
    275     } else {
    276         // otherwise return error and exit
    277         http_response_code(404);
    278     }
    279     die();
    280 } else {
    281 
    282 ?>
    283 
    284 <html>
    285     <head>
    286         <title>yt2rss</title>
    287         <link
    288             rel="stylesheet"
    289             type="text/css"
    290             href="/assets/css/simple.min.css"
    291         />
    292     </head>
    293 
    294     <body>
    295         <h1>yt2rss</h1>
    296         <p><a href="https://src.jayvii.de/pub/yt2rss">Learn more</a></p>
    297 
    298         <p>Usage:</p>
    299         <ul>
    300             <li>
    301                 <?php echo "https://" .
    302                     $_SERVER["SERVER_NAME"] .
    303                     "/?channel=UCt_Y3j8CpXx366et1RaHACA";
    304                 ?>
    305             </li>
    306             <li>
    307                 <?php echo "https://" .
    308                     $_SERVER["SERVER_NAME"] .
    309                     "/?video=TV8tEq56vHI";
    310                 ?>
    311             </li>
    312         </ul>
    313 
    314         <p>Videos can be included or excluded based on title-matches:</p>
    315         <ul>
    316             <li>
    317                 Include only videos whose title contain "vlog time":<br>
    318                 <?php
    319                     echo "https://" .
    320                         $_SERVER["SERVER_NAME"] .
    321                         "/?channel=UCt_Y3j8CpXx366et1RaHACA&include=vlog%20time";
    322                 ?>
    323             </li>
    324             <li>
    325                 Exclude all videos whose title contain "vlog time":<br>
    326                 <?php
    327                     echo "https://" .
    328                         $_SERVER["SERVER_NAME"] .
    329                         "/?channel=UCt_Y3j8CpXx366et1RaHACA&exclude=vlog%20time";
    330                 ?>
    331             </li>
    332         </ul>
    333 
    334         <?php
    335             // Authentification
    336             if (!is_null($auth_key)) {
    337         ?>
    338             <p>
    339                 This Service requires an authentification key. If you have one,
    340                 add it to the request URLs with:
    341             </p>
    342             <ul>
    343                 <li>
    344                     <?php
    345                         echo "https://" .
    346                             $_SERVER["SERVER_NAME"] .
    347                             "/?auth=MY_KEY&channel=UCt_Y3j8CpXx366et1RaHACA";
    348                     ?>
    349                 </li>
    350                 <li>
    351                     <?php
    352                         echo "https://" .
    353                             $_SERVER["SERVER_NAME"] .
    354                             "/?auth=MY_KEY&video=TV8tEq56vHI";
    355                     ?>
    356                 </li>
    357             </ul>
    358             <p>
    359                 If you do not have an authentification key, please contact the
    360                 server admin.
    361             </p>
    362             <?php
    363                 if ($auth) {
    364             ?>
    365                 <p style="color:white;background-color:green;">
    366                     You are authenticated :)
    367                 </p>
    368             <?php
    369                 } else {
    370             ?>
    371                 <p style='color:white;background-color:red;'>
    372                     You are NOT authenticated :(
    373                 </p>
    374         <?php
    375                 }
    376             }
    377         ?>
    378         </body>
    379     </html>
    380 <?php
    381 }
    382 ?>