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