From c25fd7677990d2cb11a23b5ffc0ef7974f522fe2 Mon Sep 17 00:00:00 2001 From: Josh Brickner <15388+leftouterjoins@users.noreply.github.com> Date: Tue, 17 Jun 2025 08:52:36 -0600 Subject: [PATCH 1/9] Add PodcastIndex search feature --- conf/podsumer.conf | 3 ++ conf/test.conf | 3 ++ src/Brickner/Podsumer/PodcastIndex.php | 45 ++++++++++++++++++++ templates/home.html.php | 7 +++ templates/search.html.php | 38 +++++++++++++++++ tests/Brickner/Podsumer/PodcastIndexTest.php | 13 ++++++ www/index.php | 31 ++++++++++++++ 7 files changed, 140 insertions(+) create mode 100644 src/Brickner/Podsumer/PodcastIndex.php create mode 100644 templates/search.html.php create mode 100644 tests/Brickner/Podsumer/PodcastIndexTest.php diff --git a/conf/podsumer.conf b/conf/podsumer.conf index cfca4e1..6e7730f 100755 --- a/conf/podsumer.conf +++ b/conf/podsumer.conf @@ -33,3 +33,6 @@ media_dir = /opt/media playback_interval = 5 playback_rewind = 5 +podcastindex_key = "" +podcastindex_secret = "" + diff --git a/conf/test.conf b/conf/test.conf index f520714..39249a8 100644 --- a/conf/test.conf +++ b/conf/test.conf @@ -14,3 +14,6 @@ ssl = false media_dir = /opt/media playback_interval = 5 playback_rewind = 5 + +podcastindex_key = "" +podcastindex_secret = "" diff --git a/src/Brickner/Podsumer/PodcastIndex.php b/src/Brickner/Podsumer/PodcastIndex.php new file mode 100644 index 0000000..0d34eda --- /dev/null +++ b/src/Brickner/Podsumer/PodcastIndex.php @@ -0,0 +1,45 @@ + + +
+

Search PodcastIndex

+ +    + +
diff --git a/templates/search.html.php b/templates/search.html.php new file mode 100644 index 0000000..1252068 --- /dev/null +++ b/templates/search.html.php @@ -0,0 +1,38 @@ +
+

Search Podcasts

+
+ + +
+ + +

No Results

+ + + +
+ + + +

+

+ Subscribe +

+

+
+ + + +
+ 1) { ?> + Previous + + + 1) { ?> |  + Next + +   Page of +
+ +
+ diff --git a/tests/Brickner/Podsumer/PodcastIndexTest.php b/tests/Brickner/Podsumer/PodcastIndexTest.php new file mode 100644 index 0000000..90eb74f --- /dev/null +++ b/tests/Brickner/Podsumer/PodcastIndexTest.php @@ -0,0 +1,13 @@ +assertEquals([], $results); + } +} diff --git a/www/index.php b/www/index.php index 3c9d6eb..ec4aa02 100755 --- a/www/index.php +++ b/www/index.php @@ -18,6 +18,7 @@ use Brickner\Podsumer\Main; use Brickner\Podsumer\OPML; use Brickner\Podsumer\Template; +use Brickner\Podsumer\PodcastIndex; # Create the application. $main = new Main(PODSUMER_PATH, array_merge($_SERVER, $_ENV), array_merge($_GET, $_POST), $_FILES); @@ -56,6 +57,36 @@ function episodes(array $args): void Template::render($main, 'episodes', $vars); } +#[Route('/search', 'GET', true)] +function search(array $args): void +{ + global $main; + + $page = isset($args['page']) ? max(1, intval($args['page'])) : 1; + $per_page = intval($main->getConf('podsumer', 'items_per_page')) ?: 10; + $q = $args['q'] ?? ''; + + $results = []; + $page_count = 1; + + if (!empty($q)) { + $key = strval($main->getConf('podsumer', 'podcastindex_key')); + $secret = strval($main->getConf('podsumer', 'podcastindex_secret')); + $all = PodcastIndex::search($q, $page * $per_page, $key, $secret); + $page_count = max(1, intval(ceil(count($all) / $per_page))); + $results = array_slice($all, ($page - 1) * $per_page, $per_page); + } + + $vars = [ + 'feeds' => $results, + 'q' => $q, + 'page' => $page, + 'page_count' => $page_count + ]; + + Template::render($main, 'search', $vars); +} + /** * Add new feed(s) * Path: /add From f9767b73f946a6c7fa7b50971ef0073e0204ac1a Mon Sep 17 00:00:00 2001 From: Josh Brickner <15388+leftouterjoins@users.noreply.github.com> Date: Tue, 17 Jun 2025 09:07:42 -0600 Subject: [PATCH 2/9] Fix search subscribe form and escape outputs --- templates/search.html.php | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/templates/search.html.php b/templates/search.html.php index 1252068..9ca116b 100644 --- a/templates/search.html.php +++ b/templates/search.html.php @@ -12,13 +12,14 @@
- + -

