feat: podcasts

This commit is contained in:
Phan An 2024-05-19 13:49:42 +08:00
parent 911410bdfd
commit 3e321bf47e
198 changed files with 4145 additions and 1048 deletions

View file

@ -0,0 +1,18 @@
<?php
namespace App\Builders;
use App\Models\User;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Query\JoinClause;
class PodcastBuilder extends Builder
{
public function subscribedBy(User $user): self
{
return $this->join('podcast_user', static function (JoinClause $join) use ($user): void {
$join->on('podcasts.id', 'podcast_user.podcast_id')
->where('user_id', $user->id);
});
}
}

View file

@ -2,6 +2,7 @@
namespace App\Builders;
use App\Enums\MediaType;
use App\Facades\License;
use App\Models\User;
use Illuminate\Database\Eloquent\Builder;
@ -21,6 +22,8 @@ class SongBuilder extends Builder
'disc' => 'songs.disc',
'artist_name' => 'artists.name',
'album_name' => 'albums.name',
'podcast_title' => 'podcasts.title',
'podcast_author' => 'podcasts.author',
];
private const VALID_SORT_COLUMNS = [
@ -30,6 +33,8 @@ class SongBuilder extends Builder
'songs.created_at',
'artists.name',
'albums.name',
'podcasts.title',
'podcasts.author',
];
public function inDirectory(string $path): self
@ -43,7 +48,7 @@ class SongBuilder extends Builder
public function withMetaFor(User $user, bool $requiresInteractions = false): self
{
$joinClosure = static function (JoinClause $join) use ($user): void {
$join->on('interactions.song_id', '=', 'songs.id')->where('interactions.user_id', $user->id);
$join->on('interactions.song_id', 'songs.id')->where('interactions.user_id', $user->id);
};
return $this
@ -53,8 +58,8 @@ class SongBuilder extends Builder
static fn (self $query) => $query->join('interactions', $joinClosure),
static fn (self $query) => $query->leftJoin('interactions', $joinClosure)
)
->join('albums', 'songs.album_id', '=', 'albums.id')
->join('artists', 'songs.artist_id', '=', 'artists.id')
->leftJoin('albums', 'songs.album_id', 'albums.id')
->leftJoin('artists', 'songs.artist_id', 'artists.id')
->distinct('songs.id')
->select(
'songs.*',
@ -78,14 +83,15 @@ class SongBuilder extends Builder
});
}
public function sort(string $column, string $direction): self
private function sortByOneColumn(string $column, string $direction): self
{
$column = self::normalizeSortColumn($column);
Assert::oneOf($column, self::VALID_SORT_COLUMNS);
Assert::oneOf(strtolower($direction), ['asc', 'desc']);
return $this->orderBy($column, $direction)
return $this
->orderBy($column, $direction)
->when($column === 'artists.name', static fn (self $query) => $query->orderBy('albums.name')
->orderBy('songs.disc')
->orderBy('songs.track')
@ -98,6 +104,17 @@ class SongBuilder extends Builder
->orderBy('songs.track'));
}
public function sort(array $columns, string $direction): self
{
$this->leftJoin('podcasts', 'songs.podcast_id', 'podcasts.id');
foreach ($columns as $column) {
$this->sortByOneColumn($column, $direction);
}
return $this;
}
private static function normalizeSortColumn(string $column): string
{
return key_exists($column, self::SORT_COLUMNS_NORMALIZE_MAP)
@ -110,4 +127,9 @@ class SongBuilder extends Builder
return $this->whereNotNull('storage')
->where('storage', '!=', '');
}
public function typeOf(MediaType $type): self
{
return $this->where('songs.type', $type->value);
}
}

View file

@ -0,0 +1,30 @@
<?php
namespace App\Casts\Podcast;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use Illuminate\Database\Eloquent\Model;
use PhanAn\Poddle\Values\CategoryCollection;
use Throwable;
class CategoriesCast implements CastsAttributes
{
public function get(Model $model, string $key, mixed $value, array $attributes): CategoryCollection
{
try {
return CategoryCollection::fromArray($value ? json_decode($value, true) : []);
} catch (Throwable) {
return CategoryCollection::make();
}
}
/** @param CategoryCollection|array<mixed> $value */
public function set(Model $model, string $key, mixed $value, array $attributes): string
{
if (is_array($value)) {
$value = CategoryCollection::fromArray($value);
}
return $value->toJson();
}
}

View file

@ -0,0 +1,25 @@
<?php
namespace App\Casts\Podcast;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use Illuminate\Database\Eloquent\Model;
use PhanAn\Poddle\Values\Enclosure;
class EnclosureCast implements CastsAttributes
{
public function get(Model $model, string $key, mixed $value, array $attributes): Enclosure
{
return Enclosure::fromArray(json_decode($value, true));
}
/** @param Enclosure|array $value */
public function set(Model $model, string $key, mixed $value, array $attributes): string
{
if (is_array($value)) {
$value = Enclosure::fromArray($value);
}
return $value->toJson();
}
}

View file

@ -0,0 +1,30 @@
<?php
namespace App\Casts\Podcast;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use Illuminate\Database\Eloquent\Model;
use PhanAn\Poddle\Values\EpisodeMetadata;
use Throwable;
class EpisodeMetadataCast implements CastsAttributes
{
public function get(Model $model, string $key, mixed $value, array $attributes): EpisodeMetadata
{
try {
return EpisodeMetadata::fromArray(json_decode($value, true));
} catch (Throwable) {
return EpisodeMetadata::fromArray([]);
}
}
/** @param EpisodeMetadata|array<mixed>|null $value */
public function set(Model $model, string $key, mixed $value, array $attributes): string
{
if (is_array($value)) {
$value = EpisodeMetadata::fromArray($value);
}
return $value?->toJson() ?? json_encode([]);
}
}

View file

@ -0,0 +1,30 @@
<?php
namespace App\Casts\Podcast;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use Illuminate\Database\Eloquent\Model;
use PhanAn\Poddle\Values\ChannelMetadata;
use Throwable;
class PodcastMetadataCast implements CastsAttributes
{
public function get(Model $model, string $key, mixed $value, array $attributes): ChannelMetadata
{
try {
return ChannelMetadata::fromArray(json_decode($value, true));
} catch (Throwable) {
return ChannelMetadata::fromArray([]);
}
}
/** @param ChannelMetadata|array<mixed>|null $value */
public function set(Model $model, string $key, mixed $value, array $attributes): string
{
if (is_array($value)) {
$value = ChannelMetadata::fromArray($value);
}
return $value?->toJson() ?? json_encode([]);
}
}

View file

@ -0,0 +1,31 @@
<?php
namespace App\Casts\Podcast;
use App\Values\Podcast\PodcastState;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use Illuminate\Database\Eloquent\Model;
class PodcastStateCast implements CastsAttributes
{
public function get(Model $model, string $key, mixed $value, array $attributes): PodcastState
{
if (is_string($value)) {
$value = json_decode($value, true);
}
return PodcastState::fromArray($value ?? []);
}
/**
* @param PodcastState|array|null $value
*/
public function set(Model $model, string $key, mixed $value, array $attributes): ?string
{
if (is_array($value)) {
$value = PodcastState::fromArray($value);
}
return $value?->toJson();
}
}

View file

@ -0,0 +1,41 @@
<?php
namespace App\Console\Commands;
use App\Models\Podcast\Podcast;
use App\Services\PodcastService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
use Throwable;
class SyncPodcastsCommand extends Command
{
protected $signature = 'koel:podcasts:sync';
public function __construct(private readonly PodcastService $podcastService)
{
parent::__construct();
}
public function handle(): int
{
Podcast::query()->get()->each(function (Podcast $podcast): void {
try {
$this->info("Checking \"$podcast->title\" for new content…");
if (!$this->podcastService->isPodcastObsolete($podcast)) {
$this->warn('└── The podcast feed has not been updated recently, skipping.');
return;
}
$this->info('└── Synchronizing episodes…');
$this->podcastService->refreshPodcast($podcast);
} catch (Throwable $e) {
Log::error($e);
}
});
return self::SUCCESS;
}
}

View file

@ -4,6 +4,7 @@ namespace App\Console;
use App\Console\Commands\PruneLibraryCommand;
use App\Console\Commands\ScanCommand;
use App\Console\Commands\SyncPodcastsCommand;
use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Foundation\Console\Kernel as ConsoleKernel;
@ -18,5 +19,6 @@ class Kernel extends ConsoleKernel
{
$schedule->command(ScanCommand::class)->daily();
$schedule->command(PruneLibraryCommand::class)->daily();
$schedule->command(SyncPodcastsCommand::class)->daily();
}
}

9
app/Enums/MediaType.php Normal file
View file

@ -0,0 +1,9 @@
<?php
namespace App\Enums;
enum MediaType: string
{
case SONG = 'song';
case PODCAST_EPISODE = 'episode';
}

View file

@ -0,0 +1,19 @@
<?php
namespace App\Exceptions;
use Exception;
use Throwable;
final class FailedToParsePodcastFeedException extends Exception
{
private function __construct(string $url, Throwable $previous)
{
parent::__construct("Failed to parse the podcast feed at $url.", (int) $previous->getCode(), $previous);
}
public static function create(string $url, Throwable $previous): self
{
return new self($url, $previous);
}
}

View file

@ -0,0 +1,15 @@
<?php
namespace App\Exceptions;
use App\Models\Podcast\Podcast;
use App\Models\User;
use Exception;
final class UserAlreadySubscribedToPodcast extends Exception
{
public static function make(User $user, Podcast $podcast): self
{
return new self("User $user->id has already subscribed to podcast $podcast->id");
}
}

View file

@ -53,8 +53,8 @@ class FetchInitialDataController extends Controller
'latest_version' => $user->is_admin
? $applicationInformationService->getLatestVersionNumber()
: koel_version(),
'song_count' => $songRepository->count(),
'song_length' => $songRepository->getTotalLength(),
'song_count' => $songRepository->countSongs(),
'song_length' => $songRepository->getTotalSongLength(),
'queue_state' => QueueStateResource::make($queueService->getQueueState($user)),
'koel_plus' => [
'active' => $licenseStatus->isValid(),

View file

@ -17,7 +17,12 @@ class FetchSongsForQueueController extends Controller
return SongResource::collection(
$request->order === 'rand'
? $repository->getRandom($request->limit, $user)
: $repository->getForQueue($request->sort, $request->order, $request->limit, $user)
: $repository->getForQueue(
explode(',', $request->sort),
$request->order,
$request->limit,
$user
)
);
}
}

View file

@ -24,7 +24,7 @@ class GenreSongController extends Controller
return SongResource::collection(
$repository->getByGenre(
$genre === Genre::NO_GENRE ? '' : $genre,
$request->sort ?: 'songs.title',
$request->sort ? explode(',', $request->sort) : ['songs.title'],
$request->order ?: 'asc',
$user
)

View file

@ -0,0 +1,17 @@
<?php
namespace App\Http\Controllers\API\Podcast;
use App\Http\Controllers\Controller;
use App\Http\Resources\SongResource;
use App\Models\Song as Episode;
class FetchEpisodeController extends Controller
{
public function __invoke(Episode $episode)
{
$this->authorize('view', $episode->podcast);
return SongResource::make($episode);
}
}

View file

@ -0,0 +1,49 @@
<?php
namespace App\Http\Controllers\API\Podcast;
use App\Exceptions\UserAlreadySubscribedToPodcast;
use App\Http\Controllers\Controller;
use App\Http\Requests\API\Podcast\PodcastStoreRequest;
use App\Http\Resources\PodcastResource;
use App\Http\Resources\PodcastResourceCollection;
use App\Models\Podcast\Podcast;
use App\Models\User;
use App\Repositories\PodcastRepository;
use App\Services\PodcastService;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Http\Response;
class PodcastController extends Controller
{
/** @param User $user */
public function __construct(
private readonly PodcastService $podcastService,
private readonly PodcastRepository $podcastRepository,
private readonly ?Authenticatable $user
) {
}
public function index()
{
return PodcastResourceCollection::make($this->podcastRepository->getAllByUser($this->user));
}
public function store(PodcastStoreRequest $request)
{
self::disableInDemo();
try {
return PodcastResource::make($this->podcastService->addPodcast($request->url, $this->user));
} catch (UserAlreadySubscribedToPodcast) {
abort(Response::HTTP_CONFLICT, 'You have already subscribed to this podcast.');
}
}
public function show(Podcast $podcast)
{
$this->authorize('view', $podcast);
return PodcastResource::make($podcast);
}
}

View file

@ -0,0 +1,27 @@
<?php
namespace App\Http\Controllers\API\Podcast;
use App\Http\Controllers\Controller;
use App\Http\Resources\SongResource;
use App\Models\Podcast\Podcast;
use App\Repositories\SongRepository;
use App\Services\PodcastService;
class PodcastEpisodeController extends Controller
{
public function __construct(
private readonly SongRepository $episodeRepository,
private readonly PodcastService $podcastService
) {
}
public function index(Podcast $podcast)
{
if (request()->get('refresh')) {
$this->podcastService->refreshPodcast($podcast);
}
return SongResource::collection($this->episodeRepository->getEpisodesByPodcast($podcast));
}
}

View file

@ -0,0 +1,23 @@
<?php
namespace App\Http\Controllers\API\Podcast;
use App\Http\Controllers\Controller;
use App\Models\Podcast\Podcast;
use App\Models\User;
use App\Services\PodcastService;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Http\Response;
class UnsubscribeFromPodcastController extends Controller
{
/** @param User $user */
public function __invoke(Podcast $podcast, PodcastService $podcastService, Authenticatable $user)
{
abort_unless($user->subscribedToPodcast($podcast), Response::HTTP_BAD_REQUEST);
$podcastService->unsubscribeUserFromPodcast($user, $podcast);
return response()->json();
}
}

View file

@ -36,7 +36,7 @@ class SongController extends Controller
{
return SongResource::collection(
$this->songRepository->getForListing(
sortColumn: $request->sort ?: 'songs.title',
sortColumns: $request->sort ? explode(',', $request->sort) : ['songs.title'],
sortDirection: $request->order ?: 'asc',
ownSongsOnly: $request->boolean('own_songs_only'),
scopedUser: $this->user

View file

@ -5,15 +5,27 @@ namespace App\Http\Controllers\API;
use App\Http\Controllers\Controller;
use App\Http\Requests\API\UpdatePlaybackStatusRequest;
use App\Models\User;
use App\Repositories\SongRepository;
use App\Services\PodcastService;
use App\Services\QueueService;
use Illuminate\Contracts\Auth\Authenticatable;
class UpdatePlaybackStatusController extends Controller
{
/** @param User $user */
public function __invoke(UpdatePlaybackStatusRequest $request, QueueService $queueService, Authenticatable $user)
{
$queueService->updatePlaybackStatus($user, $request->song, $request->position);
public function __invoke(
UpdatePlaybackStatusRequest $request,
SongRepository $songRepository,
QueueService $queueService,
PodcastService $podcastService,
Authenticatable $user
) {
$song = $songRepository->getOne($request->song, $user);
$queueService->updatePlaybackStatus($user, $song, $request->position);
if ($song->isEpisode()) {
$podcastService->updateEpisodeProgress($user, $song, $request->position);
}
return response()->noContent();
}

View file

@ -17,9 +17,14 @@ class DownloadSongsController extends Controller
$songs = Song::query()->findMany($request->songs);
$songs->each(fn ($song) => $this->authorize('download', $song));
// For a single episode, we'll just redirect to its original media to save time and bandwidth
if ($songs->count() === 1 && $songs[0]->isEpisode()) {
return response()->redirectTo($songs[0]->path);
}
$downloadablePath = $service->getDownloadablePath($repository->getMany($request->songs));
abort_unless((bool) $downloadablePath, Response::HTTP_BAD_REQUEST, 'Song cannot be downloaded.');
abort_unless((bool) $downloadablePath, Response::HTTP_BAD_REQUEST, 'Song or episode cannot be downloaded.');
return response()->download($downloadablePath);
}

View file

@ -2,6 +2,7 @@
namespace App\Http\Requests\API;
use App\Enums\MediaType;
use App\Models\Song;
use Illuminate\Validation\Rule;
@ -12,7 +13,7 @@ class DeleteSongsRequest extends Request
public function rules(): array
{
return [
'songs' => ['required', 'array', Rule::exists(Song::class, 'id')],
'songs' => ['required', 'array', Rule::exists(Song::class, 'id')->where('type', MediaType::SONG)],
];
}
}

View file

@ -0,0 +1,21 @@
<?php
namespace App\Http\Requests\API\Podcast;
use App\Http\Requests\API\Request;
/**
* @property-read string $url
*/
class PodcastStoreRequest extends Request
{
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'url' => 'required|url',
];
}
}

View file

@ -2,6 +2,7 @@
namespace App\Http\Requests\API;
use App\Enums\MediaType;
use App\Models\Song;
use Illuminate\Validation\Rule;
@ -16,7 +17,7 @@ class SongUpdateRequest extends Request
{
return [
'data' => 'required|array',
'songs' => ['required', 'array', Rule::exists(Song::class, 'id')],
'songs' => ['required', 'array', Rule::exists(Song::class, 'id')->where('type', MediaType::SONG)],
];
}
}

View file

@ -3,7 +3,7 @@
namespace App\Http\Requests\API;
use App\Models\Song;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\Exists;
/**
* @property-read string $song
@ -11,11 +11,11 @@ use Illuminate\Validation\Rule;
*/
class UpdatePlaybackStatusRequest extends Request
{
/** @return array<mixed> */
/** @inheritdoc */
public function rules(): array
{
return [
'song' => [Rule::exists(Song::class, 'id')],
'song' => ['required', 'string', new Exists(Song::class, 'id')],
'position' => 'required|integer',
];
}

View file

@ -2,9 +2,6 @@
namespace App\Http\Requests\API;
use App\Models\Song;
use Illuminate\Validation\Rule;
/**
* @property-read array<string> $songs
*/
@ -13,8 +10,9 @@ class UpdateQueueStateRequest extends Request
/** @return array<mixed> */
public function rules(): array
{
// @todo validate song/episode ids
return [
'songs' => ['array', Rule::exists(Song::class, 'id')],
'songs' => ['array'],
];
}
}

View file

@ -17,6 +17,9 @@ class ExcerptSearchResource extends JsonResource
'albums' => [
AlbumResource::JSON_STRUCTURE,
],
'podcasts' => [
PodcastResource::JSON_STRUCTURE,
],
];
public function __construct(private readonly ExcerptSearchResult $result)
@ -31,6 +34,7 @@ class ExcerptSearchResource extends JsonResource
'songs' => SongResource::collection($this->result->songs),
'artists' => ArtistResource::collection($this->result->artists),
'albums' => AlbumResource::collection($this->result->albums),
'podcasts' => PodcastResourceCollection::make($this->result->podcasts),
];
}
}

