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 @@
+
+
+
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
+
+
+ if ($q !== '' && empty($feeds)) { ?>
+
No Results
+ } ?>
+
+ foreach ($feeds as $feed): ?>
+
+ if (!empty($feed['artwork'])) { ?>
+

+ } ?>
+
= $feed['title'] ?>
+
+ Subscribe
+
+
= substr($feed['description'] ?? '', 0, 360) ?>
+
+ endforeach ?>
+
+ if (!empty($feeds)) { ?>
+
+ if ($page > 1) { ?>
+
Previous
+ } ?>
+ if ($page < $page_count) { ?>
+ if ($page > 1) { ?> | } ?>
+
Next
+ } ?>
+
Page = $page ?> of = $page_count ?>
+
+ } ?>
+
+
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 @@
foreach ($feeds as $feed): ?>
if (!empty($feed['artwork'])) { ?>
-

+
 ?>)
} ?>
-
= $feed['title'] ?>
-
- Subscribe
-
-
= substr($feed['description'] ?? '', 0, 360) ?>
+
= htmlspecialchars($feed['title'], ENT_QUOTES) ?>
+
+
= htmlspecialchars(substr($feed['description'] ?? '', 0, 360), ENT_QUOTES) ?>
endforeach ?>
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.
+
+
+
= $item['description'] ?>
@@ -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,
+ ]);
}