mirror of
https://github.com/koel/koel
synced 2024-11-10 06:34:14 +00:00
feat: podcasts
This commit is contained in:
parent
911410bdfd
commit
3e321bf47e
198 changed files with 4145 additions and 1048 deletions
18
app/Builders/PodcastBuilder.php
Normal file
18
app/Builders/PodcastBuilder.php
Normal 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);
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
30
app/Casts/Podcast/CategoriesCast.php
Normal file
30
app/Casts/Podcast/CategoriesCast.php
Normal 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();
|
||||
}
|
||||
}
|
25
app/Casts/Podcast/EnclosureCast.php
Normal file
25
app/Casts/Podcast/EnclosureCast.php
Normal 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();
|
||||
}
|
||||
}
|
30
app/Casts/Podcast/EpisodeMetadataCast.php
Normal file
30
app/Casts/Podcast/EpisodeMetadataCast.php
Normal 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([]);
|
||||
}
|
||||
}
|
30
app/Casts/Podcast/PodcastMetadataCast.php
Normal file
30
app/Casts/Podcast/PodcastMetadataCast.php
Normal 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([]);
|
||||
}
|
||||
}
|
31
app/Casts/Podcast/PodcastStateCast.php
Normal file
31
app/Casts/Podcast/PodcastStateCast.php
Normal 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();
|
||||
}
|
||||
}
|
41
app/Console/Commands/SyncPodcastsCommand.php
Normal file
41
app/Console/Commands/SyncPodcastsCommand.php
Normal 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;
|
||||
}
|
||||
}
|
|
@ -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
9
app/Enums/MediaType.php
Normal file
|
@ -0,0 +1,9 @@
|
|||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum MediaType: string
|
||||
{
|
||||
case SONG = 'song';
|
||||
case PODCAST_EPISODE = 'episode';
|
||||
}
|
19
app/Exceptions/FailedToParsePodcastFeedException.php
Normal file
19
app/Exceptions/FailedToParsePodcastFeedException.php
Normal 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);
|
||||
}
|
||||
}
|
15
app/Exceptions/UserAlreadySubscribedToPodcast.php
Normal file
15
app/Exceptions/UserAlreadySubscribedToPodcast.php
Normal 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");
|
||||
}
|
||||
}
|
|
@ -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(),
|
||||
|
|
|
@ -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
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
)
|
||||
|
|
17
app/Http/Controllers/API/Podcast/FetchEpisodeController.php
Normal file
17
app/Http/Controllers/API/Podcast/FetchEpisodeController.php
Normal 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);
|
||||
}
|
||||
}
|
49
app/Http/Controllers/API/Podcast/PodcastController.php
Normal file
49
app/Http/Controllers/API/Podcast/PodcastController.php
Normal 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);
|
||||
}
|
||||
}
|
|
@ -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));
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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)],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
21
app/Http/Requests/API/Podcast/PodcastStoreRequest.php
Normal file
21
app/Http/Requests/API/Podcast/PodcastStoreRequest.php
Normal 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',
|
||||
];
|
||||
}
|
||||
}
|
|
@ -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)],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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',
|
||||
];
|
||||
}
|
||||
|
|
|
@ -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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
21
app/Http/Resources/PodcastCategoryResource.php
Normal file
21
app/Http/Resources/PodcastCategoryResource.php
Normal 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();
|
||||
}
|
||||
}
|
60
app/Http/Resources/PodcastResource.php
Normal file
60
app/Http/Resources/PodcastResource.php
Normal 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;
|
||||
}
|
||||
}
|
26
app/Http/Resources/PodcastResourceCollection.php
Normal file
26
app/Http/Resources/PodcastResourceCollection.php
Normal 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();
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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'];
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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());
|
||||
|
|
70
app/Models/Podcast/Episode.php
Normal file
70
app/Models/Podcast/Episode.php
Normal 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));
|
||||
}
|
||||
}
|
111
app/Models/Podcast/Podcast.php
Normal file
111
app/Models/Podcast/Podcast.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
24
app/Models/Podcast/PodcastUserPivot.php
Normal file
24
app/Models/Podcast/PodcastUserPivot.php
Normal 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,
|
||||
];
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -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 {
|
||||
|
|
14
app/Policies/EpisodePolicy.php
Normal file
14
app/Policies/EpisodePolicy.php
Normal 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);
|
||||
}
|
||||
}
|
14
app/Policies/PodcastPolicy.php
Normal file
14
app/Policies/PodcastPolicy.php
Normal 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);
|
||||
}
|
||||
}
|
|
@ -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()));
|
||||
|
|
40
app/Repositories/PodcastRepository.php
Normal file
40
app/Repositories/PodcastRepository.php
Normal 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;
|
||||
}
|
||||
}
|
|
@ -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
|
||||
{
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
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';
|
||||
: 'Not all playlists belong to the user'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
if (!UserPreferences::customizable($value)) {
|
||||
$fail('Invalid or uncustomizable user preference key.');
|
||||
}
|
||||
|
||||
public function message(): string
|
||||
{
|
||||
return 'Invalid or uncustomizable user preference key.';
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
if (!$passes) {
|
||||
$fail(
|
||||
config('koel.storage_driver') === 'local'
|
||||
? 'Media path is required for local storage.'
|
||||
: 'Media path is not required for non-local storage.'
|
||||
);
|
||||
}
|
||||
|
||||
public function message(): string
|
||||
{
|
||||
if (config('koel.storage_driver') === 'local') {
|
||||
return 'Media path is required for local storage.';
|
||||
}
|
||||
|
||||
return 'Media path is not required for non-local storage.';
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
213
app/Services/PodcastService.php
Normal file
213
app/Services/PodcastService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
|
|
|
@ -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
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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');
|
||||
|
|
38
app/Services/Streamer/Adapters/PodcastStreamerAdapter.php
Normal file
38
app/Services/Streamer/Adapters/PodcastStreamerAdapter.php
Normal 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);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
63
app/Values/Podcast/EpisodePlayable.php
Normal file
63
app/Values/Podcast/EpisodePlayable.php
Normal 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);
|
||||
}
|
||||
}
|
38
app/Values/Podcast/PodcastState.php
Normal file
38
app/Values/Podcast/PodcastState.php
Normal 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);
|
||||
}
|
||||
}
|
|
@ -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) {
|
||||
|
|
|
@ -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
427
composer.lock
generated
|
@ -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",
|
||||
|
|
|
@ -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.
|
||||
|
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
}
|
||||
};
|
|
@ -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",
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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(() => {
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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>
|
|
@ -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()
|
||||
|
|
|
@ -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))
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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', {
|
|
@ -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)
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
70
resources/assets/js/components/podcast/AddPodcastForm.vue
Normal file
70
resources/assets/js/components/podcast/AddPodcastForm.vue
Normal 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>
|
128
resources/assets/js/components/podcast/EpisodeItem.vue
Normal file
128
resources/assets/js/components/podcast/EpisodeItem.vue
Normal 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>
|
14
resources/assets/js/components/podcast/EpisodeProgress.vue
Normal file
14
resources/assets/js/components/podcast/EpisodeProgress.vue
Normal 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>
|
33
resources/assets/js/components/podcast/PodcastCard.vue
Normal file
33
resources/assets/js/components/podcast/PodcastCard.vue
Normal 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
Loading…
Reference in a new issue