Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file modified .DS_Store
Binary file not shown.
15 changes: 15 additions & 0 deletions conf/podsumer.conf
Original file line number Diff line number Diff line change
Expand Up @@ -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 = ""


3 changes: 3 additions & 0 deletions conf/test.conf
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,6 @@ ssl = false
media_dir = /opt/media
playback_interval = 5
playback_rewind = 5

podcastindex_key = ""
podcastindex_secret = ""
84 changes: 56 additions & 28 deletions src/Brickner/Podsumer/FSState.php
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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);
Expand Down
49 changes: 49 additions & 0 deletions src/Brickner/Podsumer/PodcastIndex.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<?php declare(strict_types = 1);

namespace Brickner\Podsumer;

class PodcastIndex
{
static public function search(string $query, int $max, string $key, string $secret, ?int $time = null): array
{
if (empty($key) || empty($secret) || empty($query)) {
return [];
}

$time = $time ?? time();
$hash = sha1($key . $secret . $time);

$headers = [
'User-Agent: Podsumer',
'X-Auth-Date: ' . $time,
'X-Auth-Key: ' . $key,
'Authorization: ' . $hash,
];

$url = 'https://api.podcastindex.org/api/1.0/search/byterm?q=' . urlencode($query) . '&max=' . $max;

$curl = curl_init();
curl_setopt($curl, \CURLOPT_URL, $url);
curl_setopt($curl, \CURLOPT_RETURNTRANSFER, true);
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.
curl_close($curl);

if (false === $result) {
return [];
}

$data = json_decode($result, true);
if (!is_array($data) || !array_key_exists('feeds', $data)) {
return [];
}

return $data['feeds'];
}
}

54 changes: 54 additions & 0 deletions src/Brickner/Podsumer/SponsorBlock.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
<?php declare(strict_types = 1);

namespace Brickner\Podsumer;

/**
* Very small wrapper around the public SponsorBlock API
* https://wiki.sponsor.ajay.app/
*
* We currently only use it to obtain *skip segments* for a YouTube video so we
* can automatically skip sponsor reads during podcast playback.
*/
class SponsorBlock
{
/**
* @param string $videoId YouTube video ID.
* @param string[] $categories Array of category slugs we care about.
* Defaults to the standard sponsor + selfpromo + interaction set.
*
* @return array<int,array{segment:array{0:float,1:float},category:string}> | []
*/
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;
}
}
Loading