-

- Subscribe -

-

+

+
+ + +
+

From b90ce13e62efe783906a461b857c79b2cd1c3083 Mon Sep 17 00:00:00 2001 From: Josh Brickner <15388+leftouterjoins@users.noreply.github.com> Date: Tue, 17 Jun 2025 10:08:35 -0600 Subject: [PATCH 3/9] fix search pagination --- www/index.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/www/index.php b/www/index.php index ec4aa02..673be68 100755 --- a/www/index.php +++ b/www/index.php @@ -72,7 +72,7 @@ function search(array $args): void if (!empty($q)) { $key = strval($main->getConf('podsumer', 'podcastindex_key')); $secret = strval($main->getConf('podsumer', 'podcastindex_secret')); - $all = PodcastIndex::search($q, $page * $per_page, $key, $secret); + $all = PodcastIndex::search($q, 1000, $key, $secret); $page_count = max(1, intval(ceil(count($all) / $per_page))); $results = array_slice($all, ($page - 1) * $per_page, $per_page); } From 3ee09b73e273ad44ec21605440dc147bc481b961 Mon Sep 17 00:00:00 2001 From: Josh Brickner <15388+leftouterjoins@users.noreply.github.com> Date: Tue, 17 Jun 2025 10:08:41 -0600 Subject: [PATCH 4/9] Escape search query --- templates/search.html.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/search.html.php b/templates/search.html.php index 9ca116b..2b8bcae 100644 --- a/templates/search.html.php +++ b/templates/search.html.php @@ -1,7 +1,7 @@

Search Podcasts

