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 "&",
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 "&",
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 . "&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("&"), "&", $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 "&",
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 "&",
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 . "&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 ?>