2024-05-19 05:49:42 +00:00
< ? php
namespace App\Services ;
use App\Exceptions\FailedToParsePodcastFeedException ;
use App\Exceptions\UserAlreadySubscribedToPodcast ;
2024-05-31 05:40:34 +00:00
use App\Models\Podcast ;
use App\Models\PodcastUserPivot ;
2024-05-19 05:49:42 +00:00
use App\Models\Song as Episode ;
use App\Models\User ;
use App\Repositories\PodcastRepository ;
use App\Repositories\SongRepository ;
use Carbon\Carbon ;
use GuzzleHttp\Client ;
use GuzzleHttp\RedirectMiddleware ;
2024-07-07 13:29:37 +00:00
use GuzzleHttp\RequestOptions ;
2024-05-19 05:49:42 +00:00
use Illuminate\Support\Arr ;
use Illuminate\Support\Facades\DB ;
use Illuminate\Support\Facades\Http ;
use Illuminate\Support\Facades\Log ;
2024-06-07 12:11:45 +00:00
use Illuminate\Support\Str ;
2024-05-19 05:49:42 +00:00
use PhanAn\Poddle\Poddle ;
use PhanAn\Poddle\Values\Episode as EpisodeValue ;
use PhanAn\Poddle\Values\EpisodeCollection ;
2024-05-31 14:51:10 +00:00
use Psr\Http\Client\ClientInterface ;
2024-05-19 05:49:42 +00:00
use Throwable ;
2024-05-31 14:51:10 +00:00
use Webmozart\Assert\Assert ;
2024-05-19 05:49:42 +00:00
class PodcastService
{
public function __construct (
private readonly PodcastRepository $podcastRepository ,
2024-05-31 14:51:10 +00:00
private readonly SongRepository $songRepository ,
private ? ClientInterface $client = null ,
2024-05-19 05:49:42 +00:00
) {
}
public function addPodcast ( string $url , User $user ) : Podcast
{
// Since downloading and parsing a feed can be time-consuming, try setting the execution time to 5 minutes
@ ini_set ( 'max_execution_time' , 300 );
$podcast = $this -> podcastRepository -> findOneByUrl ( $url );
if ( $podcast ) {
if ( $this -> isPodcastObsolete ( $podcast )) {
$this -> refreshPodcast ( $podcast );
}
$this -> subscribeUserToPodcast ( $user , $podcast );
return $podcast ;
}
try {
2024-05-31 14:51:10 +00:00
$parser = $this -> createParser ( $url );
2024-05-19 05:49:42 +00:00
$channel = $parser -> getChannel ();
return DB :: transaction ( function () use ( $url , $podcast , $parser , $channel , $user ) {
2024-07-07 13:29:37 +00:00
/** @var Podcast $podcast */
2024-05-19 05:49:42 +00:00
$podcast = Podcast :: query () -> create ([
'url' => $url ,
'title' => $channel -> title ,
'description' => $channel -> description ,
'author' => $channel -> metadata -> author ,
'link' => $channel -> link ,
'language' => $channel -> language ,
'explicit' => $channel -> explicit ,
'image' => $channel -> image ,
'categories' => $channel -> categories ,
'metadata' => $channel -> metadata ,
'added_by' => $user -> id ,
'last_synced_at' => now (),
]);
$this -> synchronizeEpisodes ( $podcast , $parser -> getEpisodes ( true ));
$this -> subscribeUserToPodcast ( $user , $podcast );
return $podcast ;
});
} catch ( UserAlreadySubscribedToPodcast $exception ) {
throw $exception ;
} catch ( Throwable $exception ) {
Log :: error ( $exception );
throw FailedToParsePodcastFeedException :: create ( $url , $exception );
}
}
public function refreshPodcast ( Podcast $podcast ) : Podcast
{
2024-05-31 14:51:10 +00:00
$parser = $this -> createParser ( $podcast -> url );
2024-05-19 05:49:42 +00:00
$channel = $parser -> getChannel ();
2024-07-08 21:15:19 +00:00
$pubDate = $parser -> xmlReader -> value ( 'rss.channel.pubDate' ) -> first ()
? ? $parser -> xmlReader -> value ( 'rss.channel.lastBuildDate' ) -> first ();
if ( $pubDate && Carbon :: createFromFormat ( Carbon :: RFC1123 , $pubDate ) -> isBefore ( $podcast -> last_synced_at )) {
// The pubDate/lastBuildDate value indicates that there's no new content since last check.
// We'll simply return the podcast.
return $podcast ;
}
$this -> synchronizeEpisodes ( $podcast , $parser -> getEpisodes ( true ));
2024-05-19 05:49:42 +00:00
$podcast -> update ([
'title' => $channel -> title ,
'description' => $channel -> description ,
'author' => $channel -> metadata -> author ,
'link' => $channel -> link ,
'language' => $channel -> language ,
'explicit' => $channel -> explicit ,
'image' => $channel -> image ,
'categories' => $channel -> categories ,
'metadata' => $channel -> metadata ,
'last_synced_at' => now (),
]);
return $podcast -> refresh ();
}
private function synchronizeEpisodes ( Podcast $podcast , EpisodeCollection $episodeCollection ) : void
{
$existingEpisodeGuids = $this -> songRepository -> getEpisodeGuidsByPodcast ( $podcast );
2024-06-07 12:11:45 +00:00
$records = [];
$ids = [];
2024-05-19 05:49:42 +00:00
/** @var EpisodeValue $episodeValue */
foreach ( $episodeCollection as $episodeValue ) {
if ( ! in_array ( $episodeValue -> guid -> value , $existingEpisodeGuids , true )) {
2024-06-07 12:11:45 +00:00
$id = Str :: uuid () -> toString ();
$ids [] = $id ;
$records [] = [
'id' => $id ,
'podcast_id' => $podcast -> id ,
'title' => $episodeValue -> title ,
'lyrics' => '' ,
'path' => $episodeValue -> enclosure -> url ,
'created_at' => $episodeValue -> metadata -> pubDate ? : now (),
'updated_at' => $episodeValue -> metadata -> pubDate ? : now (),
'episode_metadata' => $episodeValue -> metadata -> toJson (),
'episode_guid' => $episodeValue -> guid ,
'length' => $episodeValue -> metadata -> duration ? ? 0 ,
'mtime' => time (),
'is_public' => true ,
];
2024-05-19 05:49:42 +00:00
}
}
2024-06-07 12:11:45 +00:00
// We use insert() instead of $podcast->episodes()->createMany() for better performance,
// as the latter would trigger a separate query for each episode.
Episode :: insert ( $records );
// Since insert() doesn't trigger model events, Scout operations will not be called.
// We have to manually update the search index.
2024-07-07 13:29:37 +00:00
Episode :: query () -> whereIn ( 'id' , $ids ) -> searchable (); // @phpstan-ignore-line
2024-05-19 05:49:42 +00:00
}
private function subscribeUserToPodcast ( User $user , Podcast $podcast ) : void
{
2024-05-31 05:40:34 +00:00
$user -> subscribeToPodcast ( $podcast );
2024-05-19 05:49:42 +00:00
// Refreshing so that $podcast->subscribers are updated
$podcast -> refresh ();
}
public function updateEpisodeProgress ( User $user , Episode $episode , int $position ) : void
{
2024-05-31 14:51:10 +00:00
Assert :: true ( $user -> subscribedToPodcast ( $episode -> podcast ));
2024-05-19 05:49:42 +00:00
/** @var PodcastUserPivot $subscription */
$subscription = $episode -> podcast -> subscribers -> sole ( 'id' , $user -> id ) -> pivot ;
$state = $subscription -> state -> toArray ();
$state [ 'current_episode' ] = $episode -> id ;
$state [ 'progresses' ][ $episode -> id ] = $position ;
$subscription -> update ([ 'state' => $state ]);
}
public function unsubscribeUserFromPodcast ( User $user , Podcast $podcast ) : void
{
2024-05-31 14:51:10 +00:00
$user -> unsubscribeFromPodcast ( $podcast );
2024-05-19 05:49:42 +00:00
}
public function isPodcastObsolete ( Podcast $podcast ) : bool
{
if ( $podcast -> last_synced_at -> diffInHours ( now ()) < 12 ) {
// If we have recently synchronized the podcast, consider it "fresh"
return false ;
}
try {
$lastModified = Http :: send ( 'HEAD' , $podcast -> url ) -> header ( 'Last-Modified' );
return $lastModified
&& Carbon :: createFromFormat ( Carbon :: RFC1123 , $lastModified ) -> isAfter ( $podcast -> last_synced_at );
} catch ( Throwable ) {
return true ;
}
}
/**
2024-05-31 14:51:10 +00:00
* Get a directly streamable ( CORS - friendly ) URL by following redirects if necessary .
2024-05-19 05:49:42 +00:00
*/
public function getStreamableUrl ( string | Episode $url , ? Client $client = null , string $method = 'OPTIONS' ) : ? string
{
if ( ! is_string ( $url )) {
$url = $url -> path ;
}
2024-07-07 13:29:37 +00:00
$client ? ? = new Client ();
2024-05-19 05:49:42 +00:00
try {
$response = $client -> request ( $method , $url , [
2024-07-07 13:29:37 +00:00
RequestOptions :: HEADERS => [
2024-05-19 05:49:42 +00:00
'User-Agent' => 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_5) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Safari/605.1.15' , // @phpcs-ignore-line
'Origin' => '*' ,
],
2024-07-07 13:29:37 +00:00
RequestOptions :: HTTP_ERRORS => false ,
RequestOptions :: ALLOW_REDIRECTS => [ 'track_redirects' => true ],
2024-05-19 05:49:42 +00:00
]);
$redirects = Arr :: wrap ( $response -> getHeader ( RedirectMiddleware :: HISTORY_HEADER ));
// Sometimes the podcast server disallows OPTIONS requests. We'll try again with a HEAD request.
if ( $response -> getStatusCode () >= 400 && $response -> getStatusCode () < 500 && $method !== 'HEAD' ) {
return $this -> getStreamableUrl ( $url , $client , 'HEAD' );
}
if ( in_array ( '*' , Arr :: wrap ( $response -> getHeader ( 'Access-Control-Allow-Origin' )), true )) {
2024-05-31 14:51:10 +00:00
// If there were redirects, the last one is the final URL.
return $redirects ? Arr :: last ( $redirects ) : $url ;
2024-05-19 05:49:42 +00:00
}
return null ;
} catch ( Throwable ) {
return null ;
}
}
2024-05-31 14:51:10 +00:00
private function createParser ( string $url ) : Poddle
{
return Poddle :: fromUrl ( $url , 5 * 60 , $this -> client );
}
2024-05-19 05:49:42 +00:00
}