diff --git a/.DS_Store b/.DS_Store index a0e9b59..0bd7e1b 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/conf/podsumer.conf b/conf/podsumer.conf index cfca4e1..bab5d65 100755 --- a/conf/podsumer.conf +++ b/conf/podsumer.conf @@ -33,3 +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/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/FSState.php b/src/Brickner/Podsumer/FSState.php index 5f96477..4bf882c 100644 --- a/src/Brickner/Podsumer/FSState.php +++ b/src/Brickner/Podsumer/FSState.php @@ -96,46 +96,70 @@ protected function escapeFilename(string $filename): string public function deleteFeed(int $feed_id) { $feed = $this->getFeed($feed_id); - $file_id = $feed['image']; - $file = $this->getFileById($file_id); - - if ($file['storage_mode'] == 'DISK' && file_exists($file['filename'])) { - unlink($file['filename']); + # 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; } - $items = $this->getFeedItems($feed_id); - foreach ($items as $item) { - $file_id = $item['image']; - - if (!empty($file_id)) { + # Capture all related files before we alter the database so we can + # safely remove them from disk afterwards. + $files_to_delete = []; - $file = $this->getFileById($file_id); + $image_file_id = $feed['image'] ?? null; + if ($image_file_id) { + $files_to_delete[] = $this->getFileById($image_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_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); + } + } + } } - - parent::deleteFeed($feed_id); } public function deleteItemMedia(int $item_id) @@ -150,8 +174,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/PodcastIndex.php b/src/Brickner/Podsumer/PodcastIndex.php new file mode 100644 index 0000000..b1b1f22 --- /dev/null +++ b/src/Brickner/Podsumer/PodcastIndex.php @@ -0,0 +1,49 @@ + | [] + */ + public static function getSegments(string $videoId, array $categories = ['sponsor', 'selfpromo', 'interaction', 'intro', 'outro']): array + { + $categories = ['sponsor', 'selfpromo', 'interaction', 'intro', 'outro']; + 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/home.html.php b/templates/home.html.php index f972345..3b8a248 100755 --- a/templates/home.html.php +++ b/templates/home.html.php @@ -41,5 +41,12 @@    + +
+

Search PodcastIndex

+ +    + +
diff --git a/templates/item.html.php b/templates/item.html.php index 548002a..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/templates/search.html.php b/templates/search.html.php new file mode 100644 index 0000000..2b8bcae --- /dev/null +++ b/templates/search.html.php @@ -0,0 +1,39 @@ +
+

Search Podcasts

+
+ + +
+ + +

No Results

+ + + +
+ + + +

+
+ + +
+

+
+ + + +
+ 1) { ?> + Previous + + + 1) { ?> |  + Next + +   Page of +
+ +
+ 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/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..9295f53 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, 1000, $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 @@ -79,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']); @@ -473,3 +505,71 @@ 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; + + // 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); + 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'])); + if (empty($item)) { + $main->setResponseCode(404); + header('Content-Type: application/json'); + echo json_encode(['error' => 'item not found']); + 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($feed_id)); + 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'] ?? ''); + + $videoIds = \Brickner\Podsumer\YouTubeSearch::search($query, 1); + if (empty($videoIds)) { + header('Content-Type: application/json'); + echo json_encode([]); + return; + } + + $videoId = $videoIds[0]; + $segments = \Brickner\Podsumer\SponsorBlock::getSegments($videoId); + + header('Content-Type: application/json'); + echo json_encode([ + 'videoId' => $videoId, + 'segments' => $segments, + ]); +} +