View file

@ -0,0 +1,21 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
use PhanAn\Poddle\Values\Category;
class PodcastCategoryResource extends JsonResource
{
public function __construct(private readonly Category $category)
{
parent::__construct($this->category);
}
/** @inheritDoc */
public function toArray(Request $request): array
{
return ['type' => 'podcast-categories'] + $this->category->toArray();
}
}

View file

@ -0,0 +1,60 @@
<?php
namespace App\Http\Resources;
use App\Models\Podcast\Podcast;
use App\Models\Podcast\PodcastUserPivot;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class PodcastResource extends JsonResource
{
public const JSON_STRUCTURE = [
'type',
'id',
'url',
'title',
'image',
'link',
'description',
'author',
];
public function __construct(private readonly Podcast $podcast, private readonly bool $withSubscriptionData = true)
{
parent::__construct($this->podcast);
}
public static function collection($resource): PodcastResourceCollection
{
return PodcastResourceCollection::make($resource);
}
/** @inheritdoc */
public function toArray(Request $request): array
{
$data = [
'type' => 'podcast',
'id' => $this->podcast->id,
'url' => $this->podcast->url,
'title' => $this->podcast->title,
'image' => $this->podcast->image,
'link' => $this->podcast->link,
'description' => $this->podcast->description,
'author' => $this->podcast->author,
];
if ($this->withSubscriptionData) {
/** @var User $user */
$user = $request->user();
/** @var PodcastUserPivot $pivot */
$pivot = $this->podcast->subscribers->sole('id', $user->id)->pivot;
$data['subscribed_at'] = $pivot->created_at;
$data['state'] = $pivot->state->toArray();
}
return $data;
}
}

View file

@ -0,0 +1,26 @@
<?php
namespace App\Http\Resources;
use App\Models\Podcast\Podcast;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\ResourceCollection;
use Illuminate\Support\Collection;
class PodcastResourceCollection extends ResourceCollection
{
public function __construct(
private readonly Collection $podcasts,
private readonly bool $withSubscriptionData = true
) {
parent::__construct($this->podcasts);
}
/** @inheritDoc */
public function toArray(Request $request): array
{
return $this->podcasts->map(function (Podcast $podcast): PodcastResource {
return PodcastResource::make($podcast, $this->withSubscriptionData);
})->toArray();
}
}

View file

@ -4,6 +4,7 @@ namespace App\Http\Resources;
use App\Models\Song;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Str;
class SongResource extends JsonResource
{
@ -49,27 +50,27 @@ class SongResource extends JsonResource
],
];
public function __construct(protected Song $song)
public function __construct(protected readonly Song $song)
{
parent::__construct($song);
}
/** @return array<mixed> */
/** @inheritDoc */
public function toArray($request): array
{
return [
'type' => 'songs',
$data = [
'type' => Str::plural($this->song->type->value),
'id' => $this->song->id,
'owner_id' => $this->song->owner_id,
'title' => $this->song->title,
'lyrics' => $this->song->lyrics,
'album_id' => $this->song->album->id,
'album_name' => $this->song->album->name,
'artist_id' => $this->song->artist->id,
'artist_name' => $this->song->artist->name,
'album_artist_id' => $this->song->album_artist->id,
'album_artist_name' => $this->song->album_artist->name,
'album_cover' => $this->song->album->cover,
'album_id' => $this->song->album?->id,
'album_name' => $this->song->album?->name,
'artist_id' => $this->song->artist?->id,
'artist_name' => $this->song->artist?->name,
'album_artist_id' => $this->song->album_artist?->id,
'album_artist_name' => $this->song->album_artist?->name,
'album_cover' => $this->song->album?->cover,
'length' => $this->song->length,
'liked' => (bool) $this->song->liked,
'play_count' => (int) $this->song->play_count,
@ -80,5 +81,18 @@ class SongResource extends JsonResource
'is_public' => $this->song->is_public,
'created_at' => $this->song->created_at,
];
if ($this->song->isEpisode()) {
$data += [
'episode_description' => $this->song->episode_metadata->description,
'episode_link' => $this->song->episode_metadata->link,
'episode_image' => $this->song->episode_metadata->image ?? $this->song->podcast->image,
'podcast_id' => $this->song->podcast->id,
'podcast_title' => $this->song->podcast->title,
'podcast_author' => $this->song->podcast->metadata->author,
];
}
return $data;
}
}

View file

@ -2,10 +2,12 @@
namespace App\Listeners;
use App\Enums\MediaType;
use App\Events\MediaScanCompleted;
use App\Models\Song;
use App\Repositories\SongRepository;
use App\Values\ScanResult;
use Illuminate\Database\Eloquent\Builder;
class DeleteNonExistingRecordsPostScan
{
@ -21,6 +23,8 @@ class DeleteNonExistingRecordsPostScan
->merge($this->songRepository->getAllStoredOnCloud()->pluck('path'))
->toArray();
Song::deleteWhereValueNotIn($paths, 'path');
Song::deleteWhereValueNotIn($paths, 'path', static function (Builder $builder): Builder {
return $builder->where('type', MediaType::SONG);
});
}
}

View file

