feat: supports multi-tenant

This commit is contained in:
Phan An 2024-01-03 18:02:18 +01:00
parent 600a3c1dd6
commit 9e27a08960
45 changed files with 588 additions and 399 deletions

View file

@ -2,8 +2,11 @@
namespace App\Builders;
use App\Facades\License;
use App\Models\Album;
use App\Models\User;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Query\JoinClause;
class AlbumBuilder extends Builder
{
@ -11,4 +14,20 @@ class AlbumBuilder extends Builder
{
return $this->whereNot('albums.id', Album::UNKNOWN_ID);
}
public function accessibleBy(User $user): static
{
if (License::isCommunity()) {
// With the Community license, all albums are accessible by all users.
return $this;
}
return $this->join('songs', static function (JoinClause $join) use ($user): void {
$join->on('albums.id', 'songs.album_id')
->where(static function (JoinClause $query) use ($user): void {
$query->where('songs.owner_id', $user->id)
->orWhere('songs.is_public', true);
});
});
}
}

View file

@ -2,8 +2,11 @@
namespace App\Builders;
use App\Facades\License;
use App\Models\Artist;
use App\Models\User;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Query\JoinClause;
class ArtistBuilder extends Builder
{
@ -11,4 +14,20 @@ class ArtistBuilder extends Builder
{
return $this->whereNotIn('artists.id', [Artist::UNKNOWN_ID, Artist::VARIOUS_ID]);
}
public function accessibleBy(User $user): static
{
if (License::isCommunity()) {
// With the Community license, all artists are accessible by all users.
return $this;
}
return $this->join('songs', static function (JoinClause $join) use ($user): void {
$join->on('artists.id', 'songs.artist_id')
->where(static function (JoinClause $query) use ($user): void {
$query->where('songs.owner_id', $user->id)
->orWhere('songs.is_public', true);
});
});
}
}

View file