- +
From 15e8422221e52c414b26428caa9ea80103c37a30 Mon Sep 17 00:00:00 2001 From: Josh Brickner Date: Tue, 17 Jun 2025 12:40:29 -0600 Subject: [PATCH 5/9] add sponsor block support --- .DS_Store | Bin 8196 -> 10244 bytes src/Brickner/Podsumer/FSState.php | 63 +++++---- src/Brickner/Podsumer/SponsorBlock.php | 53 ++++++++ src/Brickner/Podsumer/YouTubeSearch.php | 174 ++++++++++++++++++++++++ templates/item.html.php | 40 ++++++ tests/Brickner/Podsumer/FSStateTest.php | 17 ++- www/index.php | 33 ++++- 7 files changed, 344 insertions(+), 36 deletions(-) create mode 100644 src/Brickner/Podsumer/SponsorBlock.php create mode 100644 src/Brickner/Podsumer/YouTubeSearch.php diff --git a/.DS_Store b/.DS_Store index a0e9b591c97a69449875f7efade4a4041e551c99..0bd7e1b70933aec2b9934195a7a6aa55e78a4fe9 100644 GIT binary patch literal 10244 zcmeHMYmD5~6+XxNva=@Z%_Ok9X(j^;36B-bhNL9fG@0y9Py%@@nN4={Ae))7$y)K) z8{0FJHjTQfRG6n-Gyk6*k5LP}^O@emRa-r}JJ6hKf_w15N!p+Y2Rq38N~X6=2X zl`2)K>N~QJ?>*<-^WAgL@x9jr0N7nrI{^{^AkxLCBGYY>!uaf(Pz1kaAyFiKFm`oj z@>XGZf@IVXc_8vYBL9R-atD=9zK(0>h312Sd(-BU&I-q~}K;JU@ zClvIoQ~wE195BUcbdNj`d0?Uk*x9`nE`?p7gBv=(FVnh$yn>ZZ=L{=HZK9-pEd32$ zSQ+N4r{xb11-=a&x|dX;p<)+?pa{8;b(nQ*qC^Nodz*dmhZ79LxPA&&0Lhe6Aw1se zjr@=Y3rx5!jHK>6vQgy)F`WAN+Sacte(-gJ)?Zpt#aPu3+f7o}^yXu_CD6czEX{-# zvOjlo{bVvh99!Roj}CnEJoCkbzJhp8Cl|t%@IK)*fXk=Y&rD0_BUue|URCROLR+<6R)vwbvTWYT9l={qUK^vW+G&i-h%JM)lr|&81 z?$4EWQS@5p#O2s_l|ZbsRHmgWkBgGWIWI2D+ZdD5}KmEc$6o?k{maD_8rCOS@w70DTEA5M-wrJN)JqDOujZ zEU(Qv?uKE_Bvi+J^XXNxyww@X+S-OPMWgbf-@b<0z4;>!Q(L9#u4Vhobu`E=tizS6 zN&}Hbo0^pwvYhD?MddA;58W*3lmj$}JQbw&)DgDg0ko$sxAH$<~3?Ij*@ilxM z-@rHVJ^V9%f}i4N__a_g)Cu)Mlh7)(3A2Usg$smv!i7SYuuNDbtQY!)tiTp&Dncdo zw)1-BF6^eKVCp#~|79W^ojkeXtJhq0b$s3bOmdx^&eLhu+2AH#1M%yd0*jF03^m*~~MM@i`j#uZ$FA^zX zlzLw6kS`S}MHRuT#yjQ9B}zo4hF3e}Dfa3Xlkg|_6uyGLQ=m`B7`EViOyXi(j;nD!ZlKW4;3nLGgP5fN zXW?z)Fz!JIe~J4k#7FSgco1*GLwFdEP?+CAfzHDG0elD_#z*k?cpRU=C-EsffiK`o z_%gmqf&UJEL;?Q={*{MqHJH~|!kAM$6%OX$n){FEp3Q#C_h*4{53akDO9a>6Y4v|( zh64JfjBOg0xrM zGkk345Z<}L^|yt&K|J^5j$pasxvG8;$K(=W`(H8p_1@0b1eJD=c5sz1-!O{_g!n-W z3omJ&cbM;(#>tHB6Q`01;@JBNgF(B)GW!J)0Q9omtIrAAK2*1(va_#p$E;4|2JHci zFndvnJaA@vpoYUsG!k7k_5c58_8=m3kq06V{6`+Z+H@w}OwSxD*yFA1ETYPbpHQ8KmY#|iGv-v delta 755 zcmZn(XmOBWU|?W$DortDU;r^WfEYvza8FDW+o<=QKhEOgW+0!1L60GwA(NpbH{Zo2 zDJMS(q&9!{;?PI`k2#{sr{I+@$S@2}&d)6X>S180WtiM7u!hlWvVouk`()Pa)Z71$ zP4*WIM3uOe;1{juQ@H6qvkOxL?H7$U;ZK#K5RlN1@u% z%oxZvH=i6P;>|22>^b?IloadweP0&oOnxgR3uW<3%QACL{4iNx+MY={d~$)b6309V zuzij|j)V}x(T7FrtmXhc1r!#Wd`p^7astSutPCX#sSL$HvKZzz#>uawWt3g=P-T(B z=Vhl!`(n($jndgetFeed($feed_id); - $file_id = $feed['image']; - $file = $this->getFileById($file_id); + # Capture all related files before we alter the database so we can + # safely remove them from disk afterwards. + $files_to_delete = []; - if ($file['storage_mode'] == 'DISK' && file_exists($file['filename'])) { - unlink($file['filename']); + $image_file_id = $feed['image'] ?? null; + if ($image_file_id) { + $files_to_delete[] = $this->getFileById($image_file_id); } - $items = $this->getFeedItems($feed_id); - foreach ($items as $item) { - $file_id = $item['image']; - - if (!empty($file_id)) { - - $file = $this->getFileById($file_id); - - if ($file['storage_mode'] == 'DISK' && file_exists($file['filename'])) { - unlink($file['filename']); + # Collect images / audio from each item before DB deletion + foreach ($this->getFeedItems($feed_id) as $item) { + foreach (['image', 'audio_file'] as $col) { + $fid = $item[$col] ?? null; + if ($fid) { + $files_to_delete[] = $this->getFileById($fid); } } + } - $file_id = $item['audio_file']; - - if (!empty($file_id)) { # The audio for an item may not be downloaded. - - $file = $this->getFileById($file_id); + # Delete from the database first (cascades will clean up related rows) + parent::deleteFeed($feed_id); - if ($file['storage_mode'] == 'DISK' && file_exists($file['filename'])) { - unlink($file['filename']); + # Remove the on-disk files we captured earlier + foreach ($files_to_delete as $file) { + if (!empty($file) && ($file['storage_mode'] ?? null) === 'DISK') { + $filename = $file['filename'] ?? null; + if (!empty($filename) && file_exists($filename)) { + @unlink($filename); } } } - # Delete feed dir. - $feed_dir = $this->getFeedDir($feed['name']); - if (file_exists($feed_dir)) { - rmdir($feed_dir); + # Finally, try to remove the (now empty) feed directory + $feed_dir = $this->getFeedDir($feed['name'] ?? '') ?: null; + if ($feed_dir && file_exists($feed_dir)) { + $files_in_dir = array_diff(scandir($feed_dir), ['.', '..']); + if (empty($files_in_dir)) { + @rmdir($feed_dir); + } } - - parent::deleteFeed($feed_id); } public function deleteItemMedia(int $item_id) @@ -150,8 +151,12 @@ public function deleteItemMedia(int $item_id) $file = $this->getFileById($file_id); - if ($file['storage_mode'] == 'DISK' && file_exists($file['filename'])) { - unlink($file['filename']); + if (!empty($file) && ($file['storage_mode'] ?? null) === 'DISK') { + $filename = $file['filename'] ?? null; + + if (!empty($filename) && file_exists($filename)) { + @unlink($filename); + } } parent::deleteItemMedia($item_id); diff --git a/src/Brickner/Podsumer/SponsorBlock.php b/src/Brickner/Podsumer/SponsorBlock.php new file mode 100644 index 0000000..fe6fe7e --- /dev/null +++ b/src/Brickner/Podsumer/SponsorBlock.php @@ -0,0 +1,53 @@ + | [] + */ + public static function getSegments(string $videoId, array $categories = ['sponsor', 'selfpromo', 'interaction', 'intro', 'outro']): array + { + if (empty($videoId)) { + return []; + } + + $url = 'https://sponsor.ajay.app/api/skipSegments?videoID=' . urlencode($videoId) . '&categories=' . rawurlencode(json_encode($categories)); + + $curl = curl_init(); + curl_setopt($curl, \CURLOPT_URL, $url); + curl_setopt($curl, \CURLOPT_RETURNTRANSFER, true); + curl_setopt($curl, \CURLOPT_HTTPHEADER, [ + 'User-Agent: Podsumer', + 'Accept: application/json', + ]); + curl_setopt($curl, \CURLOPT_CONNECTTIMEOUT, 10); + curl_setopt($curl, \CURLOPT_TIMEOUT, 20); + + $response = curl_exec($curl); + curl_close($curl); + + if (false === $response) { + return []; + } + + $decoded = json_decode($response, true); + if (!is_array($decoded)) { + return []; + } + + return $decoded; + } +} \ No newline at end of file diff --git a/src/Brickner/Podsumer/YouTubeSearch.php b/src/Brickner/Podsumer/YouTubeSearch.php new file mode 100644 index 0000000..ad60261 --- /dev/null +++ b/src/Brickner/Podsumer/YouTubeSearch.php @@ -0,0 +1,174 @@ + Array of videoIds – can be empty if nothing found. + */ + public static function search(string $query, int $maxResults = 1): array + { + if (empty($query)) { + return []; + } + + $results = []; + $continuation = null; + + // We loop until we either exhaust available pages *or* gather $maxResults. + while (count($results) < $maxResults) { + $json = self::makeRequest($query, $continuation); + if (!$json) { + break; // network / decode failure – give up early + } + + self::extractVideoIds($json, $results, $maxResults); + + // Continuation token? + $continuation = self::getContinuationToken($json); + if (!$continuation) { + break; // no more pages + } + // Only send the continuation on subsequent iterations – unset $query so makeRequest knows which variant. + $query = ''; + } + + return $results; + } + + /* --------------------------- Internal helpers --------------------------- */ + + private const INNER_TUBE_KEY = 'AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8'; + private const INNER_TUBE_BASE = 'https://www.youtube.com/youtubei/v1/search?key='; + private const CLIENT_NAME = 'WEB'; + private const CLIENT_VERSION = '2.20240616.01.00'; // reasonably up-to-date, can be bumped later + + /** + * Performs a POST to the InnerTube search endpoint. If $continuation is + * provided, we send that instead of the initial query. + * + * @return array|null Decoded JSON as associative array or null on failure. + */ + private static function makeRequest(string $query, ?string $continuation = null): ?array + { + $payload = [ + 'context' => [ + 'client' => [ + 'clientName' => self::CLIENT_NAME, + 'clientVersion' => self::CLIENT_VERSION, + // We could add hl/gl if localisation is needed. + ], + ], + ]; + + if ($continuation) { + $payload['continuation'] = $continuation; + } else { + $payload['query'] = $query; + // Filter to *videos only* to simplify downstream parsing. + // Videos-only filter encoded param – same string yt-dlp uses. + $payload['params'] = 'EgIQAQ%3D%3D'; + } + + $jsonPayload = json_encode($payload); + if ($jsonPayload === false) { + return null; + } + + $headers = [ + 'Content-Type: application/json', + // UA that resembles a mainstream browser to avoid suspicion. + 'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36', + ]; + + $curl = curl_init(); + curl_setopt($curl, \CURLOPT_URL, self::INNER_TUBE_BASE . self::INNER_TUBE_KEY); + curl_setopt($curl, \CURLOPT_POST, true); + curl_setopt($curl, \CURLOPT_HTTPHEADER, $headers); + curl_setopt($curl, \CURLOPT_RETURNTRANSFER, true); + curl_setopt($curl, \CURLOPT_POSTFIELDS, $jsonPayload); + curl_setopt($curl, \CURLOPT_FOLLOWLOCATION, true); + curl_setopt($curl, \CURLOPT_CONNECTTIMEOUT, 15); + curl_setopt($curl, \CURLOPT_TIMEOUT, 30); + + $response = curl_exec($curl); + curl_close($curl); + + if (false === $response) { + return null; + } + + $decoded = json_decode($response, true); + if (!is_array($decoded)) { + return null; + } + + return $decoded; + } + + /** + * Walks the response tree and appends up to $max videoIds onto $out. + * + * @param array $json + * @param array $out + * @param int $max + */ + private static function extractVideoIds(array $json, array &$out, int $max): void + { + $contentsPointer = $json['contents']['twoColumnSearchResultsRenderer']['primaryContents']['sectionListRenderer']['contents'] ?? null; + if (!is_array($contentsPointer)) { + return; + } + + foreach ($contentsPointer as $section) { + $items = $section['itemSectionRenderer']['contents'] ?? []; + foreach ($items as $item) { + if (isset($item['videoRenderer'])) { + $vid = $item['videoRenderer']['videoId'] ?? null; + if ($vid && !in_array($vid, $out, true)) { + $out[] = $vid; + if (count($out) >= $max) { + return; + } + } + } + } + } + } + + /** + * Attempts to fetch the continuation token from a response if present. + */ + private static function getContinuationToken(array $json): ?string + { + $sectionList = $json['contents']['twoColumnSearchResultsRenderer']['primaryContents']['sectionListRenderer']['contents'] ?? []; + foreach ($sectionList as $section) { + if (isset($section['continuationItemRenderer']['continuationEndpoint']['continuationCommand']['token'])) { + return strval($section['continuationItemRenderer']['continuationEndpoint']['continuationCommand']['token']); + } + } + // Sometimes token is elsewhere – also inspect onResponseReceivedCommands collection. + $commands = $json['onResponseReceivedCommands'] ?? []; + foreach ($commands as $command) { + if (isset($command['appendContinuationItemsAction']['continuationItems'][0]['continuationItemRenderer']['continuationEndpoint']['continuationCommand']['token'])) { + return strval($command['appendContinuationItemsAction']['continuationItems'][0]['continuationItemRenderer']['continuationEndpoint']['continuationCommand']['token']); + } + } + return null; + } +} \ No newline at end of file diff --git a/templates/item.html.php b/templates/item.html.php index 548002a..dfcc351 100755 --- a/templates/item.html.php +++ b/templates/item.html.php @@ -120,5 +120,45 @@ function linkifyNode(node) { link.setAttribute('target', '_blank'); link.setAttribute('rel', 'noopener noreferrer'); }); + + /* ------------------------------------------------------------------ + * SponsorBlock integration – fetch segments and auto-skip them. + * ------------------------------------------------------------------ */ + fetch('/sponsor_segments?item_id=' + itemId) + .then(r => r.json()) + .then(segments => { + if (!Array.isArray(segments) || segments.length === 0) return; + + // Flatten into simpler array of [start, end] seconds + const ranges = segments + .map(s => Array.isArray(s.segment) ? s.segment.map(Number) : null) + .filter(Boolean); + + let currentRangeIdx = -1; + + audio.addEventListener('timeupdate', () => { + const t = audio.currentTime; + + // Optimization: if we are inside previously skipped range, do nothing. + if (currentRangeIdx >= 0) { + const [s, e] = ranges[currentRangeIdx]; + if (t >= s && t < e) { + audio.currentTime = e + 0.05; // minimal jump to pass end + return; + } + } + + // Otherwise check if we just entered any range + for (let i = 0; i < ranges.length; i++) { + const [start, end] = ranges[i]; + if (t >= start && t < end) { + currentRangeIdx = i; + audio.currentTime = end + 0.05; + break; + } + } + }); + }) + .catch(() => {/* ignore network errors */}); })(); diff --git a/tests/Brickner/Podsumer/FSStateTest.php b/tests/Brickner/Podsumer/FSStateTest.php index ebe3660..fbd8d30 100644 --- a/tests/Brickner/Podsumer/FSStateTest.php +++ b/tests/Brickner/Podsumer/FSStateTest.php @@ -75,18 +75,23 @@ public function testBadMediaDir() public function testDeleteFeed() { - $this->expectNotToPerformAssertions(); - $this->feed = new Feed(self::TEST_FEED_URL); $feed_id = $this->main->getState()->addFeed($this->feed); - $feed_data = $this->main->getState()->getFeed($feed_id); - $item = $this->main->getState()->getFeedItems(1)[0]; + // Sanity-check feed was added + $this->assertNotEmpty($this->main->getState()->getFeed($feed_id)); + + // Give the feed one audio file so deleteFeed has something to clean up + $item = $this->main->getState()->getFeedItems($feed_id)[0]; $file = new File($this->main); - $file_id = $file->cacheUrl($item['audio_url'], $feed_data); + $file_id = $file->cacheUrl($item['audio_url'], $this->main->getState()->getFeed($feed_id)); $this->main->getState()->setItemAudioFile($item['id'], $file_id); - $this->main->getState()->deleteFeed(1); + // Delete the feed + $this->main->getState()->deleteFeed($feed_id); + + // Verify it's gone + $this->assertEmpty($this->main->getState()->getFeed($feed_id)); } public function testDeleteItemMedia() diff --git a/www/index.php b/www/index.php index 673be68..5c0cdc1 100755 --- a/www/index.php +++ b/www/index.php @@ -110,7 +110,8 @@ function add(array $args): void $uploads = $main->getUploads(); - if (count(array_filter($uploads['opml'])) > 2) { + // Only attempt to process OPML file if it was actually uploaded + if (isset($uploads['opml']) && is_array($uploads['opml']) && count(array_filter($uploads['opml'])) > 2) { $feed_urls = OPML::parse($uploads['opml']); @@ -504,3 +505,33 @@ function set_playback(array $args): void $main->getState()->setPlaybackPosition(intval($args['item_id']), intval($args['position'])); } +#[Route('/sponsor_segments', 'GET', true)] +function sponsor_segments(array $args): void +{ + global $main; + + if (empty($args['item_id'])) { + $main->setResponseCode(404); + echo json_encode(['error' => 'missing item_id']); + return; + } + + $item = $main->getState()->getFeedItem(intval($args['item_id'])); + $feed = $main->getState()->getFeed(intval($item['feed_id'])); + + // Compose search query – podcast name + episode title. + $query = ($feed['name'] ?? '') . ' ' . ($item['name'] ?? ''); + + $videoIds = \Brickner\Podsumer\YouTubeSearch::search($query, 1); + if (empty($videoIds)) { + header('Content-Type: application/json'); + echo json_encode([]); + return; + } + + $segments = \Brickner\Podsumer\SponsorBlock::getSegments($videoIds[0]); + + header('Content-Type: application/json'); + echo json_encode($segments); +} + From 85fe2824b781ee9baef22f569bf81e407a7dd773 Mon Sep 17 00:00:00 2001 From: Josh Brickner Date: Tue, 17 Jun 2025 13:05:02 -0600 Subject: [PATCH 6/9] cursor bugfix --- www/index.php | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/www/index.php b/www/index.php index 5c0cdc1..a8d1d0d 100755 --- a/www/index.php +++ b/www/index.php @@ -510,14 +510,31 @@ function sponsor_segments(array $args): void { global $main; + // Validate required parameter if (empty($args['item_id'])) { $main->setResponseCode(404); + header('Content-Type: application/json'); echo json_encode(['error' => 'missing item_id']); return; } + // Retrieve the requested item and ensure it exists $item = $main->getState()->getFeedItem(intval($args['item_id'])); - $feed = $main->getState()->getFeed(intval($item['feed_id'])); + if (empty($item)) { + $main->setResponseCode(404); + header('Content-Type: application/json'); + echo json_encode(['error' => 'item not found']); + return; + } + + // Retrieve the feed associated with the item and ensure it exists + $feed = $main->getState()->getFeed(intval($item['feed_id'] ?? 0)); + if (empty($feed)) { + $main->setResponseCode(404); + header('Content-Type: application/json'); + echo json_encode(['error' => 'feed not found']); + return; + } // Compose search query – podcast name + episode title. $query = ($feed['name'] ?? '') . ' ' . ($item['name'] ?? ''); From a4db305eed6c7f18a9d557c5f97df1880e3c62f2 Mon Sep 17 00:00:00 2001 From: Josh Brickner Date: Tue, 17 Jun 2025 13:10:23 -0600 Subject: [PATCH 7/9] cursor bugfixes --- src/Brickner/Podsumer/PodcastIndex.php | 3 +++ www/index.php | 11 ++++++++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/Brickner/Podsumer/PodcastIndex.php b/src/Brickner/Podsumer/PodcastIndex.php index 0d34eda..0e3d285 100644 --- a/src/Brickner/Podsumer/PodcastIndex.php +++ b/src/Brickner/Podsumer/PodcastIndex.php @@ -30,6 +30,9 @@ static public function search(string $query, int $max, string $key, string $secr curl_setopt($curl, \CURLOPT_CONNECTTIMEOUT, 30); $result = curl_exec($curl); + // Always close the handle to avoid leaking resources. + curl_close($curl); + if (false === $result) { return []; } diff --git a/www/index.php b/www/index.php index a8d1d0d..b9a679a 100755 --- a/www/index.php +++ b/www/index.php @@ -527,8 +527,17 @@ function sponsor_segments(array $args): void return; } + // Extract feed_id and validate it exists before continuing + $feed_id = $item['feed_id'] ?? null; + if (empty($feed_id)) { + $main->setResponseCode(404); + header('Content-Type: application/json'); + echo json_encode(['error' => 'feed not found']); + return; + } + // Retrieve the feed associated with the item and ensure it exists - $feed = $main->getState()->getFeed(intval($item['feed_id'] ?? 0)); + $feed = $main->getState()->getFeed(intval($feed_id)); if (empty($feed)) { $main->setResponseCode(404); header('Content-Type: application/json'); From 71f5399e93f81c44d1703b233b955884e0846807 Mon Sep 17 00:00:00 2001 From: Josh Brickner Date: Tue, 17 Jun 2025 14:37:22 -0600 Subject: [PATCH 8/9] bug fixes --- src/Brickner/Podsumer/FSState.php | 33 ++++++++++++++++++++++---- src/Brickner/Podsumer/PodcastIndex.php | 1 + 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/src/Brickner/Podsumer/FSState.php b/src/Brickner/Podsumer/FSState.php index 5a3da65..4bf882c 100644 --- a/src/Brickner/Podsumer/FSState.php +++ b/src/Brickner/Podsumer/FSState.php @@ -97,6 +97,15 @@ public function deleteFeed(int $feed_id) { $feed = $this->getFeed($feed_id); + # If no feed record is found, or if the feed record lacks a valid + # name, run the parent clean-up logic and exit early. Attempting to + # continue without a valid feed name can lead to resolving the media + # root directory as the feed directory which is unsafe. + if (empty($feed) || empty(trim($feed['name'] ?? ''))) { + parent::deleteFeed($feed_id); + return; + } + # Capture all related files before we alter the database so we can # safely remove them from disk afterwards. $files_to_delete = []; @@ -130,11 +139,25 @@ public function deleteFeed(int $feed_id) } # Finally, try to remove the (now empty) feed directory - $feed_dir = $this->getFeedDir($feed['name'] ?? '') ?: null; - if ($feed_dir && file_exists($feed_dir)) { - $files_in_dir = array_diff(scandir($feed_dir), ['.', '..']); - if (empty($files_in_dir)) { - @rmdir($feed_dir); + $feed_name = trim($feed['name']); + + if ($feed_name !== '') { + $feed_dir = $this->getFeedDir($feed_name); + $media_dir = rtrim($this->getMediaDir(), DIRECTORY_SEPARATOR); + + # Ensure the directory we are about to touch is not the media root + if ($feed_dir !== $media_dir && file_exists($feed_dir) && is_dir($feed_dir)) { + $dir_contents = @scandir($feed_dir); + + # scandir() returns false on failure. Guard against that so we + # do not pass a boolean to array_diff(), which would raise a + # TypeError. + if (false !== $dir_contents) { + $files_in_dir = array_diff($dir_contents, ['.', '..']); + if (empty($files_in_dir)) { + @rmdir($feed_dir); + } + } } } } diff --git a/src/Brickner/Podsumer/PodcastIndex.php b/src/Brickner/Podsumer/PodcastIndex.php index 0e3d285..b1b1f22 100644 --- a/src/Brickner/Podsumer/PodcastIndex.php +++ b/src/Brickner/Podsumer/PodcastIndex.php @@ -28,6 +28,7 @@ static public function search(string $query, int $max, string $key, string $secr curl_setopt($curl, \CURLOPT_HTTPHEADER, $headers); curl_setopt($curl, \CURLOPT_FOLLOWLOCATION, true); curl_setopt($curl, \CURLOPT_CONNECTTIMEOUT, 30); + curl_setopt($curl, \CURLOPT_TIMEOUT, 60); $result = curl_exec($curl); // Always close the handle to avoid leaking resources. From 1f0c9acd92510f2ce21b5689c82c0be9a433d90b Mon Sep 17 00:00:00 2001 From: Josh Brickner Date: Tue, 17 Jun 2025 15:11:29 -0600 Subject: [PATCH 9/9] bug fixes and improvements --- conf/podsumer.conf | 12 ++ src/Brickner/Podsumer/SponsorBlock.php | 1 + templates/item.html.php | 159 ++++++++++++++++++++----- www/index.php | 16 ++- 4 files changed, 158 insertions(+), 30 deletions(-) diff --git a/conf/podsumer.conf b/conf/podsumer.conf index 6e7730f..bab5d65 100755 --- a/conf/podsumer.conf +++ b/conf/podsumer.conf @@ -33,6 +33,18 @@ media_dir = /opt/media playback_interval = 5 playback_rewind = 5 +# Enable/disable SponsorBlock integration auto-skip timestamps +# Set to `true` to enable. Default is off. +sponsorblock_enabled = false + +# PodcastIndex API key and secret +# +# These are used to search for podcasts and episodes using the PodcastIndex API. +# +# You can get your own API key and secret by signing up at https://podcastindex.org/ +# +# Once you have your API key and secret, you can add them here. podcastindex_key = "" podcastindex_secret = "" + diff --git a/src/Brickner/Podsumer/SponsorBlock.php b/src/Brickner/Podsumer/SponsorBlock.php index fe6fe7e..0841796 100644 --- a/src/Brickner/Podsumer/SponsorBlock.php +++ b/src/Brickner/Podsumer/SponsorBlock.php @@ -20,6 +20,7 @@ class SponsorBlock */ public static function getSegments(string $videoId, array $categories = ['sponsor', 'selfpromo', 'interaction', 'intro', 'outro']): array { + $categories = ['sponsor', 'selfpromo', 'interaction', 'intro', 'outro']; if (empty($videoId)) { return []; } diff --git a/templates/item.html.php b/templates/item.html.php index dfcc351..727e8f7 100755 --- a/templates/item.html.php +++ b/templates/item.html.php @@ -11,6 +11,16 @@
+ main->getConf('podsumer', 'sponsorblock_enabled'), FILTER_VALIDATE_BOOLEAN); + ?> + + + +
SponsorBlock skipping is in alpha.
+ + +
@@ -59,6 +69,30 @@ color: rgb(253, 230, 138); } + .sponsor-breaks { + max-width: 380px; + margin: 0.5rem auto 1.5rem; + text-align: center; + font-size: 0.9rem; + color: rgb(253, 230, 138); + } + + .sponsor-breaks a { + color: rgb(253, 230, 138); + text-decoration: underline; + margin: 0 4px; + } + .sponsor-breaks a:hover { + opacity: 0.8; + } + + .alpha-notice { + text-align: center; + font-size: 0.8rem; + color: rgb(253, 230, 138); + margin-bottom: 0.25rem; + } + @media (max-width: 640px) { .media-container { max-width: 90%; @@ -67,6 +101,8 @@ diff --git a/www/index.php b/www/index.php index b9a679a..9295f53 100755 --- a/www/index.php +++ b/www/index.php @@ -510,6 +510,14 @@ function sponsor_segments(array $args): void { global $main; + // Feature flag – disabled by default + $enabled = filter_var($main->getConf('podsumer', 'sponsorblock_enabled'), \FILTER_VALIDATE_BOOLEAN); + if (!$enabled) { + header('Content-Type: application/json'); + echo json_encode([]); + return; + } + // Validate required parameter if (empty($args['item_id'])) { $main->setResponseCode(404); @@ -555,9 +563,13 @@ function sponsor_segments(array $args): void return; } - $segments = \Brickner\Podsumer\SponsorBlock::getSegments($videoIds[0]); + $videoId = $videoIds[0]; + $segments = \Brickner\Podsumer\SponsorBlock::getSegments($videoId); header('Content-Type: application/json'); - echo json_encode($segments); + echo json_encode([ + 'videoId' => $videoId, + 'segments' => $segments, + ]); }