mirror of
https://github.com/koel/koel
synced 2024-11-10 06:34:14 +00:00
feat(test): BE tests for Podcast feature
This commit is contained in:
parent
f60d7b0acf
commit
3d68b1b470
21 changed files with 506 additions and 55 deletions
|
@ -2,7 +2,6 @@
|
|||
|
||||
namespace App\Builders;
|
||||
|
||||
use App\Enums\PlayableType;
|
||||
use App\Facades\License;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
@ -128,8 +127,8 @@ class SongBuilder extends Builder
|
|||
->where('storage', '!=', '');
|
||||
}
|
||||
|
||||
public function typeOf(PlayableType $type): self
|
||||
public function onlySongs(): self
|
||||
{
|
||||
return $this->where('songs.type', $type->value);
|
||||
return $this->whereNull('songs.podcast_id');
|
||||
}
|
||||
}
|
||||
|
|
|
@ -21,6 +21,8 @@ class UpdatePlaybackStatusController extends Controller
|
|||
Authenticatable $user
|
||||
) {
|
||||
$song = $songRepository->getOne($request->song, $user);
|
||||
$this->authorize('access', $song);
|
||||
|
||||
$queueService->updatePlaybackStatus($user, $song, $request->position);
|
||||
|
||||
if ($song->isEpisode()) {
|
||||
|
|
|
@ -2,18 +2,17 @@
|
|||
|
||||
namespace App\Http\Requests\API;
|
||||
|
||||
use App\Enums\PlayableType;
|
||||
use App\Models\Song;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
/** @property-read array<string> $songs */
|
||||
class DeleteSongsRequest extends Request
|
||||
{
|
||||
/** @return array<mixed> */
|
||||
/** @inheritdoc */
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'songs' => ['required', 'array', Rule::exists(Song::class, 'id')->where('type', PlayableType::SONG)],
|
||||
'songs' => ['required', 'array', Rule::exists(Song::class, 'id')->whereNull('podcast_id')],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,7 +2,6 @@
|
|||
|
||||
namespace App\Http\Requests\API;
|
||||
|
||||
use App\Enums\PlayableType;
|
||||
use App\Models\Song;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
|
@ -12,12 +11,12 @@ use Illuminate\Validation\Rule;
|
|||
*/
|
||||
class SongUpdateRequest extends Request
|
||||
{
|
||||
/** @return array<mixed> */
|
||||
/** @inheritdoc */
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'data' => 'required|array',
|
||||
'songs' => ['required', 'array', Rule::exists(Song::class, 'id')->where('type', PlayableType::SONG)],
|
||||
'songs' => ['required', 'array', Rule::exists(Song::class, 'id')->whereNull('podcast_id')],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,7 +2,6 @@
|
|||
|
||||
namespace App\Listeners;
|
||||
|
||||
use App\Enums\PlayableType;
|
||||
use App\Events\MediaScanCompleted;
|
||||
use App\Models\Song;
|
||||
use App\Repositories\SongRepository;
|
||||
|
@ -24,7 +23,7 @@ class DeleteNonExistingRecordsPostScan
|
|||
->toArray();
|
||||
|
||||
Song::deleteWhereValueNotIn($paths, 'path', static function (Builder $builder): Builder {
|
||||
return $builder->where('type', PlayableType::SONG);
|
||||
return $builder->whereNull('podcast_id');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,7 +2,6 @@
|
|||
|
||||
namespace App\Listeners;
|
||||
|
||||
use App\Enums\PlayableType;
|
||||
use App\Events\SongLikeToggled;
|
||||
use App\Services\LastfmService;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
|
@ -16,7 +15,7 @@ class LoveTrackOnLastfm implements ShouldQueue
|
|||
public function handle(SongLikeToggled $event): void
|
||||
{
|
||||
if (
|
||||
$event->interaction->song->type !== PlayableType::SONG
|
||||
$event->interaction->song->isEpisode()
|
||||
|| !LastfmService::enabled()
|
||||
|| !$event->interaction->user->preferences->lastFmSessionKey
|
||||
|| $event->interaction->song->artist->is_unknown
|
||||
|
|
|
@ -2,7 +2,6 @@
|
|||
|
||||
namespace App\Listeners;
|
||||
|
||||
use App\Enums\PlayableType;
|
||||
use App\Events\PlaybackStarted;
|
||||
use App\Services\LastfmService;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
|
@ -18,7 +17,7 @@ class UpdateLastfmNowPlaying implements ShouldQueue
|
|||
if (
|
||||
!LastfmService::enabled()
|
||||
|| !$event->user->preferences->lastFmSessionKey
|
||||
|| $event->song->type !== PlayableType::SONG
|
||||
|| $event->song->isEpisode()
|
||||
|| $event->song->artist?->is_unknown
|
||||
) {
|
||||
return;
|
||||
|
|
|
@ -5,7 +5,6 @@ namespace App\Models;
|
|||
use App\Builders\PodcastBuilder;
|
||||
use App\Casts\Podcast\CategoriesCast;
|
||||
use App\Casts\Podcast\PodcastMetadataCast;
|
||||
use App\Enums\PlayableType;
|
||||
use App\Models\Song as Episode;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
|
@ -87,6 +86,7 @@ class Podcast extends Model
|
|||
{
|
||||
return $this->episodes()->create([
|
||||
'title' => $dto->title,
|
||||
'lyrics' => '',
|
||||
'path' => $dto->enclosure->url,
|
||||
'created_at' => $dto->metadata->pubDate ?: now(),
|
||||
'episode_metadata' => $dto->metadata,
|
||||
|
@ -94,7 +94,6 @@ class Podcast extends Model
|
|||
'length' => $dto->metadata->duration ?? 0,
|
||||
'mtime' => time(),
|
||||
'is_public' => true,
|
||||
'type' => PlayableType::PODCAST_EPISODE,
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
|
@ -56,11 +56,12 @@ use Throwable;
|
|||
* @property-read ?string $collaborator_name The name of the user who added the song to the playlist
|
||||
* @property-read ?int $collaborator_id The ID of the user who added the song to the playlist
|
||||
* @property-read ?string $added_at The date the song was added to the playlist
|
||||
* @property PlayableType $type
|
||||
* @property-read PlayableType $type
|
||||
*
|
||||
* // Podcast episode properties
|
||||
* @property ?EpisodeMetadata $episode_metadata
|
||||
* @property ?string $episode_guid
|
||||
* @property ?string $podcast_id
|
||||
* @property ?Podcast $podcast
|
||||
*/
|
||||
class Song extends Model
|
||||
|
@ -82,7 +83,6 @@ class Song extends Model
|
|||
'track' => 'int',
|
||||
'disc' => 'int',
|
||||
'is_public' => 'bool',
|
||||
'type' => PlayableType::class,
|
||||
'episode_metadata' => EpisodeMetadataCast::class,
|
||||
];
|
||||
|
||||
|
@ -93,7 +93,6 @@ class Song extends Model
|
|||
protected static function booted(): void
|
||||
{
|
||||
static::creating(static function (self $song): void {
|
||||
$song->type ??= PlayableType::SONG;
|
||||
$song->id ??= Str::uuid()->toString();
|
||||
});
|
||||
}
|
||||
|
@ -143,6 +142,11 @@ class Song extends Model
|
|||
return $this->hasMany(Interaction::class);
|
||||
}
|
||||
|
||||
protected function type(): Attribute
|
||||
{
|
||||
return Attribute::get(fn () => $this->podcast_id ? PlayableType::PODCAST_EPISODE : PlayableType::SONG);
|
||||
}
|
||||
|
||||
protected function title(): Attribute
|
||||
{
|
||||
return new Attribute(
|
||||
|
|
|
@ -102,6 +102,11 @@ class User extends Authenticatable
|
|||
$this->podcasts()->attach($podcast);
|
||||
}
|
||||
|
||||
public function unsubscribeFromPodcast(Podcast $podcast): void
|
||||
{
|
||||
$this->podcasts()->detach($podcast);
|
||||
}
|
||||
|
||||
protected function avatar(): Attribute
|
||||
{
|
||||
return Attribute::get(function (): string {
|
||||
|
|
|
@ -3,7 +3,6 @@
|
|||
namespace App\Repositories;
|
||||
|
||||
use App\Builders\SongBuilder;
|
||||
use App\Enums\PlayableType;
|
||||
use App\Facades\License;
|
||||
use App\Models\Album;
|
||||
use App\Models\Artist;
|
||||
|
@ -37,7 +36,7 @@ class SongRepository extends Repository
|
|||
$scopedUser ??= $this->auth->user();
|
||||
|
||||
return Song::query()
|
||||
->typeOf(PlayableType::SONG)
|
||||
->onlySongs()
|
||||
->accessibleBy($scopedUser)
|
||||
->withMetaFor($scopedUser)
|
||||
->latest()
|
||||
|
@ -51,7 +50,7 @@ class SongRepository extends Repository
|
|||
$scopedUser ??= $this->auth->user();
|
||||
|
||||
return Song::query()
|
||||
->typeOf(PlayableType::SONG)
|
||||
->onlySongs()
|
||||
->accessibleBy($scopedUser)
|
||||
->withMetaFor($scopedUser, requiresInteractions: true)
|
||||
->where('interactions.play_count', '>', 0)
|
||||
|
@ -83,7 +82,7 @@ class SongRepository extends Repository
|
|||
$scopedUser ??= $this->auth->user();
|
||||
|
||||
return Song::query()
|
||||
->typeOf(PlayableType::SONG)
|
||||
->onlySongs()
|
||||
->accessibleBy($scopedUser)
|
||||
->withMetaFor($scopedUser)
|
||||
->when($ownSongsOnly, static fn (SongBuilder $query) => $query->where('songs.owner_id', $scopedUser->id))
|
||||
|
@ -201,7 +200,7 @@ class SongRepository extends Repository
|
|||
$scopedUser ??= $this->auth->user();
|
||||
|
||||
return Song::query()
|
||||
->typeOf(PlayableType::SONG)
|
||||
->onlySongs()
|
||||
->accessibleBy($scopedUser)
|
||||
->withMetaFor($scopedUser)
|
||||
->inRandomOrder()
|
||||
|
@ -276,7 +275,7 @@ class SongRepository extends Repository
|
|||
public function countSongs(?User $scopedUser = null): int
|
||||
{
|
||||
return Song::query()
|
||||
->typeOf(PlayableType::SONG)
|
||||
->onlySongs()
|
||||
->accessibleBy($scopedUser ?? auth()->user())
|
||||
->count();
|
||||
}
|
||||
|
@ -284,7 +283,7 @@ class SongRepository extends Repository
|
|||
public function getTotalSongLength(?User $scopedUser = null): float
|
||||
{
|
||||
return Song::query()
|
||||
->typeOf(PlayableType::SONG)
|
||||
->onlySongs()
|
||||
->accessibleBy($scopedUser ?? auth()->user())
|
||||
->sum('length');
|
||||
}
|
||||
|
|
|
@ -20,13 +20,16 @@ use Illuminate\Support\Facades\Log;
|
|||
use PhanAn\Poddle\Poddle;
|
||||
use PhanAn\Poddle\Values\Episode as EpisodeValue;
|
||||
use PhanAn\Poddle\Values\EpisodeCollection;
|
||||
use Psr\Http\Client\ClientInterface;
|
||||
use Throwable;
|
||||
use Webmozart\Assert\Assert;
|
||||
|
||||
class PodcastService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PodcastRepository $podcastRepository,
|
||||
private readonly SongRepository $songRepository
|
||||
private readonly SongRepository $songRepository,
|
||||
private ?ClientInterface $client = null,
|
||||
) {
|
||||
}
|
||||
|
||||
|
@ -48,7 +51,7 @@ class PodcastService
|
|||
}
|
||||
|
||||
try {
|
||||
$parser = Poddle::fromUrl($url, 5 * 60);
|
||||
$parser = $this->createParser($url);
|
||||
$channel = $parser->getChannel();
|
||||
|
||||
return DB::transaction(function () use ($url, $podcast, $parser, $channel, $user) {
|
||||
|
@ -82,7 +85,7 @@ class PodcastService
|
|||
|
||||
public function refreshPodcast(Podcast $podcast): Podcast
|
||||
{
|
||||
$parser = Poddle::fromUrl($podcast->url);
|
||||
$parser = $this->createParser($podcast->url);
|
||||
$channel = $parser->getChannel();
|
||||
|
||||
$podcast->update([
|
||||
|
@ -102,8 +105,9 @@ class PodcastService
|
|||
?? $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
|
||||
return $podcast->refresh();
|
||||
// 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));
|
||||
|
@ -133,6 +137,8 @@ class PodcastService
|
|||
|
||||
public function updateEpisodeProgress(User $user, Episode $episode, int $position): void
|
||||
{
|
||||
Assert::true($user->subscribedToPodcast($episode->podcast));
|
||||
|
||||
/** @var PodcastUserPivot $subscription */
|
||||
$subscription = $episode->podcast->subscribers->sole('id', $user->id)->pivot;
|
||||
|
||||
|
@ -145,7 +151,7 @@ class PodcastService
|
|||
|
||||
public function unsubscribeUserFromPodcast(User $user, Podcast $podcast): void
|
||||
{
|
||||
$podcast->subscribers()->detach($user);
|
||||
$user->unsubscribeFromPodcast($podcast);
|
||||
}
|
||||
|
||||
public function isPodcastObsolete(Podcast $podcast): bool
|
||||
|
@ -166,7 +172,7 @@ class PodcastService
|
|||
}
|
||||
|
||||
/**
|
||||
* Get a directly streamable (CORS-friendly) from a given URL by following redirects if necessary.
|
||||
* Get a directly streamable (CORS-friendly) URL by following redirects if necessary.
|
||||
*/
|
||||
public function getStreamableUrl(string|Episode $url, ?Client $client = null, string $method = 'OPTIONS'): ?string
|
||||
{
|
||||
|
@ -174,7 +180,7 @@ class PodcastService
|
|||
$url = $url->path;
|
||||
}
|
||||
|
||||
$client ??= new Client();
|
||||
$client ??= $this->client ?? new Client();
|
||||
|
||||
try {
|
||||
$response = $client->request($method, $url, [
|
||||
|
@ -188,19 +194,14 @@ class PodcastService
|
|||
|
||||
$redirects = Arr::wrap($response->getHeader(RedirectMiddleware::HISTORY_HEADER));
|
||||
|
||||
// If there were redirects, we make the CORS check on the last URL, as it
|
||||
// would be the one eventually used by the browser.
|
||||
if ($redirects) {
|
||||
return $this->getStreamableUrl(Arr::last($redirects), $client);
|
||||
}
|
||||
|
||||
// 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)) {
|
||||
return $url;
|
||||
// If there were redirects, the last one is the final URL.
|
||||
return $redirects ? Arr::last($redirects) : $url;
|
||||
}
|
||||
|
||||
return null;
|
||||
|
@ -208,4 +209,9 @@ class PodcastService
|
|||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private function createParser(string $url): Poddle
|
||||
{
|
||||
return Poddle::fromUrl($url, 5 * 60, $this->client);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,7 +3,6 @@
|
|||
namespace App\Services;
|
||||
|
||||
use App\Builders\SongBuilder;
|
||||
use App\Enums\PlayableType;
|
||||
use App\Exceptions\NonSmartPlaylistException;
|
||||
use App\Facades\License;
|
||||
use App\Models\Playlist;
|
||||
|
@ -22,7 +21,7 @@ class SmartPlaylistService
|
|||
throw_unless($playlist->is_smart, NonSmartPlaylistException::create($playlist));
|
||||
|
||||
$query = Song::query()
|
||||
->typeOf(PlayableType::SONG)
|
||||
->onlySongs()
|
||||
->withMetaFor($user ?? $playlist->user)
|
||||
->when(License::isPlus(), static fn (SongBuilder $query) => $query->accessibleBy($user))
|
||||
->when(
|
||||
|
|
14
composer.lock
generated
14
composer.lock
generated
|
@ -4652,16 +4652,16 @@
|
|||
},
|
||||
{
|
||||
"name": "phanan/poddle",
|
||||
"version": "v1.0.3",
|
||||
"version": "v1.1.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/phanan/poddle.git",
|
||||
"reference": "9c0e62914b8c8ba0c3994e774f128dc32980f667"
|
||||
"reference": "55f0cb603fb3e91001deda4f6c328cd620ed52f3"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/phanan/poddle/zipball/9c0e62914b8c8ba0c3994e774f128dc32980f667",
|
||||
"reference": "9c0e62914b8c8ba0c3994e774f128dc32980f667",
|
||||
"url": "https://api.github.com/repos/phanan/poddle/zipball/55f0cb603fb3e91001deda4f6c328cd620ed52f3",
|
||||
"reference": "55f0cb603fb3e91001deda4f6c328cd620ed52f3",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
|
@ -4670,12 +4670,14 @@
|
|||
"illuminate/http": "^10.48",
|
||||
"illuminate/support": "^10.48",
|
||||
"php": ">=8.1",
|
||||
"psr/http-client": "^1.0",
|
||||
"saloonphp/xml-wrangler": "^1.2"
|
||||
},
|
||||
"require-dev": {
|
||||
"larastan/larastan": "^2.9",
|
||||
"laravel/pint": "^1.15",
|
||||
"laravel/tinker": "^2.9",
|
||||
"mockery/mockery": "^1.6",
|
||||
"orchestra/testbench": "*",
|
||||
"phpunit/phpunit": ">=10.5"
|
||||
},
|
||||
|
@ -4706,7 +4708,7 @@
|
|||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/phanan/poddle/issues",
|
||||
"source": "https://github.com/phanan/poddle/tree/v1.0.3"
|
||||
"source": "https://github.com/phanan/poddle/tree/v1.1.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
|
@ -4714,7 +4716,7 @@
|
|||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2024-05-30T07:36:14+00:00"
|
||||
"time": "2024-05-31T07:36:55+00:00"
|
||||
},
|
||||
{
|
||||
"name": "php-http/client-common",
|
||||
|
|
|
@ -2,8 +2,8 @@
|
|||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Enums\PlayableType;
|
||||
use App\Models\Album;
|
||||
use App\Models\Podcast;
|
||||
use App\Models\Song;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
@ -44,6 +44,9 @@ class SongFactory extends Factory
|
|||
|
||||
public function asEpisode(): self
|
||||
{
|
||||
return $this->state(fn () => ['type' => PlayableType::PODCAST_EPISODE]); // @phpcs:ignore
|
||||
return $this->state(fn () => [ // @phpcs:ignore
|
||||
'podcast_id' => Podcast::factory(),
|
||||
'is_public' => true,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -36,7 +36,6 @@ return new class extends Migration
|
|||
$table->string('podcast_id', 36)->nullable();
|
||||
$table->string('episode_guid')->nullable()->unique();
|
||||
$table->json('episode_metadata')->nullable();
|
||||
$table->string('type')->default('song')->index();
|
||||
|
||||
$table->foreign('podcast_id')->references('id')->on('podcasts')->cascadeOnDelete();
|
||||
});
|
||||
|
|
|
@ -2,7 +2,6 @@
|
|||
|
||||
namespace Tests\Integration\Listeners;
|
||||
|
||||
use App\Enums\PlayableType;
|
||||
use App\Enums\SongStorageType;
|
||||
use App\Events\MediaScanCompleted;
|
||||
use App\Listeners\DeleteNonExistingRecordsPostScan;
|
||||
|
@ -37,7 +36,7 @@ class DeleteNonExistingRecordsPostSyncTest extends TestCase
|
|||
|
||||
public function testHandleDoesNotDeleteEpisodes(): void
|
||||
{
|
||||
$episode = Song::factory()->create(['type' => PlayableType::PODCAST_EPISODE]);
|
||||
$episode = Song::factory()->asEpisode()->create();
|
||||
$this->listener->handle(new MediaScanCompleted(ScanResultCollection::create()));
|
||||
self::assertModelExists($episode);
|
||||
}
|
||||
|
|
207
tests/Integration/Services/PodcastServiceTest.php
Normal file
207
tests/Integration/Services/PodcastServiceTest.php
Normal file
|
@ -0,0 +1,207 @@
|
|||
<?php
|
||||
|
||||
namespace Integration\Services;
|
||||
|
||||
use App\Exceptions\UserAlreadySubscribedToPodcast;
|
||||
use App\Models\Podcast;
|
||||
use App\Models\PodcastUserPivot;
|
||||
use App\Models\Song;
|
||||
use App\Services\PodcastService;
|
||||
use GuzzleHttp\Client;
|
||||
use GuzzleHttp\Handler\MockHandler;
|
||||
use GuzzleHttp\HandlerStack;
|
||||
use GuzzleHttp\Psr7\Response;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Psr\Http\Client\ClientInterface;
|
||||
use Tests\TestCase;
|
||||
|
||||
use function Tests\create_user;
|
||||
use function Tests\test_path;
|
||||
|
||||
class PodcastServiceTest extends TestCase
|
||||
{
|
||||
private PodcastService $service;
|
||||
|
||||
public function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$mock = new MockHandler([
|
||||
new Response(200, [], file_get_contents(test_path('blobs/podcast.xml'))),
|
||||
]);
|
||||
|
||||
$handlerStack = HandlerStack::create($mock);
|
||||
$this->instance(ClientInterface::class, new Client(['handler' => $handlerStack]));
|
||||
|
||||
$this->service = app(PodcastService::class);
|
||||
}
|
||||
|
||||
public function testAddPodcast(): void
|
||||
{
|
||||
$url = 'https://example.com/feed.xml';
|
||||
$user = create_user();
|
||||
|
||||
$podcast = $this->service->addPodcast($url, $user);
|
||||
|
||||
$this->assertDatabaseHas(Podcast::class, [
|
||||
'url' => $url,
|
||||
'title' => 'Podcast Feed Parser',
|
||||
'description' => 'Parse podcast feeds with PHP following PSP-1 Podcast RSS Standard',
|
||||
'image' => 'https://github.com/phanan.png',
|
||||
'author' => 'Phan An (phanan)',
|
||||
'language' => 'en-US',
|
||||
'explicit' => false,
|
||||
'added_by' => $user->id,
|
||||
]);
|
||||
|
||||
self::assertCount(8, $podcast->episodes);
|
||||
}
|
||||
|
||||
public function testSubscribeUserToPodcast(): void
|
||||
{
|
||||
$podcast = Podcast::factory()->create([
|
||||
'url' => 'https://example.com/feed.xml',
|
||||
'title' => 'My Cool Podcast',
|
||||
]);
|
||||
|
||||
$user = create_user();
|
||||
|
||||
self::assertFalse($user->subscribedToPodcast($podcast));
|
||||
|
||||
$this->service->addPodcast('https://example.com/feed.xml', $user);
|
||||
self::assertTrue($user->subscribedToPodcast($podcast));
|
||||
|
||||
// the title shouldn't have changed
|
||||
self::assertSame('My Cool Podcast', $podcast->fresh()->title);
|
||||
}
|
||||
|
||||
public function testResubscribeUserToPodcastThrows(): void
|
||||
{
|
||||
self::expectException(UserAlreadySubscribedToPodcast::class);
|
||||
|
||||
$podcast = Podcast::factory()->create([
|
||||
'url' => 'https://example.com/feed.xml',
|
||||
]);
|
||||
|
||||
$user = create_user();
|
||||
$user->subscribeToPodcast($podcast);
|
||||
|
||||
$this->service->addPodcast('https://example.com/feed.xml', $user);
|
||||
}
|
||||
|
||||
public function testAddingRefreshesObsoletePodcast(): void
|
||||
{
|
||||
self::expectException(UserAlreadySubscribedToPodcast::class);
|
||||
|
||||
Http::fake([
|
||||
'https://example.com/feed.xml' => Http::response(headers: ['Last-Modified' => now()->toRfc1123String()]),
|
||||
]);
|
||||
|
||||
$podcast = Podcast::factory()->create([
|
||||
'url' => 'https://example.com/feed.xml',
|
||||
'title' => 'Shall be changed very sad',
|
||||
'last_synced_at' => now()->subDays(3),
|
||||
]);
|
||||
|
||||
self::assertCount(0, $podcast->episodes);
|
||||
|
||||
$user = create_user();
|
||||
$user->subscribeToPodcast($podcast);
|
||||
|
||||
$this->service->addPodcast('https://example.com/feed.xml', $user);
|
||||
|
||||
self::assertCount(8, $podcast->episodes);
|
||||
self::assertSame('Podcast Feed Parser', $podcast->title);
|
||||
}
|
||||
|
||||
public function testUnsubscribeUserFromPodcast(): void
|
||||
{
|
||||
$podcast = Podcast::factory()->create();
|
||||
$user = create_user();
|
||||
$user->subscribeToPodcast($podcast);
|
||||
|
||||
$this->service->unsubscribeUserFromPodcast($user, $podcast);
|
||||
|
||||
self::assertFalse($user->subscribedToPodcast($podcast));
|
||||
}
|
||||
|
||||
public function testPodcastNotObsoleteIfSyncedRecently(): void
|
||||
{
|
||||
$podcast = Podcast::factory()->create([
|
||||
'last_synced_at' => now()->subHours(6),
|
||||
]);
|
||||
|
||||
self::assertFalse($this->service->isPodcastObsolete($podcast));
|
||||
}
|
||||
|
||||
public function testPodcastObsoleteIfModifiedSinceLastSync(): void
|
||||
{
|
||||
Http::fake([
|
||||
'https://example.com/feed.xml' => Http::response(headers: ['Last-Modified' => now()->toRfc1123String()]),
|
||||
]);
|
||||
|
||||
$podcast = Podcast::factory()->create([
|
||||
'url' => 'https://example.com/feed.xml',
|
||||
'last_synced_at' => now()->subDays(1),
|
||||
]);
|
||||
|
||||
self::assertTrue($this->service->isPodcastObsolete($podcast));
|
||||
}
|
||||
|
||||
public function testUpdateEpisodeProgress(): void
|
||||
{
|
||||
$episode = Song::factory()->asEpisode()->create();
|
||||
$user = create_user();
|
||||
$user->subscribeToPodcast($episode->podcast);
|
||||
|
||||
$this->service->updateEpisodeProgress($user, $episode->refresh(), 123);
|
||||
|
||||
/** @var PodcastUserPivot $subscription */
|
||||
$subscription = $episode->podcast->subscribers->sole('id', $user->id)->pivot;
|
||||
|
||||
self::assertSame($episode->id, $subscription->state->currentEpisode);
|
||||
self::assertSame(123, $subscription->state->progresses[$episode->id]);
|
||||
}
|
||||
|
||||
public function testGetStreamableUrl(): void
|
||||
{
|
||||
$mock = new MockHandler([
|
||||
new Response(200, ['Access-Control-Allow-Origin' => '*']),
|
||||
]);
|
||||
|
||||
$handlerStack = HandlerStack::create($mock);
|
||||
$client = new Client(['handler' => $handlerStack]);
|
||||
|
||||
self::assertSame(
|
||||
'https://example.com/episode.mp3',
|
||||
$this->service->getStreamableUrl('https://example.com/episode.mp3', $client)
|
||||
);
|
||||
}
|
||||
|
||||
public function testStreamableUrlNotAvailable(): void
|
||||
{
|
||||
$mock = new MockHandler([new Response(200, [])]);
|
||||
|
||||
$handlerStack = HandlerStack::create($mock);
|
||||
$client = new Client(['handler' => $handlerStack]);
|
||||
|
||||
self::assertNull($this->service->getStreamableUrl('https://example.com/episode.mp3', $client));
|
||||
}
|
||||
|
||||
public function testGetStreamableUrlFollowsRedirects(): void
|
||||
{
|
||||
$mock = new MockHandler([
|
||||
new Response(302, ['Location' => 'https://redir.example.com/track']),
|
||||
new Response(302, ['Location' => 'https://assets.example.com/episode.mp3']),
|
||||
new Response(200, ['Access-Control-Allow-Origin' => '*']),
|
||||
]);
|
||||
|
||||
$handlerStack = HandlerStack::create($mock);
|
||||
$client = new Client(['handler' => $handlerStack]);
|
||||
|
||||
self::assertSame(
|
||||
'https://assets.example.com/episode.mp3',
|
||||
$this->service->getStreamableUrl('https://example.com/episode.mp3', $client)
|
||||
);
|
||||
}
|
||||
}
|
|
@ -7,6 +7,7 @@ use App\Exceptions\KoelPlusRequiredException;
|
|||
use App\Models\Song;
|
||||
use App\Services\Streamer\Adapters\LocalStreamerAdapter;
|
||||
use App\Services\Streamer\Adapters\PhpStreamerAdapter;
|
||||
use App\Services\Streamer\Adapters\PodcastStreamerAdapter;
|
||||
use App\Services\Streamer\Adapters\S3CompatibleStreamerAdapter;
|
||||
use App\Services\Streamer\Adapters\TranscodingStreamerAdapter;
|
||||
use App\Services\Streamer\Adapters\XAccelRedirectStreamerAdapter;
|
||||
|
@ -98,4 +99,13 @@ class StreamerTest extends TestCase
|
|||
|
||||
config(['koel.streaming.method' => null]);
|
||||
}
|
||||
|
||||
public function testResolvePodcastAdapter(): void
|
||||
{
|
||||
/** @var Song $song */
|
||||
$song = Song::factory()->asEpisode()->create();
|
||||
$streamer = new Streamer($song);
|
||||
|
||||
self::assertInstanceOf(PodcastStreamerAdapter::class, $streamer->getAdapter());
|
||||
}
|
||||
}
|
||||
|
|
35
tests/Integration/Values/EpisodePlayableTest.php
Normal file
35
tests/Integration/Values/EpisodePlayableTest.php
Normal file
|
@ -0,0 +1,35 @@
|
|||
<?php
|
||||
|
||||
namespace Integration\Values;
|
||||
|
||||
use App\Models\Song;
|
||||
use App\Values\Podcast\EpisodePlayable;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Tests\TestCase;
|
||||
|
||||
class EpisodePlayableTest extends TestCase
|
||||
{
|
||||
public function testCreateAndRetrieved(): void
|
||||
{
|
||||
Http::fake([
|
||||
'https://example.com/episode.mp3' => Http::response('foo'),
|
||||
]);
|
||||
|
||||
$episode = Song::factory()->asEpisode()->create([
|
||||
'path' => 'https://example.com/episode.mp3',
|
||||
]);
|
||||
|
||||
$playable = EpisodePlayable::createForEpisode($episode);
|
||||
self::assertSame('acbd18db4cc2f85cedef654fccc4a4d8', $playable->checksum);
|
||||
|
||||
self::assertTrue(Cache::has("episode-playable.$episode->id"));
|
||||
|
||||
$retrieved = EpisodePlayable::retrieveForEpisode($episode);
|
||||
self::assertSame($playable, $retrieved);
|
||||
self::assertTrue($retrieved->valid());
|
||||
|
||||
file_put_contents($playable->path, 'bar');
|
||||
self::assertFalse($retrieved->valid());
|
||||
}
|
||||
}
|
188
tests/blobs/podcast.xml
Normal file
188
tests/blobs/podcast.xml
Normal file
|
@ -0,0 +1,188 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0"
|
||||
xmlns:atom="http://www.w3.org/2005/Atom"
|
||||
xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd"
|
||||
xmlns:podcast="https://podcastindex.org/namespace/1.0">
|
||||
|
||||
<channel>
|
||||
<title>Podcast Feed Parser</title>
|
||||
<link>https://github.com/phanan/podcast-feed-parser</link>
|
||||
<description><![CDATA[Parse podcast feeds with PHP following PSP-1 Podcast RSS Standard]]></description>
|
||||
<lastBuildDate>Thu, 02 May 2024 06:44:38 +0000</lastBuildDate>
|
||||
|
||||
<image>
|
||||
<url>https://github.com/phanan.png</url>
|
||||
<title>Phan An</title>
|
||||
<link>https://phanan.net</link>
|
||||
</image>
|
||||
<atom:link rel="self" type="application/rss+xml" title="Podcast Feed Parser" href="https://phanan.net"/>
|
||||
<language>en-US</language>
|
||||
<generator>Rice Cooker</generator>
|
||||
<copyright>Phan An © 2024</copyright>
|
||||
<itunes:author>Phan An (phanan)</itunes:author>
|
||||
<itunes:type>episodic</itunes:type>
|
||||
<itunes:category text="News">
|
||||
<itunes:category text="Tech News"/>
|
||||
</itunes:category>
|
||||
|
||||
<itunes:image href="https://github.com/phanan.png"/>
|
||||
<itunes:block>no</itunes:block>
|
||||
<itunes:explicit>no</itunes:explicit>
|
||||
|
||||
<podcast:funding url="https://github.com/sponsors/phanan">Sponsor me on GitHub</podcast:funding>
|
||||
<podcast:funding url="https://opencollective.com/koel">My other Open Collective</podcast:funding>
|
||||
|
||||
<podcast:txt>naj3eEZaWVVY9a38uhX8FekACyhtqP4JN</podcast:txt>
|
||||
<podcast:txt purpose="verify">S6lpp-7ZCn8-dZfGc-OoyaG</podcast:txt>
|
||||
|
||||
<item>
|
||||
<itunes:episodeType>trailer</itunes:episodeType>
|
||||
<itunes:title>Hiking Treks Trailer</itunes:title>
|
||||
<description><![CDATA[The Sunset Explorers share tips, techniques and recommendations for great hikes]]></description>
|
||||
<enclosure
|
||||
length="498537"
|
||||
type="audio/mpeg"
|
||||
url="http://example.com/podcasts/everything/AllAboutEverythingEpisode4.mp3"
|
||||
/>
|
||||
<guid>D03EEC9B-B1B4-475B-92C8-54F853FA2A22</guid>
|
||||
<pubDate>Tue, 8 Jan 2019 01:15:00 GMT</pubDate>
|
||||
<itunes:duration>1079</itunes:duration>
|
||||
<itunes:explicit>false</itunes:explicit>
|
||||
</item>
|
||||
<item>
|
||||
<itunes:episodeType>full</itunes:episodeType>
|
||||
<itunes:episode>4</itunes:episode>
|
||||
<itunes:season>2</itunes:season>
|
||||
<title>S02 EP04 Mt. Hood, Oregon</title>
|
||||
<description>
|
||||
Tips for trekking around the tallest mountain in Oregon
|
||||
</description>
|
||||
<enclosure
|
||||
length="8727310"
|
||||
type="audio/x-m4a"
|
||||
url="http://example.com/podcasts/everything/mthood.m4a"
|
||||
/>
|
||||
<guid>22BCFEBF-44FB-4A19-8229-7AC678629F57</guid>
|
||||
<pubDate>Tue, 07 May 2019 12:00:00 GMT</pubDate>
|
||||
<itunes:duration>1024</itunes:duration>
|
||||
<itunes:explicit>false</itunes:explicit>
|
||||
</item>
|
||||
<item>
|
||||
<itunes:episodeType>full</itunes:episodeType>
|
||||
<itunes:episode>3</itunes:episode>
|
||||
<itunes:season>2</itunes:season>
|
||||
<title>S02 EP03 Bouldering Around Boulder</title>
|
||||
<description>
|
||||
We explore fun walks to climbing areas about the beautiful Colorado city of Boulder.
|
||||
</description>
|
||||
<itunes:image
|
||||
href="http://example.com/podcasts/everything/AllAboutEverything/Episode2.jpg"
|
||||
/>
|
||||
<link>href="http://example.com/podcasts/everything/</link>
|
||||
<enclosure
|
||||
length="5650889"
|
||||
type="video/mp4"
|
||||
url="http://example.com/podcasts/boulder.mp4"
|
||||
/>
|
||||
<guid>BE486CAA-B3D5-4FB0-8298-EFEBE71C5982</guid>
|
||||
<pubDate>Tue, 30 Apr 2019 13:00:00 EST</pubDate>
|
||||
<itunes:duration>3627</itunes:duration>
|
||||
<itunes:explicit>false</itunes:explicit>
|
||||
</item>
|
||||
<item>
|
||||
<itunes:episodeType>full</itunes:episodeType>
|
||||
<itunes:episode>2</itunes:episode>
|
||||
<itunes:season>2</itunes:season>
|
||||
<title>S02 EP02 Caribou Mountain, Maine</title>
|
||||
<description>
|
||||
Put your fitness to the test with this invigorating hill climb.
|
||||
</description>
|
||||
<itunes:image
|
||||
href="http://example.com/podcasts/everything/AllAboutEverything/Episode3.jpg"
|
||||
/>
|
||||
<enclosure
|
||||
length="5650889"
|
||||
type="audio/x-m4v"
|
||||
url="http://example.com/podcasts/everything/caribou.m4v"
|
||||
/>
|
||||
<guid>142FAFE9-B1DF-4F6D-BAA8-79BDBAF653A9</guid>
|
||||
<pubDate>Tue, 23 May 2019 02:00:00 -0700</pubDate>
|
||||
<itunes:duration>2434</itunes:duration>
|
||||
<itunes:explicit>false</itunes:explicit>
|
||||
</item>
|
||||
<item>
|
||||
<itunes:episodeType>full</itunes:episodeType>
|
||||
<itunes:episode>4</itunes:episode>
|
||||
<itunes:season>1</itunes:season>
|
||||
<title>S01 EP04 Kuliouou Ridge Trail</title>
|
||||
<description>
|
||||
Oahu, Hawaii, has some picturesque hikes and this is one of the best!
|
||||
</description>
|
||||
<enclosure
|
||||
length="498537"
|
||||
type="audio/mpeg"
|
||||
url="http://example.com/podcasts/everything/AllAboutEverythingEpisode4.mp3"
|
||||
/>
|
||||
<guid>B5FCEB80-317C-4CD0-A84B-807065B43FB9</guid>
|
||||
<pubDate>Tue, 27 Nov 2018 01:15:00 +0000</pubDate>
|
||||
<itunes:duration>929</itunes:duration>
|
||||
<itunes:explicit>false</itunes:explicit>
|
||||
</item>
|
||||
<item>
|
||||
<itunes:episodeType>full</itunes:episodeType>
|
||||
<itunes:episode>3</itunes:episode>
|
||||
<itunes:season>1</itunes:season>
|
||||
<title>S01 EP03 Blood Mountain Loop</title>
|
||||
<description>
|
||||
Hiking the Appalachian Trail and Freeman Trail in Georgia
|
||||
</description>
|
||||
<enclosure
|
||||
length="498537"
|
||||
type="audio/mpeg"
|
||||
url="http://example.com/podcasts/everything/AllAboutEverythingEpisode4.mp3"
|
||||
/>
|
||||
<guid>F0C5D763-ED85-4449-9C09-81FEBDF6F126</guid>
|
||||
<pubDate>Tue, 23 Oct 2018 01:15:00 +0000</pubDate>
|
||||
<itunes:duration>1440</itunes:duration>
|
||||
<itunes:explicit>false</itunes:explicit>
|
||||
</item>
|
||||
<item>
|
||||
<itunes:episodeType>full</itunes:episodeType>
|
||||
<itunes:episode>2</itunes:episode>
|
||||
<itunes:season>1</itunes:season>
|
||||
<title>S01 EP02 Garden of the Gods Wilderness</title>
|
||||
<description>
|
||||
Wilderness Area Garden of the Gods in Illinois is a delightful spot for
|
||||
an extended hike.
|
||||
</description>
|
||||
<enclosure
|
||||
length="498537"
|
||||
type="audio/mpeg"
|
||||
url="http://example.com/podcasts/everything/AllAboutEverythingEpisode4.mp3"
|
||||
/>
|
||||
<guid>821DD0B2-571D-4DFD-8E11-556E8C1EFE6A</guid>
|
||||
<pubDate>Tue, 18 Sep 2018 01:15:00 +0000</pubDate>
|
||||
<itunes:duration>839</itunes:duration>
|
||||
<itunes:explicit>false</itunes:explicit>
|
||||
</item>
|
||||
<item>
|
||||
<itunes:episodeType>full</itunes:episodeType>
|
||||
<itunes:episode>1</itunes:episode>
|
||||
<itunes:season>1</itunes:season>
|
||||
<title>S01 EP01 Upper Priest Lake Trail to Continental Creek Trail</title>
|
||||
<description>
|
||||
We check out this powerfully scenic hike following the river in the Idaho
|
||||
Panhandle National Forests.
|
||||
</description>
|
||||
<enclosure
|
||||
length="498537"
|
||||
type="audio/mpeg"
|
||||
url="http://example.com/podcasts/everything/AllAboutEverythingEpisode4.mp3"
|
||||
/>
|
||||
<guid>EABDA7EE-1AC6-4B60-9E11-6B3F30B72F87</guid>
|
||||
<pubDate>Tue, 14 Aug 2018 01:15:00 +0000</pubDate>
|
||||
<itunes:duration>1399</itunes:duration>
|
||||
<itunes:explicit>false</itunes:explicit>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
Loading…
Reference in a new issue