@ -2,12 +2,36 @@
namespace App\Builders;
use App\Facades\License;
use App\Models\User;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Query\JoinClause;
use Webmozart\Assert\Assert;
/**
* @method self logSql()
*/
class SongBuilder extends Builder
{
public const SORT_COLUMNS_NORMALIZE_MAP = [
'title' => 'songs.title',
'track' => 'songs.track',
'length' => 'songs.length',
'created_at' => 'songs.created_at',
'disc' => 'songs.disc',
'artist_name' => 'artists.name',
'album_name' => 'albums.name',
];
private const VALID_SORT_COLUMNS = [
'songs.title',
'songs.track',
'songs.length',
'songs.created_at',
'artists.name',
'albums.name',
];
public function inDirectory(string $path): static
{
// Make sure the path ends with a directory separator.
@ -16,12 +40,15 @@ class SongBuilder extends Builder
return $this->where('path', 'LIKE', "$path%");
}
public function withMeta(User $user): static
public function withMetaFor(User $user, bool $requiresInteractions = false): static
{
$joinType = $requiresInteractions ? 'join' : 'leftJoin';
return $this
->with('artist', 'album', 'album.artist')
->leftJoin('interactions', static function (JoinClause $join) use ($user): void {
$join->on('interactions.song_id', '=', 'songs.id')->where('interactions.user_id', $user->id);
->$joinType('interactions', static function (JoinClause $join) use ($user): void {
$join->on('interactions.song_id', '=', 'songs.id')
->where('interactions.user_id', $user->id);
})
->join('albums', 'songs.album_id', '=', 'albums.id')
->join('artists', 'songs.artist_id', '=', 'artists.id')
@ -34,6 +61,53 @@ class SongBuilder extends Builder
);
}
public function accessibleBy(User $user, bool $withTableName = true): static
{
if (License::isCommunity()) {
// In the Community Edition, all songs are accessible by all users.
return $this;
}
return $this->where(static function (Builder $query) use ($user, $withTableName): void {
$query->where(($withTableName ? 'songs.' : '') . 'is_public', true)
->orWhere(($withTableName ? 'songs.' : '') . 'owner_id', $user->id);
});
}
public function sort(string $column, string $direction): static
{
$column = self::normalizeSortColumn($column);
Assert::oneOf($column, self::VALID_SORT_COLUMNS);
Assert::oneOf(strtolower($direction), ['asc', 'desc']);
$this->orderBy($column, $direction);
if ($column === 'artists.name') {
$this->orderBy('albums.name')
->orderBy('songs.disc')
->orderBy('songs.track')
->orderBy('songs.title');
} elseif ($column === 'albums.name') {
$this->orderBy('artists.name')
->orderBy('songs.disc')
->orderBy('songs.track')
->orderBy('songs.title');
} elseif ($column === 'track') {
$this->orderBy('songs.disc')
->orderBy('songs.track');
}
return $this;
}
private static function normalizeSortColumn(string $column): string
{
return key_exists($column, self::SORT_COLUMNS_NORMALIZE_MAP)
? self::SORT_COLUMNS_NORMALIZE_MAP[$column]
: $column;
}
public function hostedOnS3(): static
{
return $this->where('path', 'LIKE', 's3://%');

17
app/Facades/License.php Normal file
View file

@ -0,0 +1,17 @@
<?php
namespace App\Facades;
use Illuminate\Support\Facades\Facade;
/**
* @method static bool isPlus()
* @method static bool isCommunity()
*/
class License extends Facade
{
protected static function getFacadeAccessor(): string
{
return 'License';
}
}

View file

@ -1,21 +0,0 @@
<?php
namespace App\Http\Controllers\API\Interaction;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Repositories\InteractionRepository;
use Illuminate\Contracts\Auth\Authenticatable;
class RecentlyPlayedController extends Controller
{
/** @param User $user */
public function __construct(private InteractionRepository $interactionRepository, private ?Authenticatable $user)
{
}
public function index(?int $count = null)
{
return response()->json($this->interactionRepository->getRecentlyPlayed($this->user, $count));
}
}

View file

@ -26,7 +26,7 @@ class UploadController extends Controller
UploadRequest $request,
Authenticatable $user
) {
$this->authorize('admin', User::class);
$this->authorize('upload', User::class);
try {
$song = $songRepository->getOne($uploadService->handleUploadedFile($request->file)->id);

View file

@ -1,19 +0,0 @@
<?php
namespace App\Http\Controllers\Download;
use App\Http\Controllers\Controller;
use App\Models\Album;
use App\Services\DownloadService;
class AlbumController extends Controller
{
public function __construct(private DownloadService $downloadService)
{
}
public function show(Album $album)
{
return response()->download($this->downloadService->from($album));
}
}

View file

@ -1,19 +0,0 @@
<?php
namespace App\Http\Controllers\Download;
use App\Http\Controllers\Controller;
use App\Models\Artist;
use App\Services\DownloadService;
class ArtistController extends Controller
{
public function __construct(private DownloadService $downloadService)
{
}
public function show(Artist $artist)
{
return response()->download($this->downloadService->from($artist));
}
}

View file

@ -0,0 +1,15 @@
<?php
namespace App\Http\Controllers\Download;
use App\Http\Controllers\Controller;
use App\Models\Album;
use App\Services\DownloadService;
class DownloadAlbumController extends Controller
{
public function __invoke(Album $album, DownloadService $download)
{
return response()->download($download->from($album));
}
}

View file

@ -0,0 +1,15 @@
<?php
namespace App\Http\Controllers\Download;
use App\Http\Controllers\Controller;
use App\Models\Artist;
use App\Services\DownloadService;
class DownloadArtistController extends Controller
{
public function __invoke(Artist $artist, DownloadService $download)
{
return response()->download($download->from($artist));
}
}

View file

@ -0,0 +1,18 @@
<?php
namespace App\Http\Controllers\Download;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Repositories\InteractionRepository;
use App\Services\DownloadService;
use Illuminate\Contracts\Auth\Authenticatable;
class DownloadFavoritesController extends Controller
{
/** @param User $user */
public function __invoke(DownloadService $download, InteractionRepository $repository, Authenticatable $user)
{
return response()->download($download->from($repository->getUserFavorites($user)));
}
}

View file

@ -0,0 +1,17 @@
<?php
namespace App\Http\Controllers\Download;
use App\Http\Controllers\Controller;
use App\Models\Playlist;
use App\Services\DownloadService;
class DownloadPlaylistController extends Controller
{
public function __invoke(Playlist $playlist, DownloadService $download)
{
$this->authorize('own', $playlist);
return response()->download($download->from($playlist));
}
}

View file

@ -0,0 +1,16 @@
<?php
namespace App\Http\Controllers\Download;
use App\Http\Controllers\Controller;
use App\Http\Requests\Download\DownloadSongsRequest;
use App\Repositories\SongRepository;
use App\Services\DownloadService;
class DownloadSongsController extends Controller
{
public function __invoke(DownloadSongsRequest $request, DownloadService $download, SongRepository $repository)
{
return response()->download($download->from($repository->getMany($request->songs)));
}
}

View file

@ -1,27 +0,0 @@
<?php
namespace App\Http\Controllers\Download;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Repositories\InteractionRepository;
use App\Services\DownloadService;
use Illuminate\Contracts\Auth\Authenticatable;
class FavoritesController extends Controller
{
/** @param User $user */
public function __construct(
private DownloadService $downloadService,
private InteractionRepository $interactionRepository,
private ?Authenticatable $user
) {
}
public function show()
{
$songs = $this->interactionRepository->getUserFavorites($this->user);
return response()->download($this->downloadService->from($songs));
}
}

View file

@ -1,21 +0,0 @@
<?php
namespace App\Http\Controllers\Download;
use App\Http\Controllers\Controller;
use App\Models\Playlist;
use App\Services\DownloadService;
class PlaylistController extends Controller
{
public function __construct(private DownloadService $downloadService)
{
}
public function show(Playlist $playlist)
{
$this->authorize('own', $playlist);
return response()->download($this->downloadService->from($playlist));
}
}

View file

@ -1,22 +0,0 @@
<?php
namespace App\Http\Controllers\Download;
use App\Http\Controllers\Controller;
use App\Http\Requests\Download\SongRequest;
use App\Repositories\SongRepository;
use App\Services\DownloadService;
class SongController extends Controller
{
public function __construct(private DownloadService $downloadService, private SongRepository $songRepository)
{
}
public function show(SongRequest $request)
{
$songs = $this->songRepository->getMany($request->songs);
return response()->download($this->downloadService->from($songs));
}
}

View file

@ -1,29 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Http\Requests\API\ViewSongOnITunesRequest;
use App\Models\Album;
use App\Services\ITunesService;
use App\Services\TokenManager;
use Illuminate\Http\Response;
class ITunesController extends Controller
{
public function __construct(private ITunesService $iTunesService, private TokenManager $tokenManager)
{
}
public function viewSong(ViewSongOnITunesRequest $request, Album $album)
{
abort_unless(
(bool) $this->tokenManager->getUserFromPlainTextToken($request->api_token),
Response::HTTP_UNAUTHORIZED
);
$url = $this->iTunesService->getTrackUrl($request->q, $album->name, $album->artist->name);
abort_unless((bool) $url, 404, "Koel can't find such a song on iTunes Store.");
return redirect($url);
}
}

View file

@ -8,13 +8,16 @@ use App\Models\Song;
class PlayController extends Controller
{
public function __construct(private StreamerFactory $streamerFactory)
{
}
public function __invoke(
StreamerFactory $streamerFactory,
SongPlayRequest $request,
Song $song,
?bool $transcode = null,
?int $bitRate = null
) {
$this->authorize('play', $song);
public function show(SongPlayRequest $request, Song $song, ?bool $transcode = null, ?int $bitRate = null)
{
return $this->streamerFactory
return $streamerFactory
->createStreamer($song, $transcode, $bitRate, (float) $request->time)
->stream();
}

View file

@ -0,0 +1,29 @@
<?php
namespace App\Http\Controllers;
use App\Http\Requests\API\ViewSongOnITunesRequest;
use App\Models\Album;
use App\Services\ITunesService;
use App\Services\TokenManager;
use Illuminate\Http\Response;
class ViewSongOnITunesController extends Controller
{
public function __invoke(
ViewSongOnITunesRequest $request,
ITunesService $iTunesService,
TokenManager $tokenManager,
Album $album
) {
abort_unless(
(bool) $tokenManager->getUserFromPlainTextToken($request->api_token),
Response::HTTP_UNAUTHORIZED
);
$url = $iTunesService->getTrackUrl($request->q, $album->name, $album->artist->name);
abort_unless((bool) $url, Response::HTTP_NOT_FOUND, "Koel can't find such a song on iTunes Store.");
return redirect($url);
}
}

View file

@ -2,7 +2,7 @@
namespace App\Http\Requests\API;
use App\Repositories\SongRepository;
use App\Builders\SongBuilder;
use Illuminate\Validation\Rule;
/**
@ -20,7 +20,7 @@ class FetchSongsForQueueRequest extends Request
'limit' => 'required|integer|min:1',
'sort' => [
'required_unless:order,rand',
Rule::in(array_keys(SongRepository::SORT_COLUMNS_NORMALIZE_MAP)),
Rule::in(array_keys(SongBuilder::SORT_COLUMNS_NORMALIZE_MAP)),
],
];
}

View file

@ -5,7 +5,7 @@ namespace App\Http\Requests\Download;
/**
* @property array $songs
*/
class SongRequest extends Request
class DownloadSongsRequest extends Request
{
/** @return array<mixed> */
public function rules(): array

View file

@ -35,6 +35,7 @@ class SongResource extends JsonResource
'genre' => $this->song->genre,
'year' => $this->song->year,
'created_at' => $this->song->created_at,
'owner_id' => $this->song->owner_id,
];
}
}

View file

@ -17,6 +17,7 @@ use Laravel\Scout\Searchable;
* @property string $path
* @property string $title
* @property Album $album
* @property User $uploader
* @property Artist $artist
* @property Artist $album_artist
* @property float $length
@ -33,6 +34,8 @@ use Laravel\Scout\Searchable;
* @property ?int $play_count The number of times the song has been played by the current user (dynamically calculated)
* @property Carbon $created_at
* @property array<mixed> $s3_params
* @property int $owner_id
* @property bool $is_public
*/
class Song extends Model
{
@ -52,6 +55,7 @@ class Song extends Model
'mtime' => 'int',
'track' => 'int',
'disc' => 'int',
'is_public' => 'bool',
];
protected $keyType = 'string';
@ -66,6 +70,11 @@ class Song extends Model
return parent::query();
}
public function owner(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function newEloquentBuilder($query): SongBuilder
{
return new SongBuilder($query);
@ -104,6 +113,11 @@ class Song extends Model
);
}
public function accessibleBy(User $user): bool
{
return $this->is_public || $this->owner_id === $user->id;
}
protected function lyrics(): Attribute
{
$normalizer = static function (?string $value): string {

View file

@ -0,0 +1,15 @@
<?php
namespace App\Policies;
use App\Facades\License;
use App\Models\Song;
use App\Models\User;
class SongPolicy
{
public function play(User $user, Song $song): bool
{
return License::isCommunity() || $song->is_public || $song->owner_id === $user->id;
}
}

View file

@ -2,6 +2,7 @@
namespace App\Policies;
use App\Facades\License;
use App\Models\User;
class UserPolicy
@ -15,4 +16,11 @@ class UserPolicy
{
return $currentUser->is_admin && $currentUser->isNot($userToDestroy);
}
public function upload(User $currentUser): bool
{
// For Community Edition, only admins can upload songs.
// For Plus Edition, any user can upload songs (to their own library).
return License::isCommunity() ? $currentUser->is_admin : true;
}
}

View file

@ -2,12 +2,7 @@
namespace App\Providers;
use App\Models\Playlist;
use App\Models\PlaylistFolder;
use App\Models\User;
use App\Policies\PlaylistFolderPolicy;
use App\Policies\PlaylistPolicy;
use App\Policies\UserPolicy;
use App\Services\TokenManager;
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
use Illuminate\Http\Request;
@ -16,12 +11,6 @@ use Illuminate\Validation\Rules\Password;
class AuthServiceProvider extends ServiceProvider
{
protected $policies = [
Playlist::class => PlaylistPolicy::class,
User::class => UserPolicy::class,
PlaylistFolder::class => PlaylistFolderPolicy::class,
];
public function boot(): void
{
$this->registerPolicies();

View file

@ -0,0 +1,14 @@
<?php
namespace App\Providers;
use App\Services\LicenseService;
use Illuminate\Support\ServiceProvider;
class LicenseServiceProvider extends ServiceProvider
{
public function register(): void
{
app()->singleton('License', static fn (): LicenseService => app(LicenseService::class));
}
}

View file

@ -2,7 +2,9 @@
namespace App\Providers;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\ServiceProvider;
class MacroProvider extends ServiceProvider
@ -13,5 +15,12 @@ class MacroProvider extends ServiceProvider
/** @var Collection $this */
return $this->sortBy(static fn ($item) => array_search($item->$key, $orderBy, true));
});
Builder::macro('logSql', function (): Builder {
/** @var Builder $this */
Log::info($this->toSql());
return $this;
});
}
}

View file

@ -2,59 +2,91 @@
namespace App\Repositories;
use App\Facades\License;
use App\Models\Album;
use App\Models\Artist;
use App\Models\User;
use App\Repositories\Traits\Searchable;
use Illuminate\Contracts\Pagination\Paginator;
use Illuminate\Database\Query\JoinClause;
use Illuminate\Support\Collection;
class AlbumRepository extends Repository
{
use Searchable;
/** @return Collection|array<array-key, Album> */
public function getRecentlyAdded(int $count = 6): Collection
public function getRecentlyAdded(int $count = 6, ?User $user = null): Collection
{
return Album::query()
->isStandard()
->latest('created_at')
->accessibleBy($user ?? $this->auth->user())
->groupBy('albums.id')
->distinct()
->latest('albums.created_at')
->limit($count)
->get();
->get('albums.*');
}
/** @return Collection|array<array-key, Album> */
public function getMostPlayed(int $count = 6, ?User $user = null): Collection
{
/** @var User $user */
$user ??= $this->auth->user();
return Album::query()
->leftJoin('songs', 'albums.id', 'songs.album_id')
->leftJoin('interactions', static function (JoinClause $join) use ($user): void {
$join->on('songs.id', 'interactions.song_id')->where('interactions.user_id', $user->id);
})
$query = Album::query()
->isStandard()
->accessibleBy($user);
if (License::isCommunity()) {
// if the license is Plus, accessibleBy() would have already joined the songs table
// and we don't want to join it twice
$query->leftJoin('songs', 'albums.id', 'songs.album_id');
}
return $query->join('interactions', static function (JoinClause $join) use ($user): void {
$join->on('songs.id', 'interactions.song_id')->where('interactions.user_id', $user->id);
})
->groupBy('albums.id')
->distinct()
->orderByDesc('play_count')
->limit($count)
->get('albums.*');
}
/** @return Collection|array<array-key, Album> */
public function getByArtist(Artist $artist): Collection
public function getMany(array $ids, bool $inThatOrder = false, ?User $user = null): Collection
{
return Album::query()
->where('artist_id', $artist->id)
->orWhereIn('id', $artist->songs()->pluck('album_id'))
->orderBy('name')
->get();
$albums = Album::query()
->isStandard()
->accessibleBy($user ?? auth()->user())
->whereIn('albums.id', $ids)
->groupBy('albums.id')
->distinct()
->get('albums.*');
return $inThatOrder ? $albums->orderByArray($ids) : $albums;
}
public function paginate(): Paginator
/** @return Collection|array<array-key, Album> */
public function getByArtist(Artist $artist, ?User $user = null): Collection
{
return Album::query()
->accessibleBy($user ?? $this->auth->user())
->where('albums.artist_id', $artist->id)
->orWhereIn('albums.id', $artist->songs()->pluck('album_id'))
->orderBy('albums.name')
->groupBy('albums.id')
->distinct()
->get('albums.*');
}
public function paginate(?User $user = null): Paginator
{
return Album::query()
->accessibleBy($user ?? $this->auth->user())
->isStandard()
->orderBy('name')
->orderBy('albums.name')
->groupBy('albums.id')
->distinct()
->select('albums.*')
->simplePaginate(21);
}
}

View file

@ -2,27 +2,34 @@
namespace App\Repositories;
use App\Facades\License;
use App\Models\Artist;
use App\Models\User;
use App\Repositories\Traits\Searchable;
use Illuminate\Contracts\Pagination\Paginator;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Query\JoinClause;
class ArtistRepository extends Repository
{
use Searchable;
/** @return Collection|array<array-key, Artist> */
public function getMostPlayed(int $count = 6, ?User $user = null): Collection
{
/** @var User $user */
$user ??= auth()->user();
return Artist::query()
->leftJoin('songs', 'artists.id', '=', 'songs.artist_id')
->leftJoin('interactions', static function (JoinClause $join) use ($user): void {
$join->on('interactions.song_id', '=', 'songs.id')->where('interactions.user_id', $user->id);
})
$query = Artist::query()
->isStandard()
->accessibleBy($user);
if (License::isCommunity()) {
// if the license is Plus, accessibleBy() would have already joined the songs table
// and we don't want to join it twice
$query->leftJoin('songs', 'artists.id', 'songs.artist_id');
}
return $query->join('interactions', static function (JoinClause $join) use ($user): void {
$join->on('interactions.song_id', '=', 'songs.id')->where('interactions.user_id', $user->id);
})
->groupBy([
'artists.id',
'play_count',
@ -31,28 +38,35 @@ class ArtistRepository extends Repository
'artists.created_at',
'artists.updated_at',
])
->isStandard()
->distinct()
->orderByDesc('play_count')
->limit($count)
->get('artists.*');
}
/** @return Collection|array<array-key, Artist> */
public function getMany(array $ids, bool $inThatOrder = false): Collection
public function getMany(array $ids, bool $inThatOrder = false, ?User $user = null): Collection
{
$artists = Artist::query()
->isStandard()
->whereIn('id', $ids)
->get();
->accessibleBy($user ?? auth()->user())
->whereIn('artists.id', $ids)
->groupBy('artists.id')
->distinct()
->get('artists.*');
return $inThatOrder ? $artists->orderByArray($ids) : $artists;
}
public function paginate(): Paginator
public function paginate(?User $user = null): Paginator
{
return Artist::query()
->isStandard()
->orderBy('name')
->accessibleBy($user ?? auth()->user())
->groupBy('artists.id')
->distinct()
->orderBy('artists.name')
->select('artists.*')
->simplePaginate(21);
}
}

View file

@ -3,6 +3,7 @@
namespace App\Repositories;
use App\Models\Song;
use App\Models\User;
use App\Values\Genre;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
@ -10,9 +11,10 @@ use Illuminate\Support\Facades\DB;
class GenreRepository
{
/** @return Collection|array<array-key, Genre> */
public function getAll(): Collection
public function getAll(?User $scopedUser = null): Collection
{
return Song::query()
->accessibleBy($scopedUser ?? auth()->user())
->select('genre', DB::raw('COUNT(id) AS song_count'), DB::raw('SUM(length) AS length'))
->groupBy('genre')
->orderBy('genre')
@ -24,10 +26,11 @@ class GenreRepository
));
}
public function getOne(string $name): ?Genre
public function getOne(string $name, ?User $scopedUser = null): ?Genre
{
/** @var object|null $record */
$record = Song::query()
->accessibleBy($scopedUser ?? auth()->user())
->select('genre', DB::raw('COUNT(id) AS song_count'), DB::raw('SUM(length) AS length'))
->groupBy('genre')
->where('genre', $name === Genre::NO_GENRE ? '' : $name)

View file

@ -4,19 +4,14 @@ namespace App\Repositories;
use App\Models\Interaction;
use App\Models\User;
use App\Repositories\Traits\ByCurrentUser;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
class InteractionRepository extends Repository
{
use ByCurrentUser;
/** @return Collection|array<Interaction> */
public function getUserFavorites(User $user): Collection
{
return $this->model
->newQuery()
return Interaction::query()
->where([
'user_id' => $user->id,
'liked' => true,
@ -24,17 +19,4 @@ class InteractionRepository extends Repository
->with('song')
->pluck('song');
}
/** @return array<Interaction> */
public function getRecentlyPlayed(User $user, ?int $count = null): array
{
return $this->model
->newQuery()
->where('user_id', $user->id)
->where('play_count', '>', 0)
->latest('last_played_at')
->when($count, static fn (Builder $query, int $count) => $query->take($count))
->pluck('song_id')
->all();
}
}

View file

@ -2,9 +2,6 @@
namespace App\Repositories;
use App\Repositories\Traits\ByCurrentUser;
class PlaylistFolderRepository extends Repository
{
use ByCurrentUser;
}

View file

@ -1,18 +0,0 @@
<?php
namespace App\Repositories;
use App\Models\Playlist;
use App\Repositories\Traits\ByCurrentUser;
use Illuminate\Support\Collection;
class PlaylistRepository extends Repository
{
use ByCurrentUser;
/** @return Collection|array<Playlist> */
public function getAllByCurrentUser(): Collection
{
return $this->byCurrentUser()->orderBy('name')->get();
}
}

View file

@ -7,36 +7,12 @@ use App\Models\Artist;
use App\Models\Playlist;
use App\Models\Song;
use App\Models\User;
use App\Repositories\Traits\Searchable;
use App\Values\Genre;
use Illuminate\Contracts\Database\Query\Builder;
use Illuminate\Contracts\Pagination\Paginator;
use Illuminate\Support\Collection;
use Webmozart\Assert\Assert;
class SongRepository extends Repository
{
use Searchable;
public const SORT_COLUMNS_NORMALIZE_MAP = [
'title' => 'songs.title',
'track' => 'songs.track',
'length' => 'songs.length',
'created_at' => 'songs.created_at',
'disc' => 'songs.disc',
'artist_name' => 'artists.name',
'album_name' => 'albums.name',
];
private const VALID_SORT_COLUMNS = [
'songs.title',
'songs.track',
'songs.length',
'songs.created_at',
'artists.name',
'albums.name',
];
private const DEFAULT_QUEUE_LIMIT = 500;
public function getOneByPath(string $path): ?Song
@ -53,14 +29,26 @@ class SongRepository extends Repository
/** @return Collection|array<array-key, Song> */
public function getRecentlyAdded(int $count = 10, ?User $scopedUser = null): Collection
{
return Song::query()->withMeta($scopedUser ?? $this->auth->user())->latest()->limit($count)->get();
/** @var User $scopedUser */
$scopedUser ??= $this->auth->user();
return Song::query()
->accessibleBy($scopedUser)
->withMetaFor($scopedUser)
->latest()
->limit($count)
->get();
}
/** @return Collection|array<array-key, Song> */
public function getMostPlayed(int $count = 7, ?User $scopedUser = null): Collection
{
/** @var User $scopedUser */
$scopedUser ??= $this->auth->user();
return Song::query()
->withMeta($scopedUser ?? $this->auth->user())
->accessibleBy($scopedUser)
->withMetaFor($scopedUser, requiresInteractions: true)
->where('interactions.play_count', '>', 0)
->orderByDesc('interactions.play_count')
->limit($count)
@ -70,8 +58,12 @@ class SongRepository extends Repository
/** @return Collection|array<array-key, Song> */
public function getRecentlyPlayed(int $count = 7, ?User $scopedUser = null): Collection
{
/** @var User $scopedUser */
$scopedUser ??= $this->auth->user();
return Song::query()
->withMeta($scopedUser ?? $this->auth->user())
->accessibleBy($scopedUser)
->withMetaFor($scopedUser, requiresInteractions: true)
->orderByDesc('interactions.last_played_at')
->limit($count)
->get();
@ -83,11 +75,13 @@ class SongRepository extends Repository
?User $scopedUser = null,
int $perPage = 50
): Paginator {
return self::applySort(
Song::query()->withMeta($scopedUser ?? $this->auth->user()),
$sortColumn,
$sortDirection
)
/** @var User $scopedUser */
$scopedUser ??= $this->auth->user();
return Song::query()
->accessibleBy($scopedUser)
->withMetaFor($scopedUser)
->sort($sortColumn, $sortDirection)
->simplePaginate($perPage);
}
@ -98,13 +92,14 @@ class SongRepository extends Repository
?User $scopedUser = null,
int $perPage = 50
): Paginator {
return self::applySort(
Song::query()
->withMeta($scopedUser ?? $this->auth->user())
->where('genre', $genre),
$sortColumn,
$sortDirection
)
/** @var User $scopedUser */
$scopedUser ??= $this->auth->user();
return Song::query()
->accessibleBy($scopedUser)
->withMetaFor($scopedUser)
->where('genre', $genre)
->sort($sortColumn, $sortDirection)
->simplePaginate($perPage);
}
@ -115,26 +110,39 @@ class SongRepository extends Repository
int $limit = self::DEFAULT_QUEUE_LIMIT,
?User $scopedUser = null,
): Collection {
return self::applySort(
Song::query()->withMeta($scopedUser ?? $this->auth->user()),
$sortColumn,
$sortDirection
)
->limit($limit)
->get();
/** @var User $scopedUser */
$scopedUser ??= $this->auth->user();
return Song::query()
->accessibleBy($scopedUser)
->withMetaFor($scopedUser)
->sort($sortColumn, $sortDirection)
->limit($limit)
->get();
}
/** @return Collection|array<array-key, Song> */
public function getFavorites(?User $scopedUser = null): Collection
{
return Song::query()->withMeta($scopedUser ?? $this->auth->user())->where('interactions.liked', true)->get();
/** @var User $scopedUser */
$scopedUser ??= $this->auth->user();
return Song::query()
->accessibleBy($scopedUser)
->withMetaFor($scopedUser)
->where('interactions.liked', true)
->get();
}
/** @return Collection|array<array-key, Song> */
public function getByAlbum(Album $album, ?User $scopedUser = null): Collection
{
/** @var User $scopedUser */
$scopedUser ??= $this->auth->user();
return Song::query()
->withMeta($scopedUser ?? $this->auth->user())
->accessibleBy($scopedUser)
->withMetaFor($scopedUser)
->where('album_id', $album->id)
->orderBy('songs.disc')
->orderBy('songs.track')
@ -145,8 +153,12 @@ class SongRepository extends Repository
/** @return Collection|array<array-key, Song> */
public function getByArtist(Artist $artist, ?User $scopedUser = null): Collection
{
/** @var User $scopedUser */
$scopedUser ??= $this->auth->user();
return Song::query()
->withMeta($scopedUser ?? $this->auth->user())
->accessibleBy($scopedUser)
->withMetaFor($scopedUser)
->where('songs.artist_id', $artist->id)
->orWhere('albums.artist_id', $artist->id)
->orderBy('albums.name')
@ -159,8 +171,12 @@ class SongRepository extends Repository
/** @return Collection|array<array-key, Song> */
public function getByStandardPlaylist(Playlist $playlist, ?User $scopedUser = null): Collection
{
/** @var User $scopedUser */
$scopedUser ??= $this->auth->user();
return Song::query()
->withMeta($scopedUser ?? $this->auth->user())
->accessibleBy($scopedUser)
->withMetaFor($scopedUser)
->leftJoin('playlist_song', 'songs.id', '=', 'playlist_song.song_id')
->leftJoin('playlists', 'playlists.id', '=', 'playlist_song.playlist_id')
->where('playlists.id', $playlist->id)
@ -171,14 +187,26 @@ class SongRepository extends Repository
/** @return Collection|array<array-key, Song> */
public function getRandom(int $limit, ?User $scopedUser = null): Collection
{
return Song::query()->withMeta($scopedUser ?? $this->auth->user())->inRandomOrder()->limit($limit)->get();
/** @var User $scopedUser */
$scopedUser ??= $this->auth->user();
return Song::query()
->accessibleBy($scopedUser)
->withMetaFor($scopedUser)
->inRandomOrder()
->limit($limit)
->get();
}
/** @return Collection|array<array-key, Song> */
public function getMany(array $ids, bool $inThatOrder = false, ?User $scopedUser = null): Collection
{
/** @var User $scopedUser */
$scopedUser ??= $this->auth->user();
$songs = Song::query()
->withMeta($scopedUser ?? $this->auth->user())
->accessibleBy($scopedUser)
->withMetaFor($scopedUser)
->whereIn('songs.id', $ids)
->get();
@ -187,63 +215,45 @@ class SongRepository extends Repository
public function getOne($id, ?User $scopedUser = null): Song
{
return Song::query()->withMeta($scopedUser ?? $this->auth->user())->findOrFail($id);
/** @var User $scopedUser */
$scopedUser ??= $this->auth->user();
return Song::query()
->accessibleBy($scopedUser)
->withMetaFor($scopedUser)
->findOrFail($id);
}
public function findOne($id, ?User $scopedUser = null): ?Song
{
return Song::query()->withMeta($scopedUser ?? $this->auth->user())->find($id);
/** @var User $scopedUser */
$scopedUser ??= $this->auth->user();
return Song::query()
->accessibleBy($scopedUser)
->withMetaFor($scopedUser ?? $this->auth->user())
->find($id);
}
public function count(): int
public function count(?User $scopedUser = null): int
{
return Song::query()->count();
return Song::query()->accessibleBy($scopedUser ?? auth()->user())->count();
}
public function getTotalLength(): float
public function getTotalLength(?User $scopedUser = null): float
{
return Song::query()->sum('length');
}
private static function normalizeSortColumn(string $column): string
{
return key_exists($column, self::SORT_COLUMNS_NORMALIZE_MAP)
? self::SORT_COLUMNS_NORMALIZE_MAP[$column]
: $column;
}
private static function applySort(Builder $query, string $column, string $direction): Builder
{
$column = self::normalizeSortColumn($column);
Assert::oneOf($column, self::VALID_SORT_COLUMNS);
Assert::oneOf(strtolower($direction), ['asc', 'desc']);
$query->orderBy($column, $direction);
if ($column === 'artists.name') {
$query->orderBy('albums.name')
->orderBy('songs.disc')
->orderBy('songs.track')
->orderBy('songs.title');
} elseif ($column === 'albums.name') {
$query->orderBy('artists.name')
->orderBy('songs.disc')
->orderBy('songs.track')
->orderBy('songs.title');
} elseif ($column === 'track') {
$query->orderBy('song.disc')
->orderBy('songs.track');
}
return $query;
return Song::query()->accessibleBy($scopedUser ?? auth()->user())->sum('length');
}
/** @return Collection|array<array-key, Song> */
public function getRandomByGenre(string $genre, int $limit, ?User $scopedUser = null): Collection
{
/** @var User $scopedUser */
$scopedUser ??= $this->auth->user();
return Song::query()
->withMeta($scopedUser ?? $this->auth->user())
->accessibleBy($scopedUser)
->withMetaFor($scopedUser)
->where('genre', $genre === Genre::NO_GENRE ? '' : $genre)
->limit($limit)
->inRandomOrder()

View file

@ -1,21 +0,0 @@
<?php
namespace App\Repositories\Traits;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Collection;
trait ByCurrentUser
{
private function byCurrentUser(): Builder
{
return $this->model->whereUserId($this->auth->id());
}
/** @return Collection|array<Model> */
public function getAllByCurrentUser(): Collection
{
return $this->byCurrentUser()->get();
}
}

View file

@ -1,13 +0,0 @@
<?php
namespace App\Repositories\Traits;
use Laravel\Scout\Builder;
trait Searchable
{
public function search(string $keywords): Builder
{
return forward_static_call([$this->getModelClass(), 'search'], $keywords);
}
}

View file

@ -0,0 +1,11 @@
<?php
namespace App\Services;
class CommunityLicenseService extends LicenseService
{
public function isPlus(): bool
{
return false;
}
}

View file

@ -0,0 +1,17 @@
<?php
namespace App\Services;
class LicenseService
{
public function isPlus(): bool
{
// @todo Implement checking for Plus license
return true;
}
public function isCommunity(): bool
{
return !$this->isPlus();
}
}

View file

@ -51,7 +51,7 @@ class SearchService
): Collection {
return Song::search($keywords)
->query(static function (SongBuilder $builder) use ($scopedUser, $limit): void {
$builder->withMeta($scopedUser ?? auth()->user())->limit($limit);
$builder->withMetaFor($scopedUser ?? auth()->user())->limit($limit);
})
->get();
}

View file

@ -19,7 +19,7 @@ class SmartPlaylistService
{
throw_unless($playlist->is_smart, NonSmartPlaylistException::create($playlist));
$query = Song::query()->withMeta($user ?? $playlist->user);
$query = Song::query()->withMetaFor($user ?? $playlist->user);
$playlist->rule_groups->each(static function (RuleGroup $group, int $index) use ($query): void {
$clause = $index === 0 ? 'where' : 'orWhere';

View file

@ -146,6 +146,7 @@ return [
App\Providers\StreamerServiceProvider::class,
App\Providers\ObjectStorageServiceProvider::class,
App\Providers\MacroProvider::class,
App\Providers\LicenseServiceProvider::class,
],
/*
@ -199,5 +200,6 @@ return [
'YouTube' => App\Facades\YouTube::class,
'Download' => App\Facades\Download::class,
'ITunes' => App\Facades\ITunes::class,
'License' => App\Facades\License::class,
],
];

View file

@ -0,0 +1,35 @@
<?php
use App\Models\Song;
use App\Models\User;
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::table('songs', static function (Blueprint $table): void {
$table->unsignedInteger('owner_id')->nullable();
$table->foreign('owner_id')->references('id')->on('users')->cascadeOnUpdate()->cascadeOnDelete();
$table->boolean('is_public')->default(false)->index();
});
Schema::table('songs', static function (Blueprint $table): void {
/** @var ?User $firstAdmin */
$firstAdmin = User::query()->where('is_admin', true)->oldest()->first();
if ($firstAdmin === null) {
return;
}
// make sure all existing songs are accessible by all users and assuming the first admin "owns" them
Song::query()->update([
'is_public' => true,
'owner_id' => $firstAdmin->id,
]);
$table->unsignedInteger('owner_id')->nullable(false)->change();
});
}
};

View file

@ -1,14 +1,14 @@
<?php
use App\Facades\ITunes;
use App\Http\Controllers\Download\AlbumController as AlbumDownloadController;
use App\Http\Controllers\Download\ArtistController as ArtistDownloadController;
use App\Http\Controllers\Download\FavoritesController as FavoritesDownloadController;
use App\Http\Controllers\Download\PlaylistController as PlaylistDownloadController;
use App\Http\Controllers\Download\SongController as SongDownloadController;
use App\Http\Controllers\ITunesController;
use App\Http\Controllers\Download\DownloadAlbumController;
use App\Http\Controllers\Download\DownloadArtistController;
use App\Http\Controllers\Download\DownloadFavoritesController;
use App\Http\Controllers\Download\DownloadPlaylistController;
use App\Http\Controllers\Download\DownloadSongsController;
use App\Http\Controllers\LastfmController;
use App\Http\Controllers\PlayController;
use App\Http\Controllers\ViewSongOnITunesController;
use Illuminate\Support\Facades\Route;
Route::middleware('web')->group(static function (): void {
@ -23,19 +23,19 @@ Route::middleware('web')->group(static function (): void {
});
if (ITunes::used()) {
Route::get('itunes/song/{album}', [ITunesController::class, 'viewSong'])->name('iTunes.viewSong');
Route::get('itunes/song/{album}', ViewSongOnITunesController::class)->name('iTunes.viewSong');
}
});
Route::middleware('audio.auth')->group(static function (): void {
Route::get('play/{song}/{transcode?}/{bitrate?}', [PlayController::class, 'show'])->name('song.play');
Route::get('play/{song}/{transcode?}/{bitrate?}', PlayController::class)->name('song.play');
Route::prefix('download')->group(static function (): void {
Route::get('songs', [SongDownloadController::class, 'show']);
Route::get('album/{album}', [AlbumDownloadController::class, 'show']);
Route::get('artist/{artist}', [ArtistDownloadController::class, 'show']);
Route::get('playlist/{playlist}', [PlaylistDownloadController::class, 'show']);
Route::get('favorites', [FavoritesDownloadController::class, 'show']);
Route::get('songs', DownloadSongsController::class);
Route::get('album/{album}', DownloadAlbumController::class);
Route::get('artist/{artist}', DownloadArtistController::class);
Route::get('playlist/{playlist}', DownloadPlaylistController::class);
Route::get('favorites', DownloadFavoritesController::class);
});
});
});

View file

@ -2,9 +2,11 @@
namespace Tests;
use App\Facades\License;
use App\Models\Album;
use App\Models\Artist;
use App\Models\Song;
use App\Services\CommunityLicenseService;
use DMS\PHPUnitExtensions\ArraySubset\ArraySubsetAsserts;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
@ -24,6 +26,8 @@ abstract class TestCase extends BaseTestCase
{
parent::setUp();
License::swap($this->app->make(CommunityLicenseService::class));
TestResponse::macro('log', function (string $file = 'test-response.json'): TestResponse {
/** @var TestResponse $this */
file_put_contents(storage_path('logs/' . $file), $this->getContent());