<?php
namespace App\Repositories;
use App\Builders\SongBuilder;
use App\Enums\EmbeddableType;
use App\Enums\PlayableType;
use App\Exceptions\EmbeddableNotFoundException;
use App\Exceptions\NonSmartPlaylistException;
use App\Facades\License;
use App\Models\Album;
use App\Models\Artist;
use App\Models\Embed;
use App\Models\Folder;
use App\Models\Genre;
use App\Models\Playlist;
use App\Models\Podcast;
use App\Models\Song;
use App\Models\User;
use App\Repositories\Contracts\ScoutableRepository;
use App\Values\SmartPlaylist\SmartPlaylistQueryModifier as QueryModifier;
use App\Values\SmartPlaylist\SmartPlaylistRule as Rule;
use App\Values\SmartPlaylist\SmartPlaylistRuleGroup as RuleGroup;
use Illuminate\Contracts\Pagination\Paginator;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use LogicException;
/**
* @extends Repository<Song>
* @implements ScoutableRepository<Song>
*/
class SongRepository extends Repository implements ScoutableRepository
{
private const LIST_SIZE_LIMIT = 500;
public function __construct(private readonly FolderRepository $folderRepository)
{
parent::__construct();
}
public function findOneByPath(string $path): ?Song
{
return Song::query()->where('path', $path)->first();
}
/** @return Collection|array<array-key, Song> */
public function getAllStoredOnCloud(): Collection
{
return Song::query()->storedOnCloud()->get();
}
/** @return Collection|array<array-key, Song> */
public function getRecentlyAdded(int $count = 8, ?User $scopedUser = null): Collection
{
return Song::query(type: PlayableType::SONG, user: $scopedUser ?? $this->auth->user())
->withUserContext()
->latest()
->limit($count)
->get();
}
/** @return Collection|array<array-key, Song> */
public function getMostPlayed(int $count = 8, ?User $scopedUser = null): Collection
{
return Song::query(user: $scopedUser ?? $this->auth->user())
->withUserContext()
->where('interactions.play_count', '>', 0)
->orderByDesc('interactions.play_count')
->limit($count)
->get();
}
/** @return Collection|array<array-key, Song> */
public function getRecentlyPlayed(int $count = 8, ?User $scopedUser = null): Collection
{
return Song::query(user: $scopedUser ?? $this->auth->user())
->withUserContext()
->where('interactions.play_count', '>', 0)
->addSelect('interactions.last_played_at')
->orderByDesc('interactions.last_played_at')
->limit($count)
->get();
}
public function paginate(
array $sortColumns,
string $sortDirection,
?User $scopedUser = null,
int $perPage = 50
): Paginator {
$scopedUser ??= $this->auth->user();
return Song::query(type: PlayableType::SONG, user: $scopedUser)
->withUserContext()
->sort($sortColumns, $sortDirection)
->simplePaginate($perPage);
}
/**
* @param Genre|null $genre If null, paginate songs that have no genre
*/
public function paginateByGenre(
?Genre $genre,
array $sortColumns,
string $sortDirection,
?User $scopedUser = null,
int $perPage = 50
): Paginator {
return Song::query(type: PlayableType::SONG, user: $scopedUser ?? $this->auth->user())
->withUserContext()
->when($genre, static fn (Builder $builder) => $builder->whereRelation('genres', 'genres.id', $genre->id))
->when(!$genre, static fn (Builder $builder) => $builder->whereDoesntHave('genres'))
->sort($sortColumns, $sortDirection)
->simplePaginate($perPage);
}
/** @return Collection|array<array-key, Song> */
public function getForQueue(
array $sortColumns,
string $sortDirection,
int $limit = self::LIST_SIZE_LIMIT,
?User $scopedUser = null,
): Collection {
return Song::query(type: PlayableType::SONG, user: $scopedUser ?? $this->auth->user())
->withUserContext()
->sort($sortColumns, $sortDirection)
->limit($limit)
->get();
}
/** @return Collection|array<array-key, Song> */
public function getFavorites(?User $scopedUser = null): Collection
{
return Song::query(user: $scopedUser ?? $this->auth->user())
->withUserContext(favoritesOnly: true)
->get();
}
/** @return Collection|array<array-key, Song> */
public function getByAlbum(Album $album, ?User $scopedUser = null): Collection
{
return Song::query(user: $scopedUser ?? $this->auth->user())
->withUserContext()
->whereBelongsTo($album)
->orderBy('songs.disc')
->orderBy('songs.track')
->orderBy('songs.title')
->get();
}
public function paginateInFolder(?Folder $folder = null, ?User $scopedUser = null): Paginator
{
return Song::query(type: PlayableType::SONG, user: $scopedUser ?? $this->auth->user())
->withUserContext()
->storedLocally()
->when($folder, static fn (SongBuilder $query) => $query->where('folder_id', $folder->id)) // @phpstan-ignore-line
->when(!$folder, static fn (SongBuilder $query) => $query->whereNull('folder_id'))
->orderBy('songs.path')
->simplePaginate(50);
}
/** @return Collection|array<array-key, Song> */
public function getByArtist(Artist $artist, ?User $scopedUser = null): Collection
{
return Song::query(type: PlayableType::SONG, user: $scopedUser ?? $this->auth->user())
->withUserContext()
->where(static function (SongBuilder $query) use ($artist): void {
$query->whereBelongsTo($artist)
->orWhereHas('album', static function (Builder $albumQuery) use ($artist): void {
$albumQuery->whereBelongsTo($artist);
});
})
->orderBy('songs.album_name')
->orderBy('songs.disc')
->orderBy('songs.track')
->orderBy('songs.title')
->get();
}
/** @return Collection|array<array-key, Song> */
public function getByPlaylist(Playlist $playlist, ?User $scopedUser = null): Collection
{
if ($playlist->is_smart) {
return $this->getBySmartPlaylist($playlist, $scopedUser);
} else {
return $this->getByStandardPlaylist($playlist, $scopedUser);
}
}
/** @return Collection|array<array-key, Song> */
private function getByStandardPlaylist(Playlist $playlist, ?User $scopedUser = null): Collection
{
throw_if($playlist->is_smart, new LogicException('Not a standard playlist.'));
return Song::query(user: $scopedUser ?? $this->auth->user())
->withUserContext()
->leftJoin('playlist_song', 'songs.id', '=', 'playlist_song.song_id')
->leftJoin('playlists', 'playlists.id', '=', 'playlist_song.playlist_id')
->when(License::isPlus(), static function (SongBuilder $query): SongBuilder {
return
$query->join('users as collaborators', 'playlist_song.user_id', '=', 'collaborators.id')
->addSelect(
'collaborators.public_id as collaborator_public_id',
'collaborators.name as collaborator_name',
'collaborators.email as collaborator_email',
'collaborators.avatar as collaborator_avatar',
'playlist_song.created_at as added_at'
);
})
->where('playlists.id', $playlist->id)
->orderBy('playlist_song.position')
->get();
}
/** @return Collection|array<array-key, Song> */
private function getBySmartPlaylist(Playlist $playlist, ?User $scopedUser = null): Collection
{
throw_unless($playlist->is_smart, NonSmartPlaylistException::create($playlist));
$query = Song::query(type: PlayableType::SONG, user: $scopedUser)->withUserContext();
$playlist->rule_groups->each(static function (RuleGroup $group, int $index) use ($query): void {
$whereClosure = static function (SongBuilder $subQuery) use ($group): void {
$group->rules->each(static function (Rule $rule) use ($subQuery): void {
QueryModifier::applyRule($rule, $subQuery);
});
};
$query->when(
$index === 0,
static fn (SongBuilder $query) => $query->where($whereClosure),
static fn (SongBuilder $query) => $query->orWhere($whereClosure)
);
});
return $query->orderBy('songs.title')
->limit(self::LIST_SIZE_LIMIT)
->get();
}
/** @return Collection|array<array-key, Song> */
public function getRandom(int $limit, ?User $scopedUser = null): Collection
{
return Song::query(type: PlayableType::SONG, user: $scopedUser ?? $this->auth->user())
->withUserContext()
->inRandomOrder()
->limit($limit)
->get();
}
/** @return Collection|array<array-key, Song> */
public function getMany(array $ids, bool $preserveOrder = false, ?User $scopedUser = null): Collection
{
$songs = Song::query(user: $scopedUser ?? $this->auth->user())
->withUserContext()
->whereIn('songs.id', $ids)
->get();
return $preserveOrder ? $songs->orderByArray($ids) : $songs; // @phpstan-ignore-line
}
/** @param array<string> $ids */
public function countAccessibleByIds(array $ids, ?User $scopedUser = null): int
{
return Song::query(user: $scopedUser ?? $this->auth->user())
->accessible()
->whereIn('songs.id', $ids)
->count();
}
/**
* Gets several songs, but also includes collaborative information.
*
* @return Collection|array<array-key, Song>
*/
public function getManyInCollaborativeContext(array $ids, ?User $scopedUser = null): Collection
{
return Song::query(user: $scopedUser ?? $this->auth->user())
->withUserContext()
->when(License::isPlus(), static function (SongBuilder $query): SongBuilder {
return
$query->leftJoin('playlist_song', 'songs.id', '=', 'playlist_song.song_id')
->leftJoin('playlists', 'playlists.id', '=', 'playlist_song.playlist_id')
->join('users as collaborators', 'playlist_song.user_id', '=', 'collaborators.id')
->addSelect(
'collaborators.public_id as collaborator_public_id',
'collaborators.name as collaborator_name',
'collaborators.email as collaborator_email',
'playlist_song.created_at as added_at'
);
})
->whereIn('songs.id', $ids)
->get();
}
/** @param string $id */
public function getOne($id, ?User $user = null): Song
{
return Song::query(user: $user ?? $this->auth->user())
->withUserContext()
->findOrFail($id);
}
/** @param string $id */
public function findOne($id, ?User $user = null): ?Song
{
return Song::query(user: $user ?? $this->auth->user())
->withUserContext()
->find($id);
}
public function countSongs(?User $scopedUser = null): int
{
return Song::query(type: PlayableType::SONG, user: $scopedUser ?? $this->auth->user())
->accessible()
->count();
}
public function getTotalSongLength(?User $scopedUser = null): float
{
return Song::query(type: PlayableType::SONG, user: $scopedUser ?? $this->auth->user())
->accessible()
->sum('length');
}
/**
* @param Genre|null $genre If null, query songs that have no genre.
*
* @return Collection|array<array-key, Song>
*/
public function getByGenre(?Genre $genre, int $limit, $random = false, ?User $scopedUser = null): Collection
{
return Song::query(type: PlayableType::SONG, user: $scopedUser ?? $this->auth->user())
->withUserContext()
->when($genre, static fn (Builder $builder) => $builder->whereRelation('genres', 'genres.id', $genre->id))
->when(!$genre, static fn (Builder $builder) => $builder->whereDoesntHave('genres'))
->when($random, static fn (Builder $builder) => $builder->inRandomOrder())
->when(!$random, static fn (Builder $builder) => $builder->orderBy('songs.title'))
->limit($limit)
->get();
}
/** @return array<string> */
public function getEpisodeGuidsByPodcast(Podcast $podcast): array
{
return $podcast->episodes()->pluck('episode_guid')->toArray();
}
/** @return Collection<Song>|array<array-key, Song> */
public function getEpisodesByPodcast(Podcast $podcast, ?User $user = null): Collection
{
return Song::query(user: $user ?? $this->auth->user())
->withUserContext()
->whereBelongsTo($podcast)
->orderByDesc('songs.created_at')
->get();
}
/** @return Collection<Song>|array<array-key, Song> */
public function getUnderPaths(
array $paths,
int $limit = 500,
bool $random = false,
?User $scopedUser = null
): Collection {
$paths = array_map(static fn (?string $path) => $path ? trim($path, DIRECTORY_SEPARATOR) : '', $paths);
if (!$paths) {
return Collection::empty();
}
$hasRootPath = in_array('', $paths, true);
$scopedUser ??= $this->auth->user();
return Song::query(type: PlayableType::SONG, user: $scopedUser)
->withUserContext()
// if the root path is included, we don't need to filter by folder
->when(!$hasRootPath, function (SongBuilder $query) use ($paths, $scopedUser): void {
$folders = $this->folderRepository->getByPaths($paths, $scopedUser);
$query->whereIn('songs.folder_id', $folders->pluck('id'));
})
->when(!$random, static fn (SongBuilder $query): SongBuilder => $query->orderBy('songs.path'))
->when($random, static fn (SongBuilder $query): SongBuilder => $query->inRandomOrder())
->limit($limit)
->get();
}
/**
* Fetch songs **directly** in a specific folder (or the media root if null).
* This does not include songs in subfolders.
*
* @return Collection<Song>|array<array-key, Song>
*/
public function getInFolder(?Folder $folder = null, int $limit = 500, ?User $scopedUser = null): Collection
{
return Song::query(type: PlayableType::SONG, user: $scopedUser ?? $this->auth->user())
->withUserContext()
->limit($limit)
->when($folder, static fn (SongBuilder $query) => $query->where('songs.folder_id', $folder->id)) // @phpstan-ignore-line
->when(!$folder, static fn (SongBuilder $query) => $query->whereNull('songs.folder_id'))
->orderBy('songs.path')
->get();
}
/** @return Collection<Song>|array<array-key, Song> */
public function search(string $keywords, int $limit, ?User $user = null): Collection
{
return $this->getMany(
ids: Song::search($keywords)->get()->take($limit)->modelKeys(),
preserveOrder: true,
scopedUser: $user,
);
}
/** @return Collection<Song>|array<array-key, Song> */
public function getForEmbed(Embed $embed): Collection
{
throw_unless((bool) $embed->embeddable, new EmbeddableNotFoundException());
return match (EmbeddableType::from($embed->embeddable_type)) {
EmbeddableType::ALBUM => $this->getByAlbum($embed->embeddable, $embed->user),
EmbeddableType::ARTIST => $this->getByArtist($embed->embeddable, $embed->user),
EmbeddableType::PLAYLIST => $this->getByPlaylist($embed->embeddable, $embed->user),
EmbeddableType::PLAYABLE => $this->getMany(ids: [$embed->embeddable->getKey()], scopedUser: $embed->user),
};
}
}