feat(test): BE tests for Podcast feature

This commit is contained in:
Phan An 2024-05-31 22:51:10 +08:00
parent f60d7b0acf
commit 3d68b1b470
21 changed files with 506 additions and 55 deletions

View file

@ -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');
}
}

View file

@ -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()) {

View file

@ -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')],
];
}
}

View file

@ -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')],
];
}
}

View file

@ -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');
});
}
}

View file

@ -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

View file

@ -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;

View file

@ -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,
]);
}

View file

@ -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(

View file

@ -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 {

View file

@ -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');
}

View file

@ -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);
}
}

View file

@ -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
View file

@ -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",

View file

@ -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,
]);
}
}

View file

@ -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();
});

View file

@ -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);
}

View 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)
);
}
}

View file

@ -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());
}
}

View 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
View 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>