@ -2,6 +2,7 @@
namespace App\Listeners;
use App\Enums\MediaType;
use App\Events\SongLikeToggled;
use App\Services\LastfmService;
use Illuminate\Contracts\Queue\ShouldQueue;
@ -15,9 +16,10 @@ class LoveTrackOnLastfm implements ShouldQueue
public function handle(SongLikeToggled $event): void
{
if (
!LastfmService::enabled() ||
!$event->interaction->user->preferences->lastFmSessionKey ||
$event->interaction->song->artist->is_unknown
$event->interaction->song->type !== MediaType::SONG
|| !LastfmService::enabled()
|| !$event->interaction->user->preferences->lastFmSessionKey
|| $event->interaction->song->artist->is_unknown
) {
return;
}

View file

@ -2,6 +2,7 @@
namespace App\Listeners;
use App\Enums\MediaType;
use App\Events\PlaybackStarted;
use App\Services\LastfmService;
use Illuminate\Contracts\Queue\ShouldQueue;
@ -17,7 +18,8 @@ class UpdateLastfmNowPlaying implements ShouldQueue
if (
!LastfmService::enabled()
|| !$event->user->preferences->lastFmSessionKey
|| $event->song->artist->is_unknown
|| $event->song->type !== MediaType::SONG
|| $event->song->artist?->is_unknown
) {
return;
}

View file

@ -47,6 +47,8 @@ class Album extends Model
protected $hidden = ['updated_at'];
protected $casts = ['artist_id' => 'integer'];
protected $with = ['artist'];
/** @deprecated */
protected $appends = ['is_compilation'];

View file

@ -2,6 +2,7 @@
namespace App\Models\Concerns;
use Closure;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\DB;
@ -17,14 +18,18 @@ trait SupportsDeleteWhereValueNotIn
/**
* Deletes all records whose certain value is not in an array.
*/
public static function deleteWhereValueNotIn(array $values, ?string $field = null): void
{
public static function deleteWhereValueNotIn(
array $values,
?string $field = null,
?Closure $queryModifier = null
): void {
$field ??= (new static())->getKeyName();
$queryModifier ??= static fn (Builder $builder) => $builder;
$maxChunkSize = DB::getDriverName() === 'sqlite' ? 999 : 65_535;
if (count($values) <= $maxChunkSize) {
static::query()->whereNotIn($field, $values)->delete();
$queryModifier(static::query())->whereNotIn($field, $values)->delete();
return;
}
@ -33,7 +38,7 @@ trait SupportsDeleteWhereValueNotIn
$deletableIds = array_diff($allIds, $values);
if (count($deletableIds) < $maxChunkSize) {
static::query()->whereIn($field, $deletableIds)->delete();
$queryModifier(static::query())->whereIn($field, $deletableIds)->delete();
return;
}

View file

@ -26,6 +26,8 @@ class PlaylistFolder extends Model
protected $keyType = 'string';
protected $guarded = ['id'];
protected $with = ['user'];
protected static function booted(): void
{
static::creating(static fn (self $folder) => $folder->id = Str::uuid()->toString());

View file

@ -0,0 +1,70 @@
<?php
namespace App\Models\Podcast;
use App\Casts\Podcast\EnclosureCast;
use App\Casts\Podcast\EpisodeMetadataCast;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Str;
use Laravel\Scout\Searchable;
use PhanAn\Poddle\Values\Enclosure;
use PhanAn\Poddle\Values\EpisodeMetadata;
use Ramsey\Uuid\Uuid;
/**
* @property EpisodeMetadata $metadata
* @property string $id
* @property Carbon $pub_date
* @property string $podcast_id
* @property Enclosure $enclosure
* @property string $title
* @property Podcast $podcast
* @property string $guid
*/
class Episode extends Model
{
use Searchable;
private const ID_PREFIX = 'e-';
protected $hidden = ['created_at', 'updated_at'];
protected $guarded = [];
protected $keyType = 'string';
public $incrementing = false;
protected $casts = [
'enclosure' => EnclosureCast::class,
'metadata' => EpisodeMetadataCast::class,
'pub_date' => 'datetime',
];
protected $with = ['podcast'];
protected static function booted(): void
{
static::creating(static fn (self $episode) => $episode->id = self::ID_PREFIX . Str::uuid()->toString());
}
public function podcast(): BelongsTo
{
return $this->belongsTo(Podcast::class);
}
/** @inheritDoc */
public function toSearchableArray(): array
{
return [
'id' => $this->id,
'title' => $this->title,
'description' => $this->metadata->description,
];
}
public static function isValidId(string $value): bool
{
return Str::startsWith($value, self::ID_PREFIX) && Uuid::isValid(Str::after($value, self::ID_PREFIX));
}
}

View file

@ -0,0 +1,111 @@
<?php
namespace App\Models\Podcast;
use App\Builders\PodcastBuilder;
use App\Casts\Podcast\CategoriesCast;
use App\Casts\Podcast\PodcastMetadataCast;
use App\Enums\MediaType;
use App\Models\Song as Episode;
use App\Models\User;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Str;
use Laravel\Scout\Searchable;
use PhanAn\Poddle\Values\CategoryCollection;
use PhanAn\Poddle\Values\ChannelMetadata;
use PhanAn\Poddle\Values\Episode as EpisodeDTO;
/**
* @property-read string $id
* @property string $url
* @property string $title
* @property string $description
* @property CategoryCollection $categories
* @property ChannelMetadata $metadata
* @property string $image
* @property string $link
* @property Collection<User> $subscribers
* @property Collection<Episode> $episodes
* @property int $added_by
* @property Carbon $last_synced_at
* @property ?string $author
*/
class Podcast extends Model
{
use Searchable;
protected $hidden = ['created_at', 'updated_at'];
protected $guarded = [];
public $incrementing = false;
protected $keyType = 'string';
protected $casts = [
'categories' => CategoriesCast::class,
'metadata' => PodcastMetadataCast::class,
'last_synced_at' => 'datetime',
'explicit' => 'boolean',
];
protected $with = ['subscribers'];
protected static function booted(): void
{
static::creating(static fn (self $podcast) => $podcast->id = Str::uuid()->toString());
}
public static function query(): PodcastBuilder
{
return parent::query();
}
public function newEloquentBuilder($query): PodcastBuilder
{
return new PodcastBuilder($query);
}
public function episodes(): HasMany
{
return $this->hasMany(Episode::class)->orderByDesc('created_at');
}
public function subscribers(): BelongsToMany
{
return $this->belongsToMany(User::class)
->using(PodcastUserPivot::class)
->withPivot('state')
->withTimestamps();
}
public function addEpisodeByDTO(EpisodeDTO $dto): Episode
{
return $this->episodes()->create([
'title' => $dto->title,
'path' => $dto->enclosure->url,
'created_at' => $dto->metadata->pubDate ?: now(),
'episode_metadata' => $dto->metadata,
'episode_guid' => $dto->guid,
'length' => $dto->metadata->duration ?? 0,
'mtime' => time(),
'is_public' => true,
'type' => MediaType::PODCAST_EPISODE,
]);
}
/** @return array<mixed> */
public function toSearchableArray(): array
{
return [
'id' => $this->id,
'url' => $this->url,
'title' => $this->title,
'description' => $this->description,
'author' => $this->metadata->author,
];
}
}

View file

@ -0,0 +1,24 @@
<?php
namespace App\Models\Podcast;
use App\Casts\Podcast\PodcastStateCast;
use App\Values\Podcast\PodcastState;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Relations\Pivot;
/**
* @property Carbon $created_at
* @property PodcastState $state
*/
class PodcastUserPivot extends Pivot
{
protected $table = 'podcast_user';
protected $guarded = [];
protected $appends = ['meta'];
protected $casts = [
'state' => PodcastStateCast::class,
];
}

View file

@ -3,8 +3,11 @@
namespace App\Models;
use App\Builders\SongBuilder;
use App\Casts\Podcast\EpisodeMetadataCast;
use App\Enums\MediaType;
use App\Enums\SongStorageType;
use App\Models\Concerns\SupportsDeleteWhereValueNotIn;
use App\Models\Podcast\Podcast;
use App\Values\SongStorageMetadata\DropboxMetadata;
use App\Values\SongStorageMetadata\LocalMetadata;
use App\Values\SongStorageMetadata\S3CompatibleMetadata;
@ -20,6 +23,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Str;
use Laravel\Scout\Searchable;
use PhanAn\Poddle\Values\EpisodeMetadata;
use Throwable;
/**
@ -27,8 +31,8 @@ use Throwable;
* @property string $title
* @property Album $album
* @property User $uploader
* @property Artist $artist
* @property Artist $album_artist
* @property ?Artist $artist
* @property ?Artist $album_artist
* @property float $length
* @property string $lyrics
* @property int $track
@ -53,6 +57,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 MediaType $type
*
* // Podcast episode properties
* @property ?EpisodeMetadata $episode_metadata
* @property ?string $episode_guid
* @property ?Podcast $podcast
*/
class Song extends Model
{
@ -73,13 +83,20 @@ class Song extends Model
'track' => 'int',
'disc' => 'int',
'is_public' => 'bool',
'type' => MediaType::class,
'episode_metadata' => EpisodeMetadataCast::class,
];
protected $with = ['album', 'artist', 'podcast'];
protected $keyType = 'string';
protected static function booted(): void
{
static::creating(static fn (self $song) => $song->id = Str::uuid()->toString());
static::creating(static function (self $song): void {
$song->type ??= MediaType::SONG;
$song->id ??= Str::uuid()->toString();
});
}
public static function query(): SongBuilder
@ -107,9 +124,14 @@ class Song extends Model
return $this->belongsTo(Album::class);
}
public function album_artist(): BelongsTo // @phpcs:ignore
protected function albumArtist(): Attribute
{
return $this->album->artist();
return Attribute::get(fn () => $this->album?->artist);
}
public function podcast(): BelongsTo
{
return $this->belongsTo(Podcast::class);
}
public function playlists(): BelongsToMany
@ -132,6 +154,10 @@ class Song extends Model
public function accessibleBy(User $user): bool
{
if ($this->isEpisode()) {
return $user->subscribedToPodcast($this->podcast);
}
return $this->is_public || $this->ownedBy($user);
}
@ -208,15 +234,25 @@ class Song extends Model
$array = [
'id' => $this->id,
'title' => $this->title,
'type' => $this->type->value,
];
if (!$this->artist->is_unknown && !$this->artist->is_various) {
if ($this->episode_metadata?->description) {
$array['episode_description'] = $this->episode_metadata->description;
}
if ($this->artist && !$this->artist->is_unknown && !$this->artist->is_various) {
$array['artist'] = $this->artist->name;
}
return $array;
}
public function isEpisode(): bool
{
return $this->type === MediaType::PODCAST_EPISODE;
}
public function __toString(): string
{
return $this->id;

View file

@ -4,6 +4,8 @@ namespace App\Models;
use App\Casts\UserPreferencesCast;
use App\Facades\License;
use App\Models\Podcast\Podcast;
use App\Models\Podcast\PodcastUserPivot;
use App\Values\UserPreferences;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Casts\Attribute;
@ -40,6 +42,7 @@ use Laravel\Sanctum\PersonalAccessToken;
* @property ?string $sso_provider
* @property ?string $sso_id
* @property bool $is_sso
* @property Collection<array-key, Podcast> $podcast
*/
class User extends Authenticatable
{
@ -66,6 +69,13 @@ class User extends Authenticatable
return $this->hasMany(Playlist::class);
}
public function podcasts(): BelongsToMany
{
return $this->belongsToMany(Podcast::class)
->using(PodcastUserPivot::class)
->withTimestamps();
}
public function collaboratedPlaylists(): BelongsToMany
{
return $this->belongsToMany(Playlist::class, 'playlist_collaborators')->withTimestamps();
@ -81,6 +91,11 @@ class User extends Authenticatable
return $this->hasMany(Interaction::class);
}
public function subscribedToPodcast(Podcast $podcast): bool
{
return $this->podcasts()->where('podcast_id', $podcast->id)->exists();
}
protected function avatar(): Attribute
{
return Attribute::get(function (): string {

View file

@ -0,0 +1,14 @@
<?php
namespace App\Policies;
use App\Models\Podcast\Song;
use App\Models\User;
class EpisodePolicy
{
public function access(User $user, Song $episode): bool
{
return $user->subscribedToPodcast($episode->podcast);
}
}

View file

@ -0,0 +1,14 @@
<?php
namespace App\Policies;
use App\Models\Podcast\Podcast;
use App\Models\User;
class PodcastPolicy
{
public function view(User $user, Podcast $podcast): bool
{
return $user->subscribedToPodcast($podcast);
}
}

View file

@ -9,6 +9,7 @@ use App\Services\LicenseService;
use App\Services\NullMusicEncyclopedia;
use App\Services\SpotifyService;
use Illuminate\Database\DatabaseManager;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Schema\Builder;
use Illuminate\Database\SQLiteConnection;
use Illuminate\Http\Resources\Json\JsonResource;
@ -25,6 +26,8 @@ class AppServiceProvider extends ServiceProvider
// Fix utf8mb4-related error starting from Laravel 5.4
$schema->defaultStringLength(191);
Model::preventLazyLoading(!app()->isProduction());
// Enable on delete cascade for sqlite connections
if ($db->connection() instanceof SQLiteConnection) {
$db->statement($db->raw('PRAGMA foreign_keys = ON')->getValue($db->getQueryGrammar()));

View file

@ -0,0 +1,40 @@
<?php
namespace App\Repositories;
use App\Models\Podcast\Podcast;
use App\Models\User;
use Illuminate\Support\Collection;
/** @extends Repository<Podcast> */
class PodcastRepository extends Repository
{
public function __construct()
{
parent::__construct(Podcast::class);
}
public function findOneByUrl(string $url): ?Podcast
{
return $this->findOneBy(['url' => $url]);
}
/** @return Collection<Podcast> */
public function getAllByUser(User $user): Collection
{
return $user->podcasts;
}
/** @return Collection<Podcast> */
public function getMany(array $ids, bool $inThatOrder = false, ?User $user = null): Collection
{
$podcasts = Podcast::query()
->subscribedBy($user ?? $this->auth->user())
->whereIn('podcasts.id', $ids)
->groupBy('podcasts.id')
->distinct()
->get('podcasts.*');
return $inThatOrder ? $podcasts->orderByArray($ids) : $podcasts;
}
}

View file

@ -35,12 +35,24 @@ abstract class Repository implements RepositoryInterface
return $this->model::query()->findOrFail($id);
}
/** @return T|null */
public function findOneBy(array $params): ?Model
{
return $this->model::query()->where($params)->first();
}
/** @return T|null */
public function findOne($id): ?Model
{
return $this->model::query()->find($id);
}
/** @return T */
public function getOneBy(array $params): Model
{
return $this->model::query()->where($params)->firstOrFail();
}
/** @return array<array-key, T>|Collection<array-key, T> */
public function getMany(array $ids, bool $inThatOrder = false): Collection
{

View file

@ -3,10 +3,12 @@
namespace App\Repositories;
use App\Builders\SongBuilder;
use App\Enums\MediaType;
use App\Facades\License;
use App\Models\Album;
use App\Models\Artist;
use App\Models\Playlist;
use App\Models\Podcast\Podcast;
use App\Models\Song;
use App\Models\User;
use App\Values\Genre;
@ -35,6 +37,7 @@ class SongRepository extends Repository
$scopedUser ??= $this->auth->user();
return Song::query()
->typeOf(MediaType::SONG)
->accessibleBy($scopedUser)
->withMetaFor($scopedUser)
->latest()
@ -48,6 +51,7 @@ class SongRepository extends Repository
$scopedUser ??= $this->auth->user();
return Song::query()
->typeOf(MediaType::SONG)
->accessibleBy($scopedUser)
->withMetaFor($scopedUser, requiresInteractions: true)
->where('interactions.play_count', '>', 0)
@ -70,7 +74,7 @@ class SongRepository extends Repository
}
public function getForListing(
string $sortColumn,
array $sortColumns,
string $sortDirection,
bool $ownSongsOnly = false,
?User $scopedUser = null,
@ -79,16 +83,17 @@ class SongRepository extends Repository
$scopedUser ??= $this->auth->user();
return Song::query()
->typeOf(MediaType::SONG)
->accessibleBy($scopedUser)
->withMetaFor($scopedUser)
->when($ownSongsOnly, static fn (SongBuilder $query) => $query->where('songs.owner_id', $scopedUser->id))
->sort($sortColumn, $sortDirection)
->sort($sortColumns, $sortDirection)
->simplePaginate($perPage);
}
public function getByGenre(
string $genre,
string $sortColumn,
array $sortColumns,
string $sortDirection,
?User $scopedUser = null,
int $perPage = 50
@ -99,13 +104,13 @@ class SongRepository extends Repository
->accessibleBy($scopedUser)
->withMetaFor($scopedUser)
->where('genre', $genre)
->sort($sortColumn, $sortDirection)
->sort($sortColumns, $sortDirection)
->simplePaginate($perPage);
}
/** @return Collection|array<array-key, Song> */
public function getForQueue(
string $sortColumn,
array $sortColumns,
string $sortDirection,
int $limit = self::DEFAULT_QUEUE_LIMIT,
?User $scopedUser = null,
@ -115,7 +120,7 @@ class SongRepository extends Repository
return Song::query()
->accessibleBy($scopedUser)
->withMetaFor($scopedUser)
->sort($sortColumn, $sortDirection)
->sort($sortColumns, $sortDirection)
->limit($limit)
->get();
}
@ -196,6 +201,7 @@ class SongRepository extends Repository
$scopedUser ??= $this->auth->user();
return Song::query()
->typeOf(MediaType::SONG)
->accessibleBy($scopedUser)
->withMetaFor($scopedUser)
->inRandomOrder()
@ -267,14 +273,20 @@ class SongRepository extends Repository
->find($id);
}
public function count(?User $scopedUser = null): int
public function countSongs(?User $scopedUser = null): int
{
return Song::query()->accessibleBy($scopedUser ?? auth()->user())->count();
return Song::query()
->typeOf(MediaType::SONG)
->accessibleBy($scopedUser ?? auth()->user())
->count();
}
public function getTotalLength(?User $scopedUser = null): float
public function getTotalSongLength(?User $scopedUser = null): float
{
return Song::query()->accessibleBy($scopedUser ?? auth()->user())->sum('length');
return Song::query()
->typeOf(MediaType::SONG)
->accessibleBy($scopedUser ?? auth()->user())
->sum('length');
}
/** @return Collection|array<array-key, Song> */
@ -290,4 +302,16 @@ class SongRepository extends Repository
->inRandomOrder()
->get();
}
/** @return array<string> */
public function getEpisodeGuidsByPodcast(Podcast $podcast): array
{
return $podcast->episodes()->pluck('episode_guid')->toArray();
}
/** @return Collection<Song> */
public function getEpisodesByPodcast(Podcast $podcast): Collection
{
return $podcast->episodes;
}
}

View file

@ -4,17 +4,17 @@ namespace App\Rules;
use App\Facades\License;
use App\Models\User;
use Illuminate\Contracts\Validation\Rule;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Support\Arr;
final class AllPlaylistsAreAccessibleBy implements Rule
final class AllPlaylistsAreAccessibleBy implements ValidationRule
{
public function __construct(private readonly User $user)
{
}
/** @param array<int> $value */
public function passes($attribute, $value): bool
public function validate(string $attribute, mixed $value, Closure $fail): void
{
$accessiblePlaylists = $this->user->playlists;
@ -22,13 +22,12 @@ final class AllPlaylistsAreAccessibleBy implements Rule
$accessiblePlaylists = $accessiblePlaylists->merge($this->user->collaboratedPlaylists);
}
return array_diff(Arr::wrap($value), $accessiblePlaylists->pluck('id')->toArray()) === [];
}
public function message(): string
{
return License::isPlus()
? 'Not all playlists are accessible by the user'
: 'Not all playlists belong to the user';
if (array_diff(Arr::wrap($value), $accessiblePlaylists->pluck('id')->toArray())) {
$fail(
License::isPlus()
? 'Not all playlists are accessible by the user'
: 'Not all playlists belong to the user'
);
}
}
}

View file

@ -3,17 +3,15 @@
namespace App\Rules;
use App\Values\UserPreferences;
use Illuminate\Contracts\Validation\Rule;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
class CustomizableUserPreference implements Rule
class CustomizableUserPreference implements ValidationRule
{
public function passes($attribute, $value): bool
public function validate(string $attribute, mixed $value, Closure $fail): void
{
return UserPreferences::customizable($value);
}
public function message(): string
{
return 'Invalid or uncustomizable user preference key.';
if (!UserPreferences::customizable($value)) {
$fail('Invalid or uncustomizable user preference key.');
}
}
}

View file

@ -2,20 +2,20 @@
namespace App\Rules;
use Illuminate\Contracts\Validation\Rule;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Support\Str;
class ImageData implements Rule
class ImageData implements ValidationRule
{
public function passes($attribute, $value): bool
public function validate(string $attribute, mixed $value, Closure $fail): void
{
return attempt(static function () use ($value) {
$passes = attempt(static function () use ($value) {
return (bool) preg_match('/data:image\/(jpe?g|png|webp|gif)/i', Str::before($value, ';'));
}, false) ?? false;
}
public function message(): string
{
return 'Invalid DataURL string';
if (!$passes) {
$fail('Invalid DataURL string');
}
}
}

View file

@ -2,27 +2,26 @@
namespace App\Rules;
use Illuminate\Contracts\Validation\Rule;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Support\Facades\File;
class MediaPath implements Rule
class MediaPath implements ValidationRule
{
public function passes($attribute, $value): bool
public function validate(string $attribute, mixed $value, Closure $fail): void
{
$passes = false;
if (config('koel.storage_driver') === 'local') {
return $value && File::isDirectory($value) && File::isReadable($value);
$passes = $value && File::isDirectory($value) && File::isReadable($value);
}
// Setting a media path is not required for non-local storage drivers.
return false;
}
public function message(): string
{
if (config('koel.storage_driver') === 'local') {
return 'Media path is required for local storage.';
if (!$passes) {
$fail(
config('koel.storage_driver') === 'local'
? 'Media path is required for local storage.'
: 'Media path is not required for non-local storage.'
);
}
return 'Media path is not required for non-local storage.';
}
}

View file

@ -2,20 +2,19 @@
namespace App\Rules;
use Closure;
use getID3;
use Illuminate\Contracts\Validation\Rule;
use Illuminate\Http\UploadedFile;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Support\Arr;
use Webmozart\Assert\Assert;
class SupportedAudioFile implements Rule
class SupportedAudioFile implements ValidationRule
{
private const SUPPORTED_FORMATS = ['mp3', 'aac', 'ogg', 'flac', 'wav'];
/** @param UploadedFile $value */
public function passes($attribute, $value): bool
public function validate(string $attribute, mixed $value, Closure $fail): void
{
return attempt(static function () use ($value) {
$passes = attempt(static function () use ($value) {
Assert::oneOf(
Arr::get((new getID3())->analyze($value->getRealPath()), 'fileformat'),
self::SUPPORTED_FORMATS
@ -23,10 +22,9 @@ class SupportedAudioFile implements Rule
return true;
}, false) ?? false;
}
public function message(): string
{
return 'Unsupported audio file';
if (!$passes) {
$fail('Unsupported audio file');
}
}
}

View file

@ -3,18 +3,18 @@
namespace App\Rules;
use App\Values\SmartPlaylistRuleGroupCollection;
use Illuminate\Contracts\Validation\Rule;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Support\Arr;
class ValidSmartPlaylistRulePayload implements Rule
class ValidSmartPlaylistRulePayload implements ValidationRule
{
public function passes($attribute, $value): bool
public function validate(string $attribute, mixed $value, Closure $fail): void
{
return (bool) attempt(static fn () => SmartPlaylistRuleGroupCollection::create(Arr::wrap($value)));
}
$passes = (bool) attempt(static fn () => SmartPlaylistRuleGroupCollection::create(Arr::wrap($value)));
public function message(): string
{
return 'Invalid smart playlist rules';
if (!$passes) {
$fail('Invalid smart playlist rules');
}
}
}

View file

@ -8,6 +8,7 @@ use App\Models\SongZipArchive;
use App\Services\SongStorages\DropboxStorage;
use App\Services\SongStorages\S3CompatibleStorage;
use App\Services\SongStorages\SftpStorage;
use App\Values\Podcast\EpisodePlayable;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\File;
@ -31,6 +32,16 @@ class DownloadService
return null;
}
if ($song->isEpisode()) {
$playable = EpisodePlayable::retrieveForEpisode($song);
if (!$playable?->valid()) {
$playable = EpisodePlayable::createForEpisode($song);
}
return $playable->path;
}
if ($song->storage === SongStorageType::LOCAL) {
return File::exists($song->path) ? $song->path : null;
}

View file

@ -0,0 +1,213 @@
<?php
namespace App\Services;
use App\Exceptions\FailedToParsePodcastFeedException;
use App\Exceptions\UserAlreadySubscribedToPodcast;
use App\Models\Podcast\Podcast;
use App\Models\Podcast\PodcastUserPivot;
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;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use PhanAn\Poddle\Poddle;
use PhanAn\Poddle\Values\Episode as EpisodeValue;
use PhanAn\Poddle\Values\EpisodeCollection;
use Throwable;
class PodcastService
{
public function __construct(
private readonly PodcastRepository $podcastRepository,
private readonly SongRepository $songRepository
) {
}
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 {
$parser = Poddle::fromUrl($url, 5 * 60);
$channel = $parser->getChannel();
return DB::transaction(function () use ($url, $podcast, $parser, $channel, $user) {
$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
{
$parser = Poddle::fromUrl($podcast->url);
$channel = $parser->getChannel();
$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(),
]);
$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
return $podcast->refresh();
}
$this->synchronizeEpisodes($podcast, $parser->getEpisodes(true));
return $podcast->refresh();
}
private function synchronizeEpisodes(Podcast $podcast, EpisodeCollection $episodeCollection): void
{
$existingEpisodeGuids = $this->songRepository->getEpisodeGuidsByPodcast($podcast);
/** @var EpisodeValue $episodeValue */
foreach ($episodeCollection as $episodeValue) {
if (!in_array($episodeValue->guid->value, $existingEpisodeGuids, true)) {
$podcast->addEpisodeByDTO($episodeValue);
}
}
}
private function subscribeUserToPodcast(User $user, Podcast $podcast): void
{
throw_if($user->subscribedToPodcast($podcast), UserAlreadySubscribedToPodcast::make($user, $podcast));
$podcast->subscribers()->attach($user);
// Refreshing so that $podcast->subscribers are updated
$podcast->refresh();
}
public function updateEpisodeProgress(User $user, Episode $episode, int $position): void
{
/** @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
{
$podcast->subscribers()->detach($user);
}
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;
}
}
/**
* Get a directly streamable (CORS-friendly) from a given URL by following redirects if necessary.
*/
public function getStreamableUrl(string|Episode $url, ?Client $client = null, string $method = 'OPTIONS'): ?string
{
if (!is_string($url)) {
$url = $url->path;
}
$client ??= new Client();
try {
$response = $client->request($method, $url, [
'headers' => [
'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' => '*',
],
'http_errors' => false,
'allow_redirects' => ['track_redirects' => true],
]);
$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;
}
return null;
} catch (Throwable) {
return null;
}
}
}

View file

@ -3,6 +3,7 @@
namespace App\Services;
use App\Models\QueueState;
use App\Models\Song;
use App\Models\User;
use App\Repositories\SongRepository;
use App\Values\QueueState as QueueStateDTO;
@ -39,12 +40,12 @@ class QueueService
]);
}
public function updatePlaybackStatus(User $user, string $songId, int $position): void
public function updatePlaybackStatus(User $user, Song $song, int $position): void
{
QueueState::query()->updateOrCreate([
'user_id' => $user->id,
], [
'current_song_id' => $songId,
'current_song_id' => $song->id,
'playback_position' => $position,
]);
}

View file

@ -5,10 +5,12 @@ namespace App\Services;
use App\Builders\SongBuilder;
use App\Models\Album;
use App\Models\Artist;
use App\Models\Podcast\Podcast;
use App\Models\Song;
use App\Models\User;
use App\Repositories\AlbumRepository;
use App\Repositories\ArtistRepository;
use App\Repositories\PodcastRepository;
use App\Repositories\SongRepository;
use App\Values\ExcerptSearchResult;
use Illuminate\Support\Collection;
@ -21,7 +23,8 @@ class SearchService
public function __construct(
private readonly SongRepository $songRepository,
private readonly AlbumRepository $albumRepository,
private readonly ArtistRepository $artistRepository
private readonly ArtistRepository $artistRepository,
private readonly PodcastRepository $podcastRepository
) {
}
@ -33,13 +36,23 @@ class SearchService
$scopedUser ??= auth()->user();
return ExcerptSearchResult::make(
$this->songRepository->getMany(
songs: $this->songRepository->getMany(
ids: Song::search($keywords)->get()->take($count)->pluck('id')->all(),
inThatOrder: true,
scopedUser: $scopedUser
),
$this->artistRepository->getMany(Artist::search($keywords)->get()->take($count)->pluck('id')->all(), true),
$this->albumRepository->getMany(Album::search($keywords)->get()->take($count)->pluck('id')->all(), true),
artists: $this->artistRepository->getMany(
ids: Artist::search($keywords)->get()->take($count)->pluck('id')->all(),
inThatOrder: true
),
albums: $this->albumRepository->getMany(
ids: Album::search($keywords)->get()->take($count)->pluck('id')->all(),
inThatOrder: true
),
podcasts: $this->podcastRepository->getMany(
ids: Podcast::search($keywords)->get()->take($count)->pluck('id')->all(),
inThatOrder: true
),
);
}

View file

@ -3,6 +3,7 @@
namespace App\Services;
use App\Builders\SongBuilder;
use App\Enums\MediaType;
use App\Exceptions\NonSmartPlaylistException;
use App\Facades\License;
use App\Models\Playlist;
@ -21,6 +22,7 @@ class SmartPlaylistService
throw_unless($playlist->is_smart, NonSmartPlaylistException::create($playlist));
$query = Song::query()
->typeOf(MediaType::SONG)
->withMetaFor($user ?? $playlist->user)
->when(License::isPlus(), static fn (SongBuilder $query) => $query->accessibleBy($user))
->when(

View file

@ -16,7 +16,7 @@ use function DaveRandom\Resume\get_request_header;
trait StreamsLocalPath
{
private function streamLocalPath(string $path): string
private function streamLocalPath(string $path): never
{
try {
$rangeHeader = get_request_header('Range');

View file

@ -0,0 +1,38 @@
<?php
namespace App\Services\Streamer\Adapters;
use App\Models\Song as Episode;
use App\Services\PodcastService;
use App\Services\Streamer\Adapters\Concerns\StreamsLocalPath;
use App\Values\Podcast\EpisodePlayable;
use Illuminate\Http\RedirectResponse;
use Webmozart\Assert\Assert;
class PodcastStreamerAdapter implements StreamerAdapter
{
use StreamsLocalPath;
public function __construct(private readonly PodcastService $podcastService)
{
}
public function stream(Episode $song, array $config = []): RedirectResponse
{
Assert::true($song->isEpisode());
$streamableUrl = $this->podcastService->getStreamableUrl($song);
if ($streamableUrl) {
return response()->redirectTo($streamableUrl);
}
$playable = EpisodePlayable::retrieveForEpisode($song);
if (!$playable?->valid()) {
$playable = EpisodePlayable::createForEpisode($song);
}
$this->streamLocalPath($playable->path);
}
}

View file

@ -7,6 +7,7 @@ use App\Exceptions\KoelPlusRequiredException;
use App\Models\Song;
use App\Services\Streamer\Adapters\DropboxStreamerAdapter;
use App\Services\Streamer\Adapters\LocalStreamerAdapter;
use App\Services\Streamer\Adapters\PodcastStreamerAdapter;
use App\Services\Streamer\Adapters\S3CompatibleStreamerAdapter;
use App\Services\Streamer\Adapters\SftpStreamerAdapter;
use App\Services\Streamer\Adapters\StreamerAdapter;
@ -32,6 +33,10 @@ class Streamer
{
throw_unless($this->song->storage->supported(), KoelPlusRequiredException::class);
if ($this->song->isEpisode()) {
return app(PodcastStreamerAdapter::class);
}
if ($this->shouldTranscode()) {
return app(TranscodingStreamerAdapter::class);
}

View file

@ -6,12 +6,16 @@ use Illuminate\Support\Collection;
final class ExcerptSearchResult
{
private function __construct(public Collection $songs, public Collection $artists, public Collection $albums)
{
private function __construct(
public Collection $songs,
public Collection $artists,
public Collection $albums,
public Collection $podcasts
) {
}
public static function make(Collection $songs, Collection $artists, Collection $albums): self
public static function make(Collection $songs, Collection $artists, Collection $albums, Collection $podcasts): self
{
return new self($songs, $artists, $albums);
return new self($songs, $artists, $albums, $podcasts);
}
}

View file

@ -0,0 +1,63 @@
<?php
namespace App\Values\Podcast;
use App\Models\Song as Episode;
use Illuminate\Contracts\Support\Arrayable;
use Illuminate\Contracts\Support\Jsonable;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Http;
final class EpisodePlayable implements Arrayable, Jsonable
{
private function __construct(public readonly string $path, public readonly string $checksum)
{
}
public static function make(string $path, string $sum): self
{
return new self($path, $sum);
}
public function valid(): bool
{
return File::isReadable($this->path) && $this->checksum === md5_file($this->path);
}
public static function retrieveForEpisode(Episode $episode): ?self
{
return Cache::get("episode-playable.$episode->id");
}
public static function createForEpisode(Episode $episode): self
{
$dir = sys_get_temp_dir() . '/koel-episodes';
$file = sprintf('%s/%s.mp3', $dir, $episode->id);
if (!File::exists($file)) {
File::ensureDirectoryExists($dir);
Http::sink($file)->get($episode->path)->throw();
}
$playable = new self($file, md5_file($file));
Cache::forever("episode-playable.$episode->id", $playable);
return $playable;
}
/** @inheritDoc */
public function toArray(): array
{
return [
'path' => $this->path,
'checksum' => $this->checksum,
];
}
/** @inheritDoc */
public function toJson($options = 0): string
{
return json_encode($this->toArray(), $options);
}
}

View file

@ -0,0 +1,38 @@
<?php
namespace App\Values\Podcast;
use Illuminate\Contracts\Support\Arrayable;
use Illuminate\Contracts\Support\Jsonable;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
final class PodcastState implements Arrayable, Jsonable
{
private function __construct(public readonly ?string $currentEpisode, public readonly Collection $progresses)
{
}
public static function fromArray(array $data): self
{
return new self(
Arr::get($data, 'current_episode'),
new Collection(Arr::get($data, 'progresses', []))
);
}
/** @inheritDoc */
public function toArray(): array
{
return [
'current_episode' => $this->currentEpisode,
'progresses' => $this->progresses->toArray(),
];
}
/** @inheritDoc */
public function toJson($options = 0): string
{
return json_encode($this->toArray(), $options);
}
}

View file

@ -79,7 +79,7 @@ final class SongScanInformation implements Arrayable
{
$keys = Arr::wrap($keys);
for ($i = 0; $i < count($keys); ++$i) {
for ($i = 0, $j = count($keys); $i < $j; ++$i) {
$value = Arr::get($arr, $keys[$i] . '.0');
if ($value) {

View file

@ -41,7 +41,9 @@
"laravel/socialite": "^5.12",
"laravel/ui": "^4.5",
"nunomaduro/collision": "^7.10",
"league/flysystem-sftp-v3": "^3.0"
"league/flysystem-sftp-v3": "^3.0",
"saloonphp/xml-wrangler": "^1.2",
"phanan/poddle": "^1.0"
},
"require-dev": {
"mockery/mockery": "~1.0",

427
composer.lock generated
View file

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "964433231b2e294b86a53fae4e5297c0",
"content-hash": "5ff48b398ac9a12844b16bb4be322ea3",
"packages": [
{
"name": "algolia/algoliasearch-client-php",
@ -229,6 +229,80 @@
},
"time": "2024-04-18T18:12:29+00:00"
},
{
"name": "azjezz/psl",
"version": "2.9.1",
"source": {
"type": "git",
"url": "https://github.com/azjezz/psl.git",
"reference": "1ade4f1a99fe07a8e06f8dee596609aa07585422"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/azjezz/psl/zipball/1ade4f1a99fe07a8e06f8dee596609aa07585422",
"reference": "1ade4f1a99fe07a8e06f8dee596609aa07585422",
"shasum": ""
},
"require": {
"ext-bcmath": "*",
"ext-intl": "*",
"ext-json": "*",
"ext-mbstring": "*",
"ext-sodium": "*",
"php": "~8.1.0 || ~8.2.0 || ~8.3.0",
"revolt/event-loop": "^1.0.1"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^3.22.0",
"php-coveralls/php-coveralls": "^2.6.0",
"php-standard-library/psalm-plugin": "^2.2.1",
"phpbench/phpbench": "^1.2.14",
"phpunit/phpunit": "^9.6.10",
"roave/infection-static-analysis-plugin": "^1.32.0",
"squizlabs/php_codesniffer": "^3.7.2",
"vimeo/psalm": "^5.13.1"
},
"suggest": {
"php-standard-library/psalm-plugin": "Psalm integration"
},
"type": "library",
"extra": {
"thanks": {
"name": "hhvm/hsl",
"url": "https://github.com/hhvm/hsl"
}
},
"autoload": {
"files": [
"src/bootstrap.php"
],
"psr-4": {
"Psl\\": "src/Psl"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "azjezz",
"email": "azjezz@protonmail.com"
}
],
"description": "PHP Standard Library",
"support": {
"issues": "https://github.com/azjezz/psl/issues",
"source": "https://github.com/azjezz/psl/tree/2.9.1"
},
"funding": [
{
"url": "https://github.com/azjezz",
"type": "github"
}
],
"time": "2024-04-05T05:18:37+00:00"
},
{
"name": "brick/math",
"version": "0.11.0",
@ -4576,6 +4650,72 @@
},
"time": "2024-04-05T21:00:10+00:00"
},
{
"name": "phanan/poddle",
"version": "v1.0.3",
"source": {
"type": "git",
"url": "https://github.com/phanan/poddle.git",
"reference": "9c0e62914b8c8ba0c3994e774f128dc32980f667"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/phanan/poddle/zipball/9c0e62914b8c8ba0c3994e774f128dc32980f667",
"reference": "9c0e62914b8c8ba0c3994e774f128dc32980f667",
"shasum": ""
},
"require": {
"guzzlehttp/guzzle": "^7.8",
"illuminate/collections": "^10.48",
"illuminate/http": "^10.48",
"illuminate/support": "^10.48",
"php": ">=8.1",
"saloonphp/xml-wrangler": "^1.2"
},
"require-dev": {
"larastan/larastan": "^2.9",
"laravel/pint": "^1.15",
"laravel/tinker": "^2.9",
"orchestra/testbench": "*",
"phpunit/phpunit": ">=10.5"
},
"type": "library",
"autoload": {
"psr-4": {
"PhanAn\\Poddle\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Phan An",
"email": "me@phanan.net"
}
],
"description": "Parse podcast feeds with PHP following PSP-1 Podcast RSS Standard",
"keywords": [
"feed",
"parser",
"podcast",
"psp-1",
"rss",
"xml"
],
"support": {
"issues": "https://github.com/phanan/poddle/issues",
"source": "https://github.com/phanan/poddle/tree/v1.0.3"
},
"funding": [
{
"url": "https://github.com/phanan",
"type": "github"
}
],
"time": "2024-05-30T07:36:14+00:00"
},
{
"name": "php-http/client-common",
"version": "2.7.1",
@ -5900,6 +6040,78 @@
],
"time": "2023-11-08T05:53:05+00:00"
},
{
"name": "revolt/event-loop",
"version": "v1.0.6",
"source": {
"type": "git",
"url": "https://github.com/revoltphp/event-loop.git",
"reference": "25de49af7223ba039f64da4ae9a28ec2d10d0254"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/revoltphp/event-loop/zipball/25de49af7223ba039f64da4ae9a28ec2d10d0254",
"reference": "25de49af7223ba039f64da4ae9a28ec2d10d0254",
"shasum": ""
},
"require": {
"php": ">=8.1"
},
"require-dev": {
"ext-json": "*",
"jetbrains/phpstorm-stubs": "^2019.3",
"phpunit/phpunit": "^9",
"psalm/phar": "^5.15"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-main": "1.x-dev"
}
},
"autoload": {
"psr-4": {
"Revolt\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Aaron Piotrowski",
"email": "aaron@trowski.com"
},
{
"name": "Cees-Jan Kiewiet",
"email": "ceesjank@gmail.com"
},
{
"name": "Christian Lück",
"email": "christian@clue.engineering"
},
{
"name": "Niklas Keller",
"email": "me@kelunik.com"
}
],
"description": "Rock-solid event loop for concurrent PHP applications.",
"keywords": [
"async",
"asynchronous",
"concurrency",
"event",
"event-loop",
"non-blocking",
"scheduler"
],
"support": {
"issues": "https://github.com/revoltphp/event-loop/issues",
"source": "https://github.com/revoltphp/event-loop/tree/v1.0.6"
},
"time": "2023-11-30T05:34:44+00:00"
},
{
"name": "saloonphp/laravel-plugin",
"version": "v3.5.0",
@ -6049,6 +6261,142 @@
],
"time": "2024-04-08T11:53:06+00:00"
},
{
"name": "saloonphp/xml-wrangler",
"version": "v1.2.1",
"source": {
"type": "git",
"url": "https://github.com/saloonphp/xml-wrangler.git",
"reference": "87d0c57687ca0abc2c1195cc76c866b18d634561"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/saloonphp/xml-wrangler/zipball/87d0c57687ca0abc2c1195cc76c866b18d634561",
"reference": "87d0c57687ca0abc2c1195cc76c866b18d634561",
"shasum": ""
},
"require": {
"ext-dom": "*",
"php": "^8.1",
"spatie/array-to-xml": "^3.2",
"veewee/xml": "^2.11.2"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^3.5",
"guzzlehttp/guzzle": "^7.8",
"illuminate/collections": "^10.30",
"pestphp/pest": "^2.24",
"phpstan/phpstan": "^1.9",
"psr/http-message": "^2.0",
"saloonphp/saloon": "^3.0",
"spatie/ray": "^1.33"
},
"suggest": {
"illuminate/collections": "Used for the collect and lazyCollect methods when reading XML."
},
"type": "library",
"autoload": {
"psr-4": {
"Saloon\\XmlWrangler\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Sam Carré",
"email": "29132017+Sammyjo20@users.noreply.github.com",
"role": "Developer"
}
],
"description": "Easily Read & Write XML in PHP",
"homepage": "https://github.com/saloonphp/xml-wrangler",
"support": {
"issues": "https://github.com/saloonphp/xml-wrangler/issues",
"source": "https://github.com/saloonphp/xml-wrangler/tree/v1.2.1"
},
"funding": [
{
"url": "https://github.com/sammyjo20",
"type": "github"
},
{
"url": "https://ko-fi.com/sammyjo20",
"type": "ko_fi"
}
],
"time": "2023-12-05T13:09:03+00:00"
},
{
"name": "spatie/array-to-xml",
"version": "3.3.0",
"source": {
"type": "git",
"url": "https://github.com/spatie/array-to-xml.git",
"reference": "f56b220fe2db1ade4c88098d83413ebdfc3bf876"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/spatie/array-to-xml/zipball/f56b220fe2db1ade4c88098d83413ebdfc3bf876",
"reference": "f56b220fe2db1ade4c88098d83413ebdfc3bf876",
"shasum": ""
},
"require": {
"ext-dom": "*",
"php": "^8.0"
},
"require-dev": {
"mockery/mockery": "^1.2",
"pestphp/pest": "^1.21",
"spatie/pest-plugin-snapshots": "^1.1"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-main": "3.x-dev"
}
},
"autoload": {
"psr-4": {
"Spatie\\ArrayToXml\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Freek Van der Herten",
"email": "freek@spatie.be",
"homepage": "https://freek.dev",
"role": "Developer"
}
],
"description": "Convert an array to xml",
"homepage": "https://github.com/spatie/array-to-xml",
"keywords": [
"array",
"convert",
"xml"
],
"support": {
"source": "https://github.com/spatie/array-to-xml/tree/3.3.0"
},
"funding": [
{
"url": "https://spatie.be/open-source/support-us",
"type": "custom"
},
{
"url": "https://github.com/spatie",
"type": "github"
}
],
"time": "2024-05-01T10:20:27+00:00"
},
{
"name": "spatie/dropbox-api",
"version": "1.22.0",
@ -8696,6 +9044,83 @@
},
"time": "2023-12-08T13:03:43+00:00"
},
{
"name": "veewee/xml",
"version": "2.14.0",
"source": {
"type": "git",
"url": "https://github.com/veewee/xml.git",
"reference": "143c5655c3af11b187157af16340ee69a244e633"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/veewee/xml/zipball/143c5655c3af11b187157af16340ee69a244e633",
"reference": "143c5655c3af11b187157af16340ee69a244e633",
"shasum": ""
},
"require": {
"azjezz/psl": "^2.0.3",
"ext-dom": "*",
"ext-libxml": "*",
"ext-xml": "*",
"ext-xmlreader": "*",
"ext-xmlwriter": "*",
"ext-xsl": "*",
"php": "~8.1.0 || ~8.2.0 || ~8.3.0",
"webmozart/assert": "^1.10"
},
"require-dev": {
"php-standard-library/psalm-plugin": "^2.2",
"symfony/finder": "^6.1",
"veewee/composer-run-parallel": "^1.0.0",
"vimeo/psalm": "^5.4"
},
"type": "library",
"autoload": {
"files": [
"src/bootstrap.php"
],
"psr-4": {
"VeeWee\\Xml\\": "src/Xml"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Toon Verwerft",
"email": "toonverwerft@gmail.com"
}
],
"description": "XML without worries",
"keywords": [
"Xpath",
"array-to-xml",
"dom",
"dom-manipulation",
"reader",
"writer",
"xml",
"xml-to-array",
"xml_decode",
"xml_encode",
"xsd",
"xslt"
],
"support": {
"issues": "https://github.com/veewee/xml/issues",
"source": "https://github.com/veewee/xml/tree/2.14.0"
},
"funding": [
{
"url": "https://github.com/veewee",
"type": "github"
}
],
"time": "2024-01-14T12:13:42+00:00"
},
{
"name": "vlucas/phpdotenv",
"version": "v5.6.0",

View file

@ -6,7 +6,7 @@ return [
| Default Queue Driver
|--------------------------------------------------------------------------
|
| The Laravel queue API supports a variety of back-ends via an unified
| The Laravel queue API supports a variety of back-ends via a unified
| API, giving you convenient access to each back-end using the same
| syntax for each one. Here you may set the default queue driver.
|

View file

@ -0,0 +1,57 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('podcast', static function (Blueprint $table): void {
$table->string('id', 36)->primary();
$table->string('url')->unique()->comment('The URL to the podcast feed')->unique();
$table->string('link')->comment('The link to the podcast website');
$table->text('title');
$table->text('image');
$table->string('author')->nullable();
$table->text('description');
$table->json('categories');
$table->boolean('explicit');
$table->string('language');
$table->json('metadata');
$table->unsignedInteger('added_by')->nullable();
$table->timestamp('last_synced_at');
$table->timestamps();
});
Schema::table('podcast', static function (Blueprint $table): void {
$table->foreign('added_by')->references('id')->on('users')->nullOnDelete();
});
Schema::table('songs', static function (Blueprint $table): void {
$table->unsignedInteger('artist_id')->nullable()->change();
$table->unsignedInteger('album_id')->nullable()->change();
$table->unsignedInteger('owner_id')->nullable()->change();
$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('podcast')->cascadeOnDelete();
});
Schema::create('podcast_user', static function (Blueprint $table): void {
$table->id();
$table->unsignedInteger('user_id');
$table->string('podcast_id', 36);
$table->json('state')->nullable();
$table->timestamps();
});
Schema::table('podcast_user', static function (Blueprint $table): void {
$table->foreign('user_id')->references('id')->on('users')->cascadeOnDelete();
$table->foreign('podcast_id')->references('id')->on('podcast')->cascadeOnDelete();
});
}
};

View file

@ -21,6 +21,8 @@
"@fortawesome/vue-fontawesome": "^3.0.1",
"axios": "^0.21.1",
"compare-versions": "^3.5.1",
"dompurify": "^3.1.4",
"fuse.js": "^7.0.0",
"ismobilejs": "^0.4.0",
"local-storage": "^2.0.0",
"lodash": "^4.17.19",
@ -42,6 +44,7 @@
"@testing-library/user-event": "^14.4.3",
"@testing-library/vue": "^6.6.1",
"@types/axios": "^0.14.0",
"@types/dompurify": "^3.0.5",
"@types/local-storage": "^1.4.0",
"@types/lodash": "^4.14.150",
"@types/nprogress": "^0.2.0",

View file

@ -39,7 +39,7 @@ import { defineAsyncComponent, onMounted, provide, ref, watch } from 'vue'
import { useOnline } from '@vueuse/core'
import { queueStore } from '@/stores'
import { authService } from '@/services'
import { CurrentSongKey, DialogBoxKey, MessageToasterKey, OverlayKey } from '@/symbols'
import { CurrentPlayableKey, DialogBoxKey, MessageToasterKey, OverlayKey } from '@/symbols'
import { useRouter } from '@/composables'
import DialogBox from '@/components/ui/DialogBox.vue'
@ -62,7 +62,7 @@ const AlbumContextMenu = defineAsyncComponent(() => import('@/components/album/A
const ArtistContextMenu = defineAsyncComponent(() => import('@/components/artist/ArtistContextMenu.vue'))
const PlaylistContextMenu = defineAsyncComponent(() => import('@/components/playlist/PlaylistContextMenu.vue'))
const PlaylistFolderContextMenu = defineAsyncComponent(() => import('@/components/playlist/PlaylistFolderContextMenu.vue'))
const SongContextMenu = defineAsyncComponent(() => import('@/components/song/SongContextMenu.vue'))
const SongContextMenu = defineAsyncComponent(() => import('@/components/song/PlayableContextMenu.vue'))
const CreateNewPlaylistContextMenu = defineAsyncComponent(() => import('@/components/playlist/CreatePlaylistContextMenu.vue'))
const SupportKoel = defineAsyncComponent(() => import('@/components/meta/SupportKoel.vue'))
const DropZone = defineAsyncComponent(() => import('@/components/ui/upload/DropZone.vue'))
@ -72,7 +72,7 @@ const ResetPasswordForm = defineAsyncComponent(() => import('@/components/auth/R
const overlay = ref<InstanceType<typeof Overlay>>()
const dialog = ref<InstanceType<typeof DialogBox>>()
const toaster = ref<InstanceType<typeof MessageToaster>>()
const currentSong = ref<Song>()
const currentSong = ref<Playable>()
const showDropZone = ref(false)
const layout = ref<'main' | 'auth' | 'invitation' | 'reset-password'>()
@ -156,7 +156,7 @@ const onDrop = () => (showDropZone.value = false)
provide(OverlayKey, overlay)
provide(DialogBoxKey, dialog)
provide(MessageToasterKey, toaster)
provide(CurrentSongKey, currentSong)
provide(CurrentPlayableKey, currentSong)
</script>
<style lang="postcss">

View file

@ -1,5 +1,5 @@
import { createApp } from 'vue'
import { focus, hideBrokenIcon, overflowFade, tooltip } from '@/directives'
import { focus, hideBrokenIcon, overflowFade, newTab, tooltip } from '@/directives'
import { FontAwesomeIcon, FontAwesomeLayers } from '@fortawesome/vue-fontawesome'
import { RouterKey } from '@/symbols'
import { routes } from '@/config'
@ -15,6 +15,7 @@ createApp(App)
.directive('koel-tooltip', tooltip)
.directive('koel-hide-broken-icon', hideBrokenIcon)
.directive('koel-overflow-fade', overflowFade)
.directive('koel-new-tab', newTab)
/**
* For Ancelot, the ancient cross of war
* for the holy town of Gods

View file

@ -18,7 +18,7 @@
<script lang="ts" setup>
import { onMounted, provide, ref, toRefs } from 'vue'
import { songStore } from '@/stores'
import { SongsKey } from '@/symbols'
import { PlayablesKey } from '@/symbols'
import TrackListItem from '@/components/album/AlbumTrackListItem.vue'
@ -28,7 +28,7 @@ const { album, tracks } = toRefs(props)
const songs = ref<Song[]>([])
// @ts-ignore
provide(SongsKey, songs)
provide(PlayablesKey, songs)
onMounted(async () => songs.value = await songStore.fetchForAlbum(album.value))
</script>

View file

@ -4,7 +4,7 @@ import factory from '@/__tests__/factory'
import { queueStore, songStore } from '@/stores'
import { playbackService } from '@/services'
import UnitTestCase from '@/__tests__/UnitTestCase'
import { SongsKey } from '@/symbols'
import { PlayablesKey } from '@/symbols'
import { ref } from 'vue'
import AlbumTrackListItem from './AlbumTrackListItem.vue'
@ -44,7 +44,7 @@ new class extends UnitTestCase {
},
global: {
provide: {
[<symbol>SongsKey]: ref(songsToMatchAgainst)
[<symbol>PlayablesKey]: ref(songsToMatchAgainst)
}
}
})

View file

@ -18,7 +18,7 @@ import { queueStore, songStore } from '@/stores'
import { authService, playbackService } from '@/services'
import { useThirdPartyServices } from '@/composables'
import { requireInjection, secondsToHis } from '@/utils'
import { SongsKey } from '@/symbols'
import { PlayablesKey } from '@/symbols'
const AppleMusicButton = defineAsyncComponent(() => import('@/components/ui/AppleMusicButton.vue'))
@ -27,7 +27,7 @@ const { album, track } = toRefs(props)
const { useAppleMusic } = useThirdPartyServices()
const songsToMatchAgainst = requireInjection<Ref<Song[]>>(SongsKey)
const songsToMatchAgainst = requireInjection<Ref<Song[]>>(PlayablesKey)
const matchedSong = computed(() => songStore.match(track.value.title, songsToMatchAgainst.value))
const tooltip = computed(() => matchedSong.value ? 'Click to play' : '')
@ -39,12 +39,7 @@ const iTunesUrl = computed(() => {
return `${window.BASE_URL}itunes/song/${album.value.id}?q=${encodeURIComponent(track.value.title)}&api_token=${authService.getApiToken()}`
})
const play = () => {
if (matchedSong.value) {
queueStore.queueIfNotQueued(matchedSong.value)
playbackService.play(matchedSong.value)
}
}
const play = () => matchedSong.value && playbackService.play(matchedSong.value)
</script>
<style lang="postcss" scoped>

View file

@ -25,6 +25,7 @@ const modalNameToComponentMap = {
'create-playlist-folder-form': defineAsyncComponent(() => import('@/components/playlist/CreatePlaylistFolderForm.vue')),
'edit-playlist-folder-form': defineAsyncComponent(() => import('@/components/playlist/EditPlaylistFolderForm.vue')),
'playlist-collaboration': defineAsyncComponent(() => import('@/components/playlist/PlaylistCollaborationModal.vue')),
'add-podcast-form': defineAsyncComponent(() => import('@/components/podcast/AddPodcastForm.vue')),
'about-koel': defineAsyncComponent(() => import('@/components/meta/AboutKoelModal.vue')),
'koel-plus': defineAsyncComponent(() => import('@/components/koel-plus/KoelPlusModal.vue')),
'equalizer': defineAsyncComponent(() => import('@/components/ui/equalizer/Equalizer.vue'))
@ -86,6 +87,9 @@ eventBus.on('MODAL_SHOW_ABOUT_KOEL', () => (activeModalName.value = 'about-koel'
context.value = { playlist }
activeModalName.value = 'playlist-collaboration'
})
.on('MODAL_SHOW_ADD_PODCAST_FORM', () => {
activeModalName.value = 'add-podcast-form'
})
.on('MODAL_SHOW_EQUALIZER', () => (activeModalName.value = 'equalizer'))
</script>

View file

@ -1,13 +1,15 @@
<template>
<div class="extra-controls flex justify-end relative md:w-[320px] px-6 md:px-8 py-0">
<div class="extra-controls flex justify-end relative md:w-[420px] px-6 md:px-8 py-0">
<div class="flex justify-end items-center gap-6">
<FooterQueueIcon />
<FooterBtn
class="visualizer-btn hidden md:!block"
data-testid="toggle-visualizer-btn"
title="Toggle visualizer"
@click.prevent="toggleVisualizer"
>
<Icon :icon="faBolt" />
<Icon :icon="faBolt" fixed-width />
</FooterBtn>
<FooterBtn
@ -17,26 +19,27 @@
title="Show equalizer"
@click.prevent="showEqualizer"
>
<Icon :icon="faSliders" />
<Icon :icon="faSliders" fixed-width />
</FooterBtn>
<VolumeSlider />
<FooterBtn v-if="isFullscreenSupported()" :title="fullscreenButtonTitle" @click.prevent="toggleFullscreen">
<Icon :icon="isFullscreen ? faCompress : faExpand" />
<Icon :icon="isFullscreen ? faCompress : faExpand" fixed-width />
</FooterBtn>
</div>
</div>
</template>
<script lang="ts" setup>
import { faBolt, faCompress, faExpand, faSliders } from '@fortawesome/free-solid-svg-icons'
import { faListOl, faBolt, faCompress, faExpand, faSliders } from '@fortawesome/free-solid-svg-icons'
import { computed, onMounted, ref } from 'vue'
import { eventBus, isAudioContextSupported as useEqualizer, isFullscreenSupported } from '@/utils'
import { useRouter } from '@/composables'
import VolumeSlider from '@/components/ui/VolumeSlider.vue'
import FooterBtn from '@/components/layout/app-footer/FooterButton.vue'
import FooterQueueIcon from '@/components/layout/app-footer/FooterQueueButton.vue'
const isFullscreen = ref(false)
const fullscreenButtonTitle = computed(() => (isFullscreen.value ? 'Exit fullscreen mode' : 'Enter fullscreen mode'))
@ -45,7 +48,6 @@ const { go, isCurrentScreen } = useRouter()
const showEqualizer = () => eventBus.emit('MODAL_SHOW_EQUALIZER')
const toggleFullscreen = () => eventBus.emit('FULLSCREEN_TOGGLE')
const toggleVisualizer = () => go(isCurrentScreen('Visualizer') ? -1 : 'visualizer')
onMounted(() => {

View file

@ -2,7 +2,7 @@ import { ref } from 'vue'
import { expect, it } from 'vitest'
import factory from '@/__tests__/factory'
import UnitTestCase from '@/__tests__/UnitTestCase'
import { CurrentSongKey } from '@/symbols'
import { CurrentPlayableKey } from '@/symbols'
import { playbackService } from '@/services'
import { screen } from '@testing-library/vue'
import FooterPlaybackControls from './FooterPlaybackControls.vue'
@ -50,7 +50,7 @@ new class extends UnitTestCase {
PlayButton: this.stub('PlayButton')
},
provide: {
[<symbol>CurrentSongKey]: ref(song)
[<symbol>CurrentPlayableKey]: ref(song)
}
}
})

View file

@ -24,14 +24,14 @@ import { faStepBackward, faStepForward } from '@fortawesome/free-solid-svg-icons
import { ref } from 'vue'
import { playbackService } from '@/services'
import { requireInjection } from '@/utils'
import { CurrentSongKey } from '@/symbols'
import { CurrentPlayableKey } from '@/symbols'
import RepeatModeSwitch from '@/components/ui/RepeatModeSwitch.vue'
import LikeButton from '@/components/song/SongLikeButton.vue'
import PlayButton from '@/components/ui/FooterPlayButton.vue'
import FooterBtn from '@/components/layout/app-footer/FooterButton.vue'
const song = requireInjection(CurrentSongKey, ref())
const song = requireInjection(CurrentPlayableKey, ref())
const playPrev = async () => await playbackService.playPrev()
const playNext = async () => await playbackService.playNext()

View file

@ -0,0 +1,66 @@
<template>
<FooterButton
:class="droppable && 'droppable'"
class="queue-btn"
title="Queue (Q)"
@click.prevent="showQueue"
@dragleave.prevent="onDragLeave"
@drop.prevent="onDrop"
@dragenter.prevent="onDragEnter"
@dragover.prevent
>
<Icon :icon="faListOl" fixed-width />
</FooterButton>
</template>
<script setup lang="ts">
import { faListOl } from '@fortawesome/free-solid-svg-icons'
import { ref } from 'vue'
import { useDroppable, useMessageToaster, useRouter } from '@/composables'
import { queueStore } from '@/stores'
import { pluralize } from '@/utils'
import FooterButton from '@/components/layout/app-footer/FooterButton.vue'
const { go, isCurrentScreen } = useRouter()
const { toastWarning, toastSuccess } = useMessageToaster()
const { acceptsDrop, resolveDroppedItems } = useDroppable(
['playables', 'album', 'artist', 'playlist', 'playlist-folder']
)
const droppable = ref(false)
const onDragEnter = (event: DragEvent) => droppable.value = acceptsDrop(event)
const onDragLeave = (e: DragEvent) => {
if ((e.currentTarget as Node)?.contains?.(e.relatedTarget as Node)) {
return
}
droppable.value = false
}
const onDrop = async (event: DragEvent) => {
droppable.value = false
event.preventDefault()
const items = await resolveDroppedItems(event) || []
if (items.length) {
queueStore.queue(items)
toastSuccess(`Added ${pluralize(items, 'item')} to queue.`)
} else {
toastWarning('No applicable items to queue.')
}
return false
}
const showQueue = () => go(isCurrentScreen('Queue') ? -1 : 'queue')
</script>
<style lang="postcss" scoped>
.droppable {
@apply text-k-highlight scale-125;
}
</style>

View file

@ -2,7 +2,7 @@ import { expect, it } from 'vitest'
import { ref } from 'vue'
import factory from '@/__tests__/factory'
import UnitTestCase from '@/__tests__/UnitTestCase'
import { CurrentSongKey } from '@/symbols'
import { CurrentPlayableKey } from '@/symbols'
import FooterSongInfo from './FooterSongInfo.vue'
new class extends UnitTestCase {
@ -21,7 +21,7 @@ new class extends UnitTestCase {
expect(this.render(FooterSongInfo, {
global: {
provide: {
[<symbol>CurrentSongKey]: ref(song)
[<symbol>CurrentPlayableKey]: ref(song)
}
}
}).html()).toMatchSnapshot()

View file

@ -2,17 +2,17 @@
<div
:class="{ playing: song?.playback_state === 'Playing' }"
:draggable="draggable"
class="song-info px-6 py-0 flex items-center content-start w-[84px] md:w-80 gap-5"
class="song-info px-6 py-0 flex items-center content-start w-[84px] md:w-[420px] gap-5"
@dragstart="onDragStart"
>
<span class="album-thumb block h-[55%] md:h-3/4 aspect-square rounded-full bg-cover" />
<div v-if="song" class="meta overflow-hidden hidden md:block">
<h3 class="title text-ellipsis overflow-hidden whitespace-nowrap">{{ song.title }}</h3>
<a
:href="`/#/artist/${song.artist_id}`"
:href="artistOrPodcastUri"
class="artist text-ellipsis overflow-hidden whitespace-nowrap block text-[0.9rem] !text-k-text-secondary hover:!text-k-accent"
>
{{ song.artist_name }}
{{ artistOrPodcastName }}
</a>
</div>
</div>
@ -20,15 +20,29 @@
<script lang="ts" setup>
import { computed, ref } from 'vue'
import { defaultCover, requireInjection } from '@/utils'
import { CurrentSongKey } from '@/symbols'
import { defaultCover, getPlayableProp, isSong, requireInjection } from '@/utils'
import { CurrentPlayableKey } from '@/symbols'
import { useDraggable } from '@/composables'
const { startDragging } = useDraggable('songs')
const { startDragging } = useDraggable('playables')
const song = requireInjection(CurrentSongKey, ref())
const song = requireInjection(CurrentPlayableKey, ref())
const cover = computed(() => {
if (!song.value) return defaultCover
return getPlayableProp(song.value, 'album_cover', 'episode_image')
})
const artistOrPodcastUri = computed(() => {
if (!song.value) return ''
return isSong(song.value) ? `#/artist/${song.value?.artist_id}` : `#/podcasts/${song.value.podcast_id}`
})
const artistOrPodcastName = computed(() => {
if (!song.value) return ''
return getPlayableProp(song.value, 'artist_name', 'podcast_title')
})
const cover = computed(() => song.value?.album_cover || defaultCover)
const coverBackgroundImage = computed(() => `url(${cover.value})`)
const draggable = computed(() => Boolean(song.value))

View file

@ -5,7 +5,7 @@
@mousemove="showControls"
@contextmenu.prevent="requestContextMenu"
>
<AudioPlayer v-show="song" />
<AudioPlayer v-show="playable" />
<div class="fullscreen-backdrop hidden" />
@ -20,18 +20,18 @@
<script lang="ts" setup>
import { throttle } from 'lodash'
import { computed, nextTick, ref, watch } from 'vue'
import { eventBus, isAudioContextSupported, requireInjection } from '@/utils'
import { CurrentSongKey } from '@/symbols'
import { eventBus, isAudioContextSupported, isSong, requireInjection } from '@/utils'
import { CurrentPlayableKey } from '@/symbols'
import { artistStore, preferenceStore } from '@/stores'
import { audioService, playbackService } from '@/services'
import { useFullscreen } from '@vueuse/core'
import AudioPlayer from '@/components/layout/app-footer/AudioPlayer.vue'
import SongInfo from '@/components/layout/app-footer/FooterSongInfo.vue'
import ExtraControls from '@/components/layout/app-footer/FooterExtraControls.vue'
import PlaybackControls from '@/components/layout/app-footer/FooterPlaybackControls.vue'
import { useFullscreen } from '@vueuse/core'
const song = requireInjection(CurrentSongKey, ref())
const playable = requireInjection(CurrentPlayableKey, ref())
let hideControlsTimeout: number
const root = ref<HTMLElement>()
@ -39,16 +39,21 @@ const artist = ref<Artist>()
const requestContextMenu = (event: MouseEvent) => {
if (document.fullscreenElement) return
song.value && eventBus.emit('SONG_CONTEXT_MENU_REQUESTED', event, song.value)
playable.value && eventBus.emit('PLAYABLE_CONTEXT_MENU_REQUESTED', event, playable.value)
}
watch(song, async () => {
if (!song.value) return
artist.value = await artistStore.resolve(song.value.artist_id)
watch(playable, async () => {
if (!playable.value) return
if (isSong(playable.value)) {
artist.value = await artistStore.resolve(playable.value.artist_id)
}
})
const backgroundBackgroundImage = computed(() => {
const src = artist.value?.image ?? song.value?.album_cover
const appBackgroundImage = computed(() => {
if (!playable.value || !isSong(playable.value)) return 'none'
const src = artist.value?.image ?? playable.value.album_cover
return src ? `url(${src})` : 'none'
})
@ -86,7 +91,7 @@ const { isFullscreen, toggle: toggleFullscreen } = useFullscreen(root)
watch(isFullscreen, fullscreen => {
if (fullscreen) {
// setupControlHidingTimer()
setupControlHidingTimer()
root.value?.classList.remove('hide-controls')
} else {
window.clearTimeout(hideControlsTimeout)
@ -101,7 +106,7 @@ footer {
box-shadow: 0 0 30px 20px rgba(0, 0, 0, .2);
.fullscreen-backdrop {
background-image: v-bind(backgroundBackgroundImage);
background-image: v-bind(appBackgroundImage);
}
&:fullscreen {

View file

@ -4,7 +4,7 @@ import { expect, it } from 'vitest'
import factory from '@/__tests__/factory'
import { albumStore, preferenceStore } from '@/stores'
import UnitTestCase from '@/__tests__/UnitTestCase'
import { CurrentSongKey } from '@/symbols'
import { CurrentPlayableKey } from '@/symbols'
import AlbumArtOverlay from '@/components/ui/AlbumArtOverlay.vue'
import MainContent from './MainContent.vue'
@ -31,7 +31,7 @@ new class extends UnitTestCase {
return this.render(MainContent, {
global: {
provide: {
[<symbol>CurrentSongKey]: ref(factory<Song>('song'))
[<symbol>CurrentPlayableKey]: ref(factory<Song>('song'))
},
stubs: {
AlbumArtOverlay,

View file

@ -9,7 +9,7 @@
For those that don't need to maintain their own UI state, we use v-if and enjoy some code-splitting juice.
-->
<VisualizerScreen v-if="screen === 'Visualizer'" />
<AlbumArtOverlay v-if="showAlbumArtOverlay && currentSong" :album="currentSong?.album_id" />
<AlbumArtOverlay v-if="showAlbumArtOverlay && currentSong && isSong(currentSong)" :album="currentSong?.album_id" />
<HomeScreen v-show="screen === 'Home'" />
<QueueScreen v-show="screen === 'Queue'" />
@ -22,6 +22,7 @@
<UploadScreen v-show="screen === 'Upload'" />
<SearchExcerptsScreen v-show="screen === 'Search.Excerpt'" />
<GenreScreen v-show="screen === 'Genre'" />
<PodcastListScreen v-show="screen === 'Podcasts'" />
<GenreListScreen v-if="screen === 'Genres'" />
<SearchSongResultsScreen v-if="screen === 'Search.Songs'" />
@ -29,18 +30,20 @@
<ArtistScreen v-if="screen === 'Artist'" />
<SettingsScreen v-if="screen === 'Settings'" />
<ProfileScreen v-if="screen === 'Profile'" />
<PodcastScreen v-if="screen ==='Podcast'" />
<EpisodeScreen v-if="screen === 'Episode'" />
<UserListScreen v-if="screen === 'Users'" />
<YoutubeScreen v-if="useYouTube" v-show="screen === 'YouTube'" />
<YouTubeScreen v-if="useYouTube" v-show="screen === 'YouTube'" />
<NotFoundScreen v-if="screen === '404'" />
</section>
</template>
<script lang="ts" setup>
import { defineAsyncComponent, onMounted, ref, toRef } from 'vue'
import { requireInjection } from '@/utils'
import { isSong, requireInjection } from '@/utils'
import { preferenceStore } from '@/stores'
import { useRouter, useThirdPartyServices } from '@/composables'
import { CurrentSongKey } from '@/symbols'
import { CurrentPlayableKey } from '@/symbols'
import HomeScreen from '@/components/screens/HomeScreen.vue'
import QueueScreen from '@/components/screens/QueueScreen.vue'
@ -53,15 +56,18 @@ import FavoritesScreen from '@/components/screens/FavoritesScreen.vue'
import RecentlyPlayedScreen from '@/components/screens/RecentlyPlayedScreen.vue'
import UploadScreen from '@/components/screens/UploadScreen.vue'
import SearchExcerptsScreen from '@/components/screens/search/SearchExcerptsScreen.vue'
import PodcastListScreen from '@/components/screens/PodcastListScreen.vue'
const UserListScreen = defineAsyncComponent(() => import('@/components/screens/UserListScreen.vue'))
const AlbumArtOverlay = defineAsyncComponent(() => import('@/components/ui/AlbumArtOverlay.vue'))
const AlbumScreen = defineAsyncComponent(() => import('@/components/screens/AlbumScreen.vue'))
const ArtistScreen = defineAsyncComponent(() => import('@/components/screens/ArtistScreen.vue'))
const GenreScreen = defineAsyncComponent(() => import('@/components/screens/GenreScreen.vue'))
const PodcastScreen = defineAsyncComponent(() => import('@/components/screens/PodcastScreen.vue'))
const EpisodeScreen = defineAsyncComponent(() => import('@/components/screens/EpisodeScreen.vue'))
const SettingsScreen = defineAsyncComponent(() => import('@/components/screens/SettingsScreen.vue'))
const ProfileScreen = defineAsyncComponent(() => import('@/components/screens/ProfileScreen.vue'))
const YoutubeScreen = defineAsyncComponent(() => import('@/components/screens/YouTubeScreen.vue'))
const YouTubeScreen = defineAsyncComponent(() => import('@/components/screens/YouTubeScreen.vue'))
const SearchSongResultsScreen = defineAsyncComponent(() => import('@/components/screens/search/SearchSongResultsScreen.vue'))
const NotFoundScreen = defineAsyncComponent(() => import('@/components/screens/NotFoundScreen.vue'))
const VisualizerScreen = defineAsyncComponent(() => import('@/components/screens/VisualizerScreen.vue'))
@ -69,7 +75,7 @@ const VisualizerScreen = defineAsyncComponent(() => import('@/components/screens
const { useYouTube } = useThirdPartyServices()
const { onRouteChanged, getCurrentScreen } = useRouter()
const currentSong = requireInjection(CurrentSongKey, ref(undefined))
const currentSong = requireInjection(CurrentPlayableKey, ref(undefined))
const showAlbumArtOverlay = toRef(preferenceStore.state, 'show_album_art_overlay')
const screen = ref<ScreenName>('Home')

View file

@ -4,7 +4,7 @@ import { RenderResult, screen, waitFor } from '@testing-library/vue'
import factory from '@/__tests__/factory'
import { albumStore, artistStore, commonStore, preferenceStore } from '@/stores'
import UnitTestCase from '@/__tests__/UnitTestCase'
import { CurrentSongKey } from '@/symbols'
import { CurrentPlayableKey } from '@/symbols'
import { eventBus } from '@/utils'
import ExtraDrawer from './ExtraDrawer.vue'
@ -83,7 +83,7 @@ new class extends UnitTestCase {
ExtraPanelTabHeader: this.stub()
},
provide: {
[<symbol>CurrentSongKey]: songRef
[<symbol>CurrentPlayableKey]: songRef
}
}
})

View file

@ -11,7 +11,7 @@
>
<div class="btn-group">
<SidebarMenuToggleButton />
<ExtraDrawerTabHeader v-if="song" v-model="activeTab" />
<ExtraDrawerTabHeader v-if="songPlaying" v-model="activeTab" />
</div>
<div class="btn-group">
@ -21,7 +21,7 @@
</div>
</div>
<div v-if="song" v-show="activeTab" class="panes py-8 px-6 overflow-auto bg-k-bg-secondary">
<div v-if="songPlaying" v-show="activeTab" class="panes py-8 px-6 overflow-auto bg-k-bg-secondary">
<div
v-show="activeTab === 'Lyrics'"
id="extraPanelLyrics"
@ -29,7 +29,7 @@
role="tabpanel"
tabindex="0"
>
<LyricsPane :song="song" />
<LyricsPane :song="playable" />
</div>
<div
@ -62,7 +62,7 @@
role="tabpanel"
tabindex="0"
>
<YouTubeVideoList v-if="useYouTube && song" :song="song" />
<YouTubeVideoList v-if="shouldShowYouTubeTab" :song="playable as Song" />
</div>
</div>
</aside>
@ -70,11 +70,11 @@
<script lang="ts" setup>
import isMobile from 'ismobilejs'
import { defineAsyncComponent, onMounted, ref, watch } from 'vue'
import { computed, defineAsyncComponent, onMounted, ref, watch } from 'vue'
import { albumStore, artistStore, preferenceStore } from '@/stores'
import { useErrorHandler, useThirdPartyServices } from '@/composables'
import { requireInjection } from '@/utils'
import { CurrentSongKey } from '@/symbols'
import { isSong, requireInjection } from '@/utils'
import { CurrentPlayableKey } from '@/symbols'
import ProfileAvatar from '@/components/ui/ProfileAvatar.vue'
import SidebarMenuToggleButton from '@/components/ui/SidebarMenuToggleButton.vue'
@ -89,26 +89,33 @@ const ExtraDrawerTabHeader = defineAsyncComponent(() => import('./ExtraDrawerTab
const { useYouTube } = useThirdPartyServices()
const song = requireInjection(CurrentSongKey, ref(undefined))
const playable = requireInjection(CurrentPlayableKey, ref(undefined))
const activeTab = ref<ExtraPanelTab | null>(null)
const artist = ref<Artist>()
const album = ref<Album>()
const fetchSongInfo = async (_song: Song) => {
song.value = _song
const songPlaying = computed(() => playable.value && isSong(playable.value))
const shouldShowYouTubeTab = computed(() => useYouTube && songPlaying.value)
const fetchSongInfo = async (song: Song) => {
playable.value = song
artist.value = undefined
album.value = undefined
try {
artist.value = await artistStore.resolve(_song.artist_id)
album.value = await albumStore.resolve(_song.album_id)
artist.value = await artistStore.resolve(song.artist_id)
album.value = await albumStore.resolve(song.album_id)
} catch (error: unknown) {
useErrorHandler().handleHttpError(error)
}
}
watch(song, song => song && fetchSongInfo(song), { immediate: true })
watch(playable, song => {
if (!song || !isSong(song)) return
fetchSongInfo(song)
}, { immediate: true })
watch(activeTab, tab => (preferenceStore.active_extra_panel_tab = tab))
const onProfileLinkClick = () => isMobile.any && (activeTab.value = null)

View file

@ -15,7 +15,7 @@
<Icon v-else-if="isFavoriteList(list)" :icon="faHeart" class="text-k-love" fixed-width />
<Icon v-else-if="list.is_smart" :icon="faWandMagicSparkles" fixed-width />
<Icon v-else-if="list.is_collaborative" :icon="faUsers" fixed-width />
<ListMusic v-else :size="16" />
<ListMusicIcon v-else :size="16" />
</template>
{{ list.name }}
</SidebarItem>
@ -23,7 +23,7 @@
<script lang="ts" setup>
import { faClockRotateLeft, faHeart, faUsers, faWandMagicSparkles } from '@fortawesome/free-solid-svg-icons'
import { ListMusic } from 'lucide-vue-next'
import { ListMusicIcon } from 'lucide-vue-next'
import { computed, ref, toRefs } from 'vue'
import { eventBus } from '@/utils'
import { favoriteStore } from '@/stores'
@ -33,11 +33,11 @@ import SidebarItem from '@/components/layout/main-wrapper/sidebar/SidebarItem.vu
const { onRouteChanged } = useRouter()
const { startDragging } = useDraggable('playlist')
const { acceptsDrop, resolveDroppedSongs } = useDroppable(['songs', 'album', 'artist'])
const { acceptsDrop, resolveDroppedItems } = useDroppable(['playables', 'album', 'artist'])
const droppable = ref(false)
const { addSongsToPlaylist } = usePlaylistManagement()
const { addToPlaylist } = usePlaylistManagement()
const props = defineProps<{ list: PlaylistLike }>()
const { list } = toRefs(props)
@ -90,14 +90,14 @@ const onDrop = async (event: DragEvent) => {
if (!contentEditable.value) return false
if (!acceptsDrop(event)) return false
const songs = await resolveDroppedSongs(event)
const playables = await resolveDroppedItems(event)
if (!songs?.length) return false
if (!playables?.length) return false
if (isFavoriteList(list.value)) {
await favoriteStore.like(songs)
await favoriteStore.like(playables)
} else if (isPlaylist(list.value)) {
await addSongsToPlaylist(list.value, songs)
await addToPlaylist(list.value, playables)
}
return false

View file

@ -1,49 +0,0 @@
<template>
<SidebarItem
:class="droppable && 'droppable'"
href="#/queue"
screen="Queue"
@dragleave="onQueueDragLeave"
@drop="onQueueDrop"
@dragover.prevent="onQueueDragOver"
>
<template #icon>
<Icon :icon="faListOl" fixed-width />
</template>
Current Queue
</SidebarItem>
</template>
<script lang="ts" setup>
import { faListOl } from '@fortawesome/free-solid-svg-icons'
import { ref } from 'vue'
import { queueStore } from '@/stores'
import { useDroppable, useMessageToaster } from '@/composables'
import SidebarItem from './SidebarItem.vue'
import { pluralize } from '@/utils'
const { toastWarning, toastSuccess } = useMessageToaster()
const { acceptsDrop, resolveDroppedSongs } = useDroppable(['songs', 'album', 'artist', 'playlist', 'playlist-folder'])
const droppable = ref(false)
const onQueueDragOver = (event: DragEvent) => (droppable.value = acceptsDrop(event))
const onQueueDragLeave = () => (droppable.value = false)
const onQueueDrop = async (event: DragEvent) => {
droppable.value = false
event.preventDefault()
const songs = await resolveDroppedSongs(event) || []
if (songs.length) {
queueStore.queue(songs)
toastSuccess(`Added ${pluralize(songs, 'song')} to queue.`)
} else {
toastWarning('No applicable songs to queue.')
}
return false
}
</script>

View file

@ -1,12 +1,18 @@
<template>
<OnClickOutside @trigger="closeIfMobile">
<nav
ref="root"
:class="{ collapsed: !expanded, 'tmp-showing': tmpShowing, showing: mobileShowing }"
class="flex flex-col fixed md:relative w-full md:w-k-sidebar-width z-10"
@mouseenter="onMouseEnter"
@mouseleave="onMouseLeave"
>
<section class="search-wrapper p-6">
<section class="home-search-block p-6 flex gap-2">
<a
class="bg-black/20 flex items-center px-3.5 rounded-md !text-k-text-secondary hover:!text-k-text-primary"
href="#/home"
>
<Icon :icon="faHome" fixed-width />
</a>
<SearchForm />
</section>
@ -22,19 +28,19 @@
<SidebarToggleButton v-model="expanded" />
</nav>
</OnClickOutside>
</template>
<script lang="ts" setup>
import { faHome } from '@fortawesome/free-solid-svg-icons'
import { computed, ref, watch } from 'vue'
import { OnClickOutside } from '@vueuse/components'
import { onClickOutside } from '@vueuse/core'
import { eventBus } from '@/utils'
import { useAuthorization, useKoelPlus, useLocalStorage, useRouter, useUpload } from '@/composables'
import SidebarPlaylistsSection from './SidebarPlaylistsSection.vue'
import SearchForm from '@/components/ui/SearchForm.vue'
import BtnUpgradeToPlus from '@/components/koel-plus/BtnUpgradeToPlus.vue'
import SidebarYourMusicSection from './SidebarYourMusicSection.vue'
import SidebarYourMusicSection from './SidebarYourLibrarySection.vue'
import SidebarManageSection from './SidebarManageSection.vue'
import SidebarToggleButton from '@/components/layout/main-wrapper/sidebar/SidebarToggleButton.vue'
@ -44,14 +50,14 @@ const { allowsUpload } = useUpload()
const { isPlus } = useKoelPlus()
const { get: lsGet, set: lsSet } = useLocalStorage()
const root = ref<HTMLElement>()
const mobileShowing = ref(false)
const expanded = ref(!lsGet('sidebar-collapsed', false))
watch(expanded, value => lsSet('sidebar-collapsed', !value))
const showManageSection = computed(() => isAdmin.value || allowsUpload.value)
const closeIfMobile = () => (mobileShowing.value = false)
let tmpShowingHandler: number | undefined
const tmpShowing = ref(false)
@ -77,6 +83,8 @@ const onMouseLeave = (e: MouseEvent) => {
tmpShowing.value = false
}
// Not using the <OnClickOutside> component because it'd mess up the overflow/scrolling behavior
onClickOutside(root, () => (mobileShowing.value = false))
onRouteChanged(_ => (mobileShowing.value = false))
/**
@ -107,6 +115,10 @@ nav {
> *:not(.btn-toggle) {
@apply block;
}
> .home-search-block {
@apply flex;
}
}
}

View file

@ -1,13 +1,13 @@
import { screen } from '@testing-library/vue'
import { expect, it } from 'vitest'
import UnitTestCase from '@/__tests__/UnitTestCase'
import SidebarYourMusicSection from './SidebarYourMusicSection.vue'
import { eventBus } from '@/utils'
import SidebarYourLibrarySection from './SidebarYourLibrarySection.vue'
new class extends UnitTestCase {
protected test () {
it('shows YouTube item if a video is played', async () => {
this.render(SidebarYourMusicSection)
this.render(SidebarYourLibrarySection)
expect(screen.queryByTestId('youtube')).toBeNull()
eventBus.emit('PLAY_YOUTUBE_VIDEO', {

View file

@ -1,17 +1,10 @@
<template>
<SidebarSection>
<template #header>
<SidebarSectionHeader>Your Music</SidebarSectionHeader>
<SidebarSectionHeader>Your Library</SidebarSectionHeader>
</template>
<ul class="menu">
<SidebarItem href="#/home" screen="Home">
<template #icon>
<Icon :icon="faHome" fixed-width />
</template>
Home
</SidebarItem>
<QueueSidebarItem />
<SidebarItem href="#/songs" screen="Songs">
<template #icon>
<Icon :icon="faMusic" fixed-width />
@ -39,12 +32,18 @@
<YouTubeSidebarItem v-if="youtubeVideoTitle" data-testid="youtube">
{{ youtubeVideoTitle }}
</YouTubeSidebarItem>
<SidebarItem href="#/podcasts" screen="Podcasts">
<template #icon>
<Icon :icon="faPodcast" fixed-width />
</template>
Podcasts
</SidebarItem>
</ul>
</SidebarSection>
</template>
<script lang="ts" setup>
import { faCompactDisc, faHome, faMicrophone, faMusic, faTags } from '@fortawesome/free-solid-svg-icons'
import { faCompactDisc, faMicrophone, faMusic, faTags, faPodcast } from '@fortawesome/free-solid-svg-icons'
import { unescape } from 'lodash'
import { ref } from 'vue'
import { eventBus } from '@/utils'
@ -52,7 +51,6 @@ import { eventBus } from '@/utils'
import SidebarSection from '@/components/layout/main-wrapper/sidebar/SidebarSection.vue'
import SidebarSectionHeader from '@/components/layout/main-wrapper/sidebar/SidebarSectionHeader.vue'
import SidebarItem from '@/components/layout/main-wrapper/sidebar/SidebarItem.vue'
import QueueSidebarItem from '@/components/layout/main-wrapper/sidebar/QueueSidebarItem.vue'
import YouTubeSidebarItem from '@/components/layout/main-wrapper/sidebar/YouTubeSidebarItem.vue'
const youtubeVideoTitle = ref<string | null>(null)

View file

@ -51,7 +51,6 @@ const submit = async () => {
toastSuccess(`Playlist folder "${folder.name}" created.`)
} catch (error: unknown) {
useErrorHandler('dialog').handleHttpError(error)
logger.error(error)
} finally {
hideOverlay()
}

View file

@ -4,7 +4,7 @@
<h1>
New Playlist
<span v-if="songs.length" class="text-k-text-secondary" data-testid="from-songs">
from {{ pluralize(songs, 'song') }}
from {{ pluralize(songs, 'item') }}
</span>
</h1>
</header>

View file

@ -0,0 +1,70 @@
<template>
<form class="md:w-[420px] min-w-full" @submit.prevent="submit" @keydown.esc="maybeClose">
<header>
<h1>New Podcast</h1>
</header>
<main>
<FormRow>
<template #label>Podcast feed URL</template>
<TextInput
type="url"
v-model="url"
v-koel-focus
name="url"
placeholder="https://example.com/feed.xml"
required
/>
</FormRow>
</main>
<footer>
<Btn type="submit">Save</Btn>
<Btn white @click.prevent="maybeClose">Cancel</Btn>
</footer>
</form>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useDialogBox, useErrorHandler, useMessageToaster, useOverlay } from '@/composables'
import { podcastStore } from '@/stores'
import TextInput from '@/components/ui/form/TextInput.vue'
import Btn from '@/components/ui/form/Btn.vue'
import FormRow from '@/components/ui/form/FormRow.vue'
const { showOverlay, hideOverlay } = useOverlay()
const { toastSuccess } = useMessageToaster()
const { showConfirmDialog } = useDialogBox()
const url = ref('')
const emit = defineEmits<{ (e: 'close'): void }>()
const close = () => emit('close')
const submit = async () => {
showOverlay()
try {
const podcast = await podcastStore.store(url.value)
close()
toastSuccess(`Podcast "${podcast.title}" added.`)
} catch (error: unknown) {
useErrorHandler('dialog').handleHttpError(error, {
409: 'You have already subscribed to this podcast.'
})
} finally {
hideOverlay()
}
}
const maybeClose = async () => {
if (url.value.trim() === '') {
close()
return
}
await showConfirmDialog('Discard all changes?') && close()
}
</script>

View file

@ -0,0 +1,128 @@
<template>
<a
data-testid="episode-item"
class="group relative flex flex-col md:flex-row gap-4 px-6 py-5 !text-k-text-primary hover:bg-white/10 duration-200"
:class="isCurrentEpisode && 'current'"
:href="`/#/episodes/${episode.id}`"
@contextmenu.prevent="requestContextMenu"
@dragstart="onDragStart"
>
<Icon :icon="faBookmark" size="xl" class="absolute -top-1 right-3 text-k-accent" v-if="isCurrentEpisode" />
<button
class="hidden md:flex-[0_0_128px] relative overflow-hidden rounded-lg active:scale-95"
role="button"
@click.prevent="playOrPause"
>
<img
:src="episode.episode_image"
alt="Episode thumbnail"
class="w-[128px] aspect-square object-cover"
loading="lazy"
/>
<span class="absolute top-0 left-0 w-full h-full group-hover:bg-black/40 z-10" />
<span
class="absolute flex opacity-0 items-center justify-center w-[48px] aspect-square rounded-full top-1/2
left-1/2 -translate-x-1/2 -translate-y-1/2 bg-k-highlight group-hover:opacity-100 duration-500 transition z-20"
>
<Icon v-if="isPlaying" :icon="faPause" class="text-white" size="2xl" />
<Icon v-else :icon="faPlay" class="ml-1 text-white" size="2xl" />
</span>
</button>
<div class="flex-1">
<time
:datetime="episode.created_at"
:title="episode.created_at"
class="block uppercase text-sm mb-1 text-k-text-secondary"
>
{{ publicationDateForHumans }}
</time>
<h3 class="text-xl" :title="episode.title">{{ episode.title }}</h3>
<div class="description text-k-text-secondary mt-3 line-clamp-3" v-html="description" />
</div>
<div class="md:flex-[0_0_96px] text-sm text-k-text-secondary flex md:flex-col items-center justify-center">
<span class="block md:mb-2">{{ timeLeft ? timeLeft : 'Played' }}</span>
<div class="px-4 w-full">
<EpisodeProgress v-if="shouldShowProgress" :episode="episode" :position="currentPosition" />
</div>
</div>
</a>
</template>
<script setup lang="ts">
import { faBookmark, faPause, faPlay } from '@fortawesome/free-solid-svg-icons'
import DOMPurify from 'dompurify'
import { computed, toRefs } from 'vue'
import { eventBus, secondsToHis } from '@/utils'
import { useDraggable } from '@/composables'
import { formatTimeAgo } from '@vueuse/core'
import { playbackService } from '@/services'
import EpisodeProgress from '@/components/podcast/EpisodeProgress.vue'
import { preferenceStore as preferences, queueStore, songStore as episodeStore } from '@/stores'
import { orderBy } from 'lodash'
const props = defineProps<{ episode: Episode, podcast: Podcast }>()
const { episode, podcast } = toRefs(props)
const { startDragging } = useDraggable('playables')
const publicationDateForHumans = computed(() => {
const publishedAt = new Date(episode.value.created_at)
if ((Date.now() - publishedAt.getTime()) / (1000 * 60 * 60 * 24) < 31) {
return formatTimeAgo(publishedAt)
}
return publishedAt.toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric'
})
})
const currentPosition = computed(() => podcast.value.state.progresses[episode.value.id] || 0)
const timeLeft = computed(() => {
if (currentPosition.value === 0) return secondsToHis(episode.value.length)
const secondsLeft = episode.value.length - currentPosition.value
return secondsLeft === 0 ? 0 : secondsToHis(secondsLeft)
})
const shouldShowProgress = computed(() => timeLeft.value !== 0 && episode.value.length && currentPosition.value)
const isCurrentEpisode = computed(() => podcast.value.state.current_episode === episode.value.id)
const description = computed(() => DOMPurify.sanitize(episode.value.episode_description))
const onDragStart = (event: DragEvent) => startDragging(event, episode.value)
const requestContextMenu = (event: MouseEvent) => eventBus.emit('PLAYABLE_CONTEXT_MENU_REQUESTED', event, episode.value)
const isPlaying = computed(() => episode.value.playback_state === 'Playing')
const playOrPause = async () => {
if (isPlaying.value) {
return playbackService.pause()
}
if (episode.value.playback_state === 'Paused') {
return playbackService.resume()
}
if (preferences.continuous_playback) {
queueStore.replaceQueueWith(orderBy(await episodeStore.fetchForPodcast(podcast.value.id), 'created_at'))
}
playbackService.play(episode.value, currentPosition.value >= episode.value.length ? 0 : currentPosition.value)
}
</script>
<style scoped lang="postcss">
.description {
:deep(p) {
@apply mb-3;
}
:deep(a) {
@apply text-k-text-primary hover:text-k-accent;
}
}
</style>

View file

@ -0,0 +1,14 @@
<template>
<div class="relative h-1 w-full rounded-full overflow-hidden bg-white/30">
<span class="absolute h-full bg-k-accent top-0 left-0" :style="{ width: `${percentage}%` }" />
</div>
</template>
<script setup lang="ts">
import { computed, toRefs } from 'vue'
const props = defineProps<{ episode: Episode, position: number }>()
const { episode, position } = toRefs(props)
const percentage = computed(() => (position.value / episode.value.length) * 100)
</script>

View file

@ -0,0 +1,33 @@
<template>
<BaseCard
:entity="podcast"
:layout="layout"
:title="`${podcast.title} by ${podcast.author}`"
@click="goToPodcast"
class="cursor-pointer"
>
<template #name>
<a :href="`#/podcasts/${podcast.id}`" class="font-medium" data-testid="title">{{ podcast.title }}</a>
<span class="text-k-text-secondary">{{ podcast.author }}</span>
</template>
<template #thumbnail>
<img :src="podcast.image" class="aspect-square w-[80px] object-cover rounded-lg" alt="Podcast image" />
</template>
</BaseCard>
</template>
<script lang="ts" setup>
import { toRefs } from 'vue'
import { eventBus } from '@/utils'
import { useRouter } from '@/composables'
import BaseCard from '@/components/ui/album-artist/AlbumOrArtistCard.vue'
const props = withDefaults(defineProps<{ podcast: Podcast, layout?: ArtistAlbumCardLayout }>(), { layout: 'full' })
const { podcast, layout } = toRefs(props)
const { go } = useRouter()
const goToPodcast = () => go(`/podcasts/${podcast.value.id}`)
</script>

Some files were not shown because too many files have changed in this diff Show more