mirror of
https://github.com/koel/koel
synced 2024-11-10 06:34:14 +00:00
parent
ed6f01ad52
commit
5f0eaf228d
35 changed files with 679 additions and 91 deletions
|
@ -5,6 +5,7 @@ namespace App\Http\Controllers\API;
|
|||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Resources\PlaylistFolderResource;
|
||||
use App\Http\Resources\PlaylistResource;
|
||||
use App\Http\Resources\QueueStateResource;
|
||||
use App\Http\Resources\UserResource;
|
||||
use App\Models\User;
|
||||
use App\Repositories\SettingRepository;
|
||||
|
@ -12,6 +13,7 @@ use App\Repositories\SongRepository;
|
|||
use App\Services\ApplicationInformationService;
|
||||
use App\Services\ITunesService;
|
||||
use App\Services\LastfmService;
|
||||
use App\Services\QueueService;
|
||||
use App\Services\SpotifyService;
|
||||
use App\Services\YouTubeService;
|
||||
use Illuminate\Contracts\Auth\Authenticatable;
|
||||
|
@ -24,6 +26,7 @@ class DataController extends Controller
|
|||
private SettingRepository $settingRepository,
|
||||
private SongRepository $songRepository,
|
||||
private ApplicationInformationService $applicationInformationService,
|
||||
private QueueService $queueService,
|
||||
private ?Authenticatable $user
|
||||
) {
|
||||
}
|
||||
|
@ -49,6 +52,7 @@ class DataController extends Controller
|
|||
: koel_version(),
|
||||
'song_count' => $this->songRepository->count(),
|
||||
'song_length' => $this->songRepository->getTotalLength(),
|
||||
'queue_state' => QueueStateResource::make($this->queueService->getQueueState($this->user)),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,16 +4,42 @@ namespace App\Http\Controllers\API;
|
|||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\API\QueueFetchSongRequest;
|
||||
use App\Http\Requests\API\UpdatePlaybackStatusRequest;
|
||||
use App\Http\Requests\API\UpdateQueueStateRequest;
|
||||
use App\Http\Resources\QueueStateResource;
|
||||
use App\Http\Resources\SongResource;
|
||||
use App\Models\User;
|
||||
use App\Repositories\SongRepository;
|
||||
use App\Services\QueueService;
|
||||
use Illuminate\Contracts\Auth\Authenticatable;
|
||||
|
||||
class QueueController extends Controller
|
||||
{
|
||||
/** @param User $user */
|
||||
public function __construct(private SongRepository $songRepository, private ?Authenticatable $user)
|
||||
public function __construct(
|
||||
private SongRepository $songRepository,
|
||||
private QueueService $queueService,
|
||||
private ?Authenticatable $user
|
||||
) {
|
||||
}
|
||||
|
||||
public function getState()
|
||||
{
|
||||
return QueueStateResource::make($this->queueService->getQueueState($this->user));
|
||||
}
|
||||
|
||||
public function updateState(UpdateQueueStateRequest $request)
|
||||
{
|
||||
$this->queueService->updateQueueState($this->user, $request->songs);
|
||||
|
||||
return response()->noContent();
|
||||
}
|
||||
|
||||
public function updatePlaybackStatus(UpdatePlaybackStatusRequest $request)
|
||||
{
|
||||
$this->queueService->updatePlaybackStatus($this->user, $request->song, $request->position);
|
||||
|
||||
return response()->noContent();
|
||||
}
|
||||
|
||||
public function fetchSongs(QueueFetchSongRequest $request)
|
||||
|
|
22
app/Http/Requests/API/UpdatePlaybackStatusRequest.php
Normal file
22
app/Http/Requests/API/UpdatePlaybackStatusRequest.php
Normal file
|
@ -0,0 +1,22 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Requests\API;
|
||||
|
||||
use App\Models\Song;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
/**
|
||||
* @property-read string $song
|
||||
* @property-read int $position
|
||||
*/
|
||||
class UpdatePlaybackStatusRequest extends Request
|
||||
{
|
||||
/** @return array<mixed> */
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'song' => [Rule::exists(Song::class, 'id')],
|
||||
'position' => 'required|integer',
|
||||
];
|
||||
}
|
||||
}
|
21
app/Http/Requests/API/UpdateQueueStateRequest.php
Normal file
21
app/Http/Requests/API/UpdateQueueStateRequest.php
Normal file
|
@ -0,0 +1,21 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Requests\API;
|
||||
|
||||
use App\Models\Song;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
/**
|
||||
* @property-read array<string> $songs
|
||||
*/
|
||||
class UpdateQueueStateRequest extends Request
|
||||
{
|
||||
/** @return array<mixed> */
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'songs' => 'array',
|
||||
'songs.*' => [Rule::exists(Song::class, 'id')],
|
||||
];
|
||||
}
|
||||
}
|
25
app/Http/Resources/QueueStateResource.php
Normal file
25
app/Http/Resources/QueueStateResource.php
Normal file
|
@ -0,0 +1,25 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use App\Values\QueueState;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class QueueStateResource extends JsonResource
|
||||
{
|
||||
public function __construct(private QueueState $state)
|
||||
{
|
||||
parent::__construct($state);
|
||||
}
|
||||
|
||||
/** @return array<mixed> */
|
||||
public function toArray($request): array
|
||||
{
|
||||
return [
|
||||
'type' => 'queue-states',
|
||||
'songs' => SongResource::collection($this->state->songs),
|
||||
'current_song' => $this->state->currentSong ? new SongResource($this->state->currentSong) : null,
|
||||
'playback_position' => $this->state->playbackPosition,
|
||||
];
|
||||
}
|
||||
}
|
30
app/Models/QueueState.php
Normal file
30
app/Models/QueueState.php
Normal file
|
@ -0,0 +1,30 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* @property array<string> $song_ids
|
||||
* @property ?string $current_song_id
|
||||
* @property int $playback_position
|
||||
* @property User $user
|
||||
*/
|
||||
class QueueState extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $guarded = ['id'];
|
||||
|
||||
protected $casts = [
|
||||
'song_ids' => 'array',
|
||||
'playback_position' => 'int',
|
||||
];
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
|
@ -2,20 +2,16 @@
|
|||
|
||||
namespace App\Providers;
|
||||
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class MacroProvider extends ServiceProvider
|
||||
{
|
||||
public function boot(): void
|
||||
{
|
||||
Builder::macro('integerCastType', function (): string { // @phpcs:ignore
|
||||
return match (DB::getDriverName()) {
|
||||
'mysql' => 'UNSIGNED', // only newer versions of MySQL support "INTEGER"
|
||||
'sqlsrv' => 'INT',
|
||||
default => 'INTEGER',
|
||||
};
|
||||
Collection::macro('orderByArray', function (array $orderBy, string $key = 'id'): Collection {
|
||||
/** @var Collection $this */
|
||||
return $this->sortBy(static fn ($item) => array_search($item->$key, $orderBy, true));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -45,14 +45,6 @@ class AlbumRepository extends Repository
|
|||
->get('albums.*');
|
||||
}
|
||||
|
||||
/** @return Collection|array<array-key, Album> */
|
||||
public function getByIds(array $ids): Collection
|
||||
{
|
||||
return Album::query()
|
||||
->whereIn('id', $ids)
|
||||
->get();
|
||||
}
|
||||
|
||||
/** @return Collection|array<array-key, Album> */
|
||||
public function getByArtist(Artist $artist): Collection
|
||||
{
|
||||
|
|
|
@ -43,12 +43,14 @@ class ArtistRepository extends Repository
|
|||
}
|
||||
|
||||
/** @return Collection|array<array-key, Artist> */
|
||||
public function getByIds(array $ids): Collection
|
||||
public function getByIds(array $ids, bool $inThatOrder = false): Collection
|
||||
{
|
||||
return Artist::query()
|
||||
$artists = Artist::query()
|
||||
->isStandard()
|
||||
->whereIn('id', $ids)
|
||||
->get();
|
||||
|
||||
return $inThatOrder ? $artists->orderByArray($ids) : $artists;
|
||||
}
|
||||
|
||||
public function paginate(): Paginator
|
||||
|
|
|
@ -33,9 +33,11 @@ abstract class Repository implements RepositoryInterface
|
|||
}
|
||||
|
||||
/** @return Collection|array<Model> */
|
||||
public function getByIds(array $ids): Collection
|
||||
public function getByIds(array $ids, bool $inThatOrder = false): Collection
|
||||
{
|
||||
return $this->model->find($ids);
|
||||
$models = $this->model::query()->find($ids);
|
||||
|
||||
return $inThatOrder ? $models->orderByArray($ids) : $models;
|
||||
}
|
||||
|
||||
/** @return Collection|array<Model> */
|
||||
|
|
|
@ -10,7 +10,7 @@ interface RepositoryInterface
|
|||
public function getOneById($id): ?Model;
|
||||
|
||||
/** @return Collection|array<Model> */
|
||||
public function getByIds(array $ids): Collection;
|
||||
public function getByIds(array $ids, bool $inThatOrder = false): Collection;
|
||||
|
||||
/** @return Collection|array<Model> */
|
||||
public function getAll(): Collection;
|
||||
|
|
|
@ -175,16 +175,26 @@ class SongRepository extends Repository
|
|||
}
|
||||
|
||||
/** @return Collection|array<array-key, Song> */
|
||||
public function getByIds(array $ids, ?User $scopedUser = null): Collection
|
||||
public function getByIds(array $ids, bool $inThatOrder = false, ?User $scopedUser = null): Collection
|
||||
{
|
||||
return Song::query()->withMeta($scopedUser ?? $this->auth->user())->whereIn('songs.id', $ids)->get();
|
||||
$songs = Song::query()
|
||||
->withMeta($scopedUser ?? $this->auth->user())
|
||||
->whereIn('songs.id', $ids)
|
||||
->get();
|
||||
|
||||
return $inThatOrder ? $songs->orderByArray($ids) : $songs;
|
||||
}
|
||||
|
||||
public function getOne($id, ?User $scopedUser = null): Song
|
||||
public function getOne(string $id, ?User $scopedUser = null): Song
|
||||
{
|
||||
return Song::query()->withMeta($scopedUser ?? $this->auth->user())->findOrFail($id);
|
||||
}
|
||||
|
||||
public function findOne(string $id, ?User $scopedUser = null): ?Song
|
||||
{
|
||||
return Song::query()->withMeta($scopedUser ?? $this->auth->user())->find($id);
|
||||
}
|
||||
|
||||
public function count(): int
|
||||
{
|
||||
return Song::query()->count();
|
||||
|
|
52
app/Services/QueueService.php
Normal file
52
app/Services/QueueService.php
Normal file
|
@ -0,0 +1,52 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\QueueState;
|
||||
use App\Models\User;
|
||||
use App\Repositories\SongRepository;
|
||||
use App\Values\QueueState as QueueStateDTO;
|
||||
|
||||
class QueueService
|
||||
{
|
||||
public function __construct(private SongRepository $songRepository)
|
||||
{
|
||||
}
|
||||
|
||||
public function getQueueState(User $user): QueueStateDTO
|
||||
{
|
||||
/** @var QueueState $state */
|
||||
$state = QueueState::query()->where('user_id', $user->id)->firstOrCreate([
|
||||
'user_id' => $user->id,
|
||||
], [
|
||||
'song_ids' => [],
|
||||
]);
|
||||
|
||||
$currentSong = $state->current_song_id ? $this->songRepository->findOne($state->current_song_id, $user) : null;
|
||||
|
||||
return QueueStateDTO::create(
|
||||
$this->songRepository->getByIds(ids: $state->song_ids, inThatOrder: true, scopedUser: $user),
|
||||
$currentSong,
|
||||
$state->playback_position ?? 0
|
||||
);
|
||||
}
|
||||
|
||||
public function updateQueueState(User $user, array $songIds): void
|
||||
{
|
||||
QueueState::query()->updateOrCreate([
|
||||
'user_id' => $user->id,
|
||||
], [
|
||||
'song_ids' => $songIds,
|
||||
]);
|
||||
}
|
||||
|
||||
public function updatePlaybackStatus(User $user, string $songId, int $position): void
|
||||
{
|
||||
QueueState::query()->updateOrCreate([
|
||||
'user_id' => $user->id,
|
||||
], [
|
||||
'current_song_id' => $songId,
|
||||
'playback_position' => $position,
|
||||
]);
|
||||
}
|
||||
}
|
|
@ -34,11 +34,12 @@ class SearchService
|
|||
|
||||
return ExcerptSearchResult::make(
|
||||
$this->songRepository->getByIds(
|
||||
Song::search($keywords)->get()->take($count)->pluck('id')->all(),
|
||||
$scopedUser
|
||||
ids: Song::search($keywords)->get()->take($count)->pluck('id')->all(),
|
||||
inThatOrder: true,
|
||||
scopedUser: $scopedUser
|
||||
),
|
||||
$this->artistRepository->getByIds(Artist::search($keywords)->get()->take($count)->pluck('id')->all()),
|
||||
$this->albumRepository->getByIds(Album::search($keywords)->get()->take($count)->pluck('id')->all()),
|
||||
$this->artistRepository->getByIds(Artist::search($keywords)->get()->take($count)->pluck('id')->all(), true),
|
||||
$this->albumRepository->getByIds(Album::search($keywords)->get()->take($count)->pluck('id')->all(), true),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
18
app/Values/QueueState.php
Normal file
18
app/Values/QueueState.php
Normal file
|
@ -0,0 +1,18 @@
|
|||
<?php
|
||||
|
||||
namespace App\Values;
|
||||
|
||||
use App\Models\Song;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class QueueState
|
||||
{
|
||||
private function __construct(public Collection $songs, public ?Song $currentSong, public ?int $playbackPosition)
|
||||
{
|
||||
}
|
||||
|
||||
public static function create(Collection $songs, ?Song $currentSong = null, ?int $playbackPosition = 0): static
|
||||
{
|
||||
return new static($songs, $currentSong, $playbackPosition);
|
||||
}
|
||||
}
|
21
database/factories/QueueStateFactory.php
Normal file
21
database/factories/QueueStateFactory.php
Normal file
|
@ -0,0 +1,21 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\Song;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
class QueueStateFactory extends Factory
|
||||
{
|
||||
/** @return array<mixed> */
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'user_id' => User::factory(),
|
||||
'song_ids' => Song::factory()->count(3)->create()->pluck('id')->toArray(),
|
||||
'current_song_id' => null,
|
||||
'playback_position' => 0,
|
||||
];
|
||||
}
|
||||
}
|
|
@ -0,0 +1,25 @@
|
|||
<?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('queue_states', static function (Blueprint $table): void {
|
||||
$table->increments('id');
|
||||
$table->unsignedInteger('user_id');
|
||||
$table->json('song_ids');
|
||||
$table->string('current_song_id', 36)->nullable();
|
||||
$table->unsignedInteger('playback_position')->default(0);
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::table('queue_states', static function (Blueprint $table): void {
|
||||
$table->foreign('user_id')->references('id')->on('users')->cascadeOnUpdate()->cascadeOnDelete();
|
||||
$table->foreign('current_song_id')->references('id')->on('songs')->cascadeOnUpdate()->nullOnDelete();
|
||||
});
|
||||
}
|
||||
};
|
|
@ -1,6 +1,6 @@
|
|||
import { ref, Ref } from 'vue'
|
||||
import { expect, it } from 'vitest'
|
||||
import { screen, waitFor } from '@testing-library/vue'
|
||||
import { expect, it, Mock } from 'vitest'
|
||||
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'
|
||||
|
@ -9,8 +9,14 @@ import { eventBus } from '@/utils'
|
|||
import ExtraDrawer from './ExtraDrawer.vue'
|
||||
|
||||
new class extends UnitTestCase {
|
||||
private renderComponent (songRef: Ref<Song | null> = ref(null)) {
|
||||
return this.render(ExtraDrawer, {
|
||||
private renderComponent (songRef: Ref<Song | null> = ref(null)): [RenderResult, Mock, Mock] {
|
||||
const artist = factory<Artist>('artist')
|
||||
const resolveArtistMock = this.mock(artistStore, 'resolve').mockResolvedValue(artist)
|
||||
|
||||
const album = factory<Album>('album')
|
||||
const resolveAlbumMock = this.mock(albumStore, 'resolve').mockResolvedValue(album)
|
||||
|
||||
const rendered = this.render(ExtraDrawer, {
|
||||
global: {
|
||||
stubs: {
|
||||
ProfileAvatar: this.stub(),
|
||||
|
@ -25,10 +31,12 @@ new class extends UnitTestCase {
|
|||
}
|
||||
}
|
||||
})
|
||||
|
||||
return [rendered, resolveArtistMock, resolveAlbumMock]
|
||||
}
|
||||
|
||||
protected test () {
|
||||
it('renders without a current song', () => expect(this.renderComponent().html()).toMatchSnapshot())
|
||||
it('renders without a current song', () => expect(this.renderComponent()[0].html()).toMatchSnapshot())
|
||||
|
||||
it('sets the active tab to the preference', async () => {
|
||||
preferenceStore.activeExtraPanelTab = 'YouTube'
|
||||
|
@ -42,17 +50,11 @@ new class extends UnitTestCase {
|
|||
|
||||
it('fetches info for the current song', async () => {
|
||||
commonStore.state.use_you_tube = true
|
||||
const artist = factory<Artist>('artist')
|
||||
const resolveArtistMock = this.mock(artistStore, 'resolve').mockResolvedValue(artist)
|
||||
|
||||
const album = factory<Album>('album')
|
||||
const resolveAlbumMock = this.mock(albumStore, 'resolve').mockResolvedValue(album)
|
||||
|
||||
const song = factory<Song>('song')
|
||||
|
||||
const songRef = ref<Song | null>(null)
|
||||
|
||||
this.renderComponent(songRef)
|
||||
const [, resolveArtistMock, resolveAlbumMock] = this.renderComponent(songRef)
|
||||
songRef.value = song
|
||||
|
||||
await waitFor(() => {
|
||||
|
@ -75,7 +77,7 @@ new class extends UnitTestCase {
|
|||
it('shows new version', () => {
|
||||
commonStore.state.current_version = 'v1.0.0'
|
||||
commonStore.state.latest_version = 'v1.0.1'
|
||||
this.actingAsAdmin().renderComponent().getByRole('button', { name: 'New version available!' })
|
||||
this.actingAsAdmin().renderComponent()[0].getByRole('button', { name: 'New version available!' })
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
@ -94,15 +94,12 @@ const { currentUser } = useAuthorization()
|
|||
const { useYouTube } = useThirdPartyServices()
|
||||
const { shouldNotifyNewVersion } = useNewVersionNotification()
|
||||
|
||||
const song = requireInjection(CurrentSongKey, ref(null))
|
||||
const song = requireInjection(CurrentSongKey, ref(undefined))
|
||||
const activeTab = ref<ExtraPanelTab | null>(null)
|
||||
|
||||
const artist = ref<Artist>()
|
||||
const album = ref<Album>()
|
||||
|
||||
watch(song, song => song && fetchSongInfo(song))
|
||||
watch(activeTab, tab => (preferenceStore.activeExtraPanelTab = tab))
|
||||
|
||||
const fetchSongInfo = async (_song: Song) => {
|
||||
song.value = _song
|
||||
artist.value = undefined
|
||||
|
@ -116,6 +113,9 @@ const fetchSongInfo = async (_song: Song) => {
|
|||
}
|
||||
}
|
||||
|
||||
watch(song, song => song && fetchSongInfo(song), { immediate: true })
|
||||
watch(activeTab, tab => (preferenceStore.activeExtraPanelTab = tab))
|
||||
|
||||
const openAboutKoelModal = () => eventBus.emit('MODAL_SHOW_ABOUT_KOEL')
|
||||
const onProfileLinkClick = () => isMobile.any && (activeTab.value = null)
|
||||
const logout = () => eventBus.emit('LOG_OUT')
|
||||
|
|
|
@ -12,7 +12,7 @@ let songs: Song[]
|
|||
|
||||
new class extends UnitTestCase {
|
||||
protected beforeEach () {
|
||||
super.beforeEach(() => queueStore.clear())
|
||||
super.beforeEach(() => queueStore.state.songs = [])
|
||||
}
|
||||
|
||||
private async renderComponent (_songs?: Song | Song[]) {
|
||||
|
|
|
@ -2,8 +2,9 @@ import { expect, it } from 'vitest'
|
|||
import factory from '@/__tests__/factory'
|
||||
import UnitTestCase from '@/__tests__/UnitTestCase'
|
||||
import { playbackService } from '@/services'
|
||||
import SongThumbnail from '@/components/song/SongThumbnail.vue'
|
||||
import { screen } from '@testing-library/vue'
|
||||
import { queueStore } from '@/stores'
|
||||
import SongThumbnail from '@/components/song/SongThumbnail.vue'
|
||||
|
||||
let song: Song
|
||||
|
||||
|
@ -28,12 +29,13 @@ new class extends UnitTestCase {
|
|||
['Playing', 'Pause', 'pause'],
|
||||
['Paused', 'Resume', 'resume']
|
||||
])('if state is currently "%s", %ss', async (state, name, method) => {
|
||||
const mock = this.mock(playbackService, method)
|
||||
this.mock(queueStore, 'queueIfNotQueued')
|
||||
const playbackMock = this.mock(playbackService, method)
|
||||
this.renderComponent(state)
|
||||
|
||||
await this.user.click(screen.getByRole('button', { name }))
|
||||
|
||||
expect(mock).toHaveBeenCalled()
|
||||
expect(playbackMock).toHaveBeenCalled()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,11 +6,13 @@ import { authService } from '@/services'
|
|||
class Http {
|
||||
client: AxiosInstance
|
||||
|
||||
private static setProgressBar () {
|
||||
private silent = false
|
||||
|
||||
private showLoadingIndicator () {
|
||||
NProgress.start()
|
||||
}
|
||||
|
||||
private static hideProgressBar () {
|
||||
private hideLoadingIndicator () {
|
||||
NProgress.done(true)
|
||||
}
|
||||
|
||||
|
@ -49,14 +51,15 @@ class Http {
|
|||
|
||||
// Intercept the request to make sure the token is injected into the header.
|
||||
this.client.interceptors.request.use(config => {
|
||||
Http.setProgressBar()
|
||||
this.silent || this.showLoadingIndicator()
|
||||
config.headers.Authorization = `Bearer ${authService.getApiToken()}`
|
||||
return config
|
||||
})
|
||||
|
||||
// Intercept the response and…
|
||||
this.client.interceptors.response.use(response => {
|
||||
Http.hideProgressBar()
|
||||
this.silent || this.hideLoadingIndicator()
|
||||
this.silent = false
|
||||
|
||||
// …get the tokens from the header or response data if exist, and save them.
|
||||
const token = response.headers.authorization || response.data.token
|
||||
|
@ -67,7 +70,8 @@ class Http {
|
|||
|
||||
return response
|
||||
}, error => {
|
||||
Http.hideProgressBar()
|
||||
this.silent || this.hideLoadingIndicator()
|
||||
this.silent = false
|
||||
|
||||
// Also, if we receive a Bad Request / Unauthorized error
|
||||
if (error.response?.status === 400 || error.response?.status === 401) {
|
||||
|
@ -81,6 +85,11 @@ class Http {
|
|||
return Promise.reject(error)
|
||||
})
|
||||
}
|
||||
|
||||
public get silently () {
|
||||
this.silent = true
|
||||
return this
|
||||
}
|
||||
}
|
||||
|
||||
export const http = new Http()
|
||||
|
|
|
@ -5,7 +5,7 @@ import { expect, it, vi } from 'vitest'
|
|||
import { noop } from '@/utils'
|
||||
import factory from '@/__tests__/factory'
|
||||
import UnitTestCase from '@/__tests__/UnitTestCase'
|
||||
import { socketService } from '@/services'
|
||||
import { http, socketService } from '@/services'
|
||||
import { playbackService } from './playbackService'
|
||||
|
||||
import {
|
||||
|
@ -62,10 +62,12 @@ new class extends UnitTestCase {
|
|||
])(
|
||||
'when playCountRegistered is %s, isTranscoding is %s, current media time is %d, media duration is %d, then registerPlay() should be call %d times',
|
||||
(playCountRegistered, isTranscoding, currentTime, duration, numberOfCalls) => {
|
||||
this.setCurrentSong(factory<Song>('song', {
|
||||
const song = factory<Song>('song', {
|
||||
play_count_registered: playCountRegistered,
|
||||
playback_state: 'Playing'
|
||||
}))
|
||||
})
|
||||
|
||||
this.setCurrentSong(song)
|
||||
|
||||
this.setReadOnlyProperty(playbackService, 'isTranscoding', isTranscoding)
|
||||
playbackService.init(document.querySelector('.plyr')!)
|
||||
|
@ -77,9 +79,15 @@ new class extends UnitTestCase {
|
|||
this.setReadOnlyProperty(mediaElement, 'duration', duration)
|
||||
|
||||
const registerPlayMock = this.mock(playbackService, 'registerPlay')
|
||||
const putMock = this.mock(http, 'put')
|
||||
|
||||
mediaElement.dispatchEvent(new Event('timeupdate'))
|
||||
|
||||
expect(registerPlayMock).toHaveBeenCalledTimes(numberOfCalls)
|
||||
expect(putMock).toHaveBeenCalledWith('queue/playback-status', {
|
||||
song: song.id,
|
||||
position: currentTime
|
||||
})
|
||||
})
|
||||
|
||||
it('plays next song if current song is errored', () => {
|
||||
|
@ -138,11 +146,14 @@ new class extends UnitTestCase {
|
|||
this.setReadOnlyProperty(mediaElement, 'duration', duration)
|
||||
|
||||
const preloadMock = this.mock(playbackService, 'preload')
|
||||
this.mock(http, 'put')
|
||||
|
||||
mediaElement.dispatchEvent(new Event('timeupdate'))
|
||||
|
||||
expect(preloadMock).toHaveBeenCalledTimes(numberOfCalls)
|
||||
}
|
||||
)
|
||||
|
||||
it('registers play', () => {
|
||||
const recentlyPlayedStoreAddMock = this.mock(recentlyPlayedStore, 'add')
|
||||
const registerPlayMock = this.mock(songStore, 'registerPlay')
|
||||
|
@ -156,6 +167,8 @@ new class extends UnitTestCase {
|
|||
})
|
||||
|
||||
it('preloads a song', () => {
|
||||
playbackService.init(document.querySelector('.plyr')!)
|
||||
|
||||
const audioElement = {
|
||||
setAttribute: vi.fn(),
|
||||
load: vi.fn()
|
||||
|
@ -175,11 +188,14 @@ new class extends UnitTestCase {
|
|||
})
|
||||
|
||||
it('restarts a song', async () => {
|
||||
playbackService.init(document.querySelector('.plyr')!)
|
||||
|
||||
const song = this.setCurrentSong()
|
||||
this.mock(Math, 'floor', 1000)
|
||||
const broadcastMock = this.mock(socketService, 'broadcast')
|
||||
const showNotificationMock = this.mock(playbackService, 'showNotification')
|
||||
const restartMock = this.mock(playbackService.player!, 'restart')
|
||||
const putMock = this.mock(http, 'put')
|
||||
const playMock = this.mock(window.HTMLMediaElement.prototype, 'play')
|
||||
|
||||
await playbackService.restart()
|
||||
|
@ -190,6 +206,11 @@ new class extends UnitTestCase {
|
|||
expect(showNotificationMock).toHaveBeenCalled()
|
||||
expect(restartMock).toHaveBeenCalled()
|
||||
expect(playMock).toHaveBeenCalled()
|
||||
|
||||
expect(putMock).toHaveBeenCalledWith('queue/playback-status', {
|
||||
song: song.id,
|
||||
position: 0,
|
||||
})
|
||||
})
|
||||
|
||||
it.each<[RepeatMode, RepeatMode]>([
|
||||
|
@ -197,6 +218,7 @@ new class extends UnitTestCase {
|
|||
['REPEAT_ALL', 'REPEAT_ONE'],
|
||||
['REPEAT_ONE', 'NO_REPEAT']
|
||||
])('it switches from repeat mode %s to repeat mode %s', (fromMode, toMode) => {
|
||||
playbackService.init(document.querySelector('.plyr')!)
|
||||
preferences.repeatMode = fromMode
|
||||
playbackService.changeRepeatMode()
|
||||
|
||||
|
@ -204,6 +226,9 @@ new class extends UnitTestCase {
|
|||
})
|
||||
|
||||
it('restarts song if playPrev is triggered after 5 seconds', async () => {
|
||||
this.setCurrentSong()
|
||||
playbackService.init(document.querySelector('.plyr')!)
|
||||
|
||||
const mock = this.mock(playbackService.player!, 'restart')
|
||||
this.setReadOnlyProperty(playbackService.player!.media, 'currentTime', 6)
|
||||
|
||||
|
@ -213,6 +238,8 @@ new class extends UnitTestCase {
|
|||
})
|
||||
|
||||
it('stops if playPrev is triggered when there is no prev song and repeat mode is NO_REPEAT', async () => {
|
||||
playbackService.init(document.querySelector('.plyr')!)
|
||||
|
||||
const stopMock = this.mock(playbackService, 'stop')
|
||||
this.setReadOnlyProperty(playbackService.player!.media, 'currentTime', 4)
|
||||
this.setReadOnlyProperty(playbackService, 'previous', undefined)
|
||||
|
@ -224,6 +251,8 @@ new class extends UnitTestCase {
|
|||
})
|
||||
|
||||
it('plays the previous song', async () => {
|
||||
playbackService.init(document.querySelector('.plyr')!)
|
||||
|
||||
const previousSong = factory('song')
|
||||
this.setReadOnlyProperty(playbackService.player!.media, 'currentTime', 4)
|
||||
this.setReadOnlyProperty(playbackService, 'previous', previousSong)
|
||||
|
@ -235,6 +264,8 @@ new class extends UnitTestCase {
|
|||
})
|
||||
|
||||
it('stops if playNext is triggered when there is no next song and repeat mode is NO_REPEAT', async () => {
|
||||
playbackService.init(document.querySelector('.plyr')!)
|
||||
|
||||
this.setReadOnlyProperty(playbackService, 'next', undefined)
|
||||
preferences.repeatMode = 'NO_REPEAT'
|
||||
const stopMock = this.mock(playbackService, 'stop')
|
||||
|
@ -245,6 +276,8 @@ new class extends UnitTestCase {
|
|||
})
|
||||
|
||||
it('plays the next song', async () => {
|
||||
playbackService.init(document.querySelector('.plyr')!)
|
||||
|
||||
const nextSong = factory('song')
|
||||
this.setReadOnlyProperty(playbackService, 'next', nextSong)
|
||||
const playMock = this.mock(playbackService, 'play')
|
||||
|
@ -255,7 +288,9 @@ new class extends UnitTestCase {
|
|||
})
|
||||
|
||||
it('stops playback', () => {
|
||||
const currentSong = factory<Song>('song')
|
||||
playbackService.init(document.querySelector('.plyr')!)
|
||||
|
||||
const currentSong = this.setCurrentSong()
|
||||
const pauseMock = this.mock(playbackService.player!, 'pause')
|
||||
const seekMock = this.mock(playbackService.player!, 'seek')
|
||||
const broadcastMock = this.mock(socketService, 'broadcast')
|
||||
|
@ -270,6 +305,8 @@ new class extends UnitTestCase {
|
|||
})
|
||||
|
||||
it('pauses playback', () => {
|
||||
playbackService.init(document.querySelector('.plyr')!)
|
||||
|
||||
const song = this.setCurrentSong()
|
||||
const pauseMock = this.mock(playbackService.player!, 'pause')
|
||||
const broadcastMock = this.mock(socketService, 'broadcast')
|
||||
|
@ -298,7 +335,8 @@ new class extends UnitTestCase {
|
|||
})
|
||||
|
||||
it('plays first in queue if toggled when there is no current song', async () => {
|
||||
queueStore.clear()
|
||||
playbackService.init(document.querySelector('.plyr')!)
|
||||
queueStore.state.songs = []
|
||||
const playFirstInQueueMock = this.mock(playbackService, 'playFirstInQueue')
|
||||
|
||||
await playbackService.toggle()
|
||||
|
@ -310,6 +348,8 @@ new class extends UnitTestCase {
|
|||
['resume', 'Paused'],
|
||||
['pause', 'Playing']
|
||||
])('%ss playback if toggled when current song playback state is %s', async (action, playbackState) => {
|
||||
playbackService.init(document.querySelector('.plyr')!)
|
||||
|
||||
this.setCurrentSong(factory<Song>('song', { playback_state: playbackState }))
|
||||
const actionMock = this.mock(playbackService, action)
|
||||
await playbackService.toggle()
|
||||
|
@ -318,6 +358,8 @@ new class extends UnitTestCase {
|
|||
})
|
||||
|
||||
it('queues and plays songs without shuffling', async () => {
|
||||
playbackService.init(document.querySelector('.plyr')!)
|
||||
|
||||
const songs = factory<Song>('song', 5)
|
||||
const replaceQueueMock = this.mock(queueStore, 'replaceQueueWith')
|
||||
const playMock = this.mock(playbackService, 'play')
|
||||
|
@ -334,6 +376,8 @@ new class extends UnitTestCase {
|
|||
})
|
||||
|
||||
it('queues and plays songs with shuffling', async () => {
|
||||
playbackService.init(document.querySelector('.plyr')!)
|
||||
|
||||
const songs = factory<Song>('song', 5)
|
||||
const shuffledSongs = factory<Song>('song', 5)
|
||||
const replaceQueueMock = this.mock(queueStore, 'replaceQueueWith')
|
||||
|
@ -351,8 +395,10 @@ new class extends UnitTestCase {
|
|||
})
|
||||
|
||||
it('plays first song in queue', async () => {
|
||||
playbackService.init(document.querySelector('.plyr')!)
|
||||
|
||||
const songs = factory<Song>('song', 5)
|
||||
queueStore.all = songs
|
||||
queueStore.state.songs = songs
|
||||
this.setReadOnlyProperty(queueStore, 'first', songs[0])
|
||||
const playMock = this.mock(playbackService, 'play')
|
||||
|
||||
|
|
|
@ -13,7 +13,7 @@ import {
|
|||
} from '@/stores'
|
||||
|
||||
import { arrayify, isAudioContextSupported, logger } from '@/utils'
|
||||
import { audioService, socketService, volumeManager } from '@/services'
|
||||
import { audioService, http, socketService, volumeManager } from '@/services'
|
||||
|
||||
/**
|
||||
* The number of seconds before the current song ends to start preload the next one.
|
||||
|
@ -72,26 +72,30 @@ class PlaybackService {
|
|||
return
|
||||
}
|
||||
|
||||
document.title = `${song.title} ♫ Koel`
|
||||
this.player.media.setAttribute('title', `${song.artist_name} - ${song.title}`)
|
||||
|
||||
if (queueStore.current) {
|
||||
queueStore.current.playback_state = 'Stopped'
|
||||
}
|
||||
|
||||
song.playback_state = 'Playing'
|
||||
|
||||
await this.setNowPlayingMeta(song)
|
||||
|
||||
// Manually set the `src` attribute of the audio to prevent plyr from resetting
|
||||
// the audio media object and cause our equalizer to malfunction.
|
||||
this.player.media.src = songStore.getSourceUrl(song)
|
||||
|
||||
// We'll just "restart" playing the song, which will handle notification, scrobbling etc.
|
||||
// Fixes #898
|
||||
await this.restart()
|
||||
}
|
||||
|
||||
private async setNowPlayingMeta(song) {
|
||||
document.title = `${song.title} ♫ Koel`
|
||||
this.player.media.setAttribute('title', `${song.artist_name} - ${song.title}`)
|
||||
|
||||
if (isAudioContextSupported) {
|
||||
await audioService.context.resume()
|
||||
}
|
||||
|
||||
await this.restart()
|
||||
}
|
||||
|
||||
public showNotification (song: Song) {
|
||||
|
@ -126,14 +130,42 @@ class PlaybackService {
|
|||
})
|
||||
}
|
||||
|
||||
public setCurrentSongAndPlaybackPosition (song: Song, playbackPosition: number) {
|
||||
if (queueStore.current) {
|
||||
queueStore.current.playback_state = 'Stopped'
|
||||
}
|
||||
|
||||
song.playback_state = 'Paused'
|
||||
|
||||
this.player.media.src = songStore.getSourceUrl(song)
|
||||
this.player.seek(playbackPosition)
|
||||
this.player.pause()
|
||||
}
|
||||
|
||||
// Record the UNIX timestamp the song starts playing, for scrobbling purpose
|
||||
private recordStartTime (song: Song) {
|
||||
song.play_start_time = Math.floor(Date.now() / 1000)
|
||||
song.play_count_registered = false
|
||||
}
|
||||
|
||||
private broadcastSong (song: Song) {
|
||||
socketService.broadcast('SOCKET_SONG', song)
|
||||
}
|
||||
|
||||
public async restart () {
|
||||
const song = queueStore.current!
|
||||
|
||||
// Record the UNIX timestamp the song starts playing, for scrobbling purpose
|
||||
song.play_start_time = Math.floor(Date.now() / 1000)
|
||||
song.play_count_registered = false
|
||||
this.recordStartTime(song)
|
||||
this.broadcastSong(song)
|
||||
|
||||
socketService.broadcast('SOCKET_SONG', song)
|
||||
try {
|
||||
http.silently.put('queue/playback-status', {
|
||||
song: song.id,
|
||||
position: 0
|
||||
})
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
|
||||
this.player.restart()
|
||||
|
||||
|
@ -246,6 +278,18 @@ class PlaybackService {
|
|||
}
|
||||
|
||||
public async resume () {
|
||||
const song = queueStore.current!
|
||||
|
||||
if (!this.player.media.src) {
|
||||
// on first load when the queue is loaded from saved state, the player's src is empty
|
||||
// we need to properly set it as well as any kind of playback metadata
|
||||
this.player.media.src = songStore.getSourceUrl(song)
|
||||
this.player.seek(commonStore.state.queue_state.playback_position);
|
||||
|
||||
await this.setNowPlayingMeta(queueStore.current!)
|
||||
this.recordStartTime(song)
|
||||
}
|
||||
|
||||
try {
|
||||
await this.player.media.play()
|
||||
} catch (error) {
|
||||
|
@ -255,7 +299,7 @@ class PlaybackService {
|
|||
queueStore.current!.playback_state = 'Playing'
|
||||
navigator.mediaSession && (navigator.mediaSession.playbackState = 'playing')
|
||||
|
||||
socketService.broadcast('SOCKET_SONG', queueStore.current)
|
||||
this.broadcastSong(song)
|
||||
}
|
||||
|
||||
public async toggle () {
|
||||
|
@ -345,6 +389,18 @@ class PlaybackService {
|
|||
}
|
||||
}
|
||||
|
||||
// every 5 seconds, we save the current playback position to the server
|
||||
if (Math.ceil(media.currentTime) % 5 === 0) {
|
||||
try {
|
||||
http.silently.put('queue/playback-status', {
|
||||
song: currentSong.id,
|
||||
position: Math.ceil(media.currentTime)
|
||||
})
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
}
|
||||
|
||||
const nextSong = queueStore.next
|
||||
|
||||
if (!nextSong || nextSong.preloaded || this.isTranscoding) {
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import isMobile from 'ismobilejs'
|
||||
import { reactive } from 'vue'
|
||||
import { http } from '@/services'
|
||||
import { playlistFolderStore, playlistStore, preferenceStore, settingStore, themeStore, userStore } from '.'
|
||||
import { playlistFolderStore, playlistStore, preferenceStore, queueStore, settingStore, themeStore, userStore } from '.'
|
||||
|
||||
interface CommonStoreState {
|
||||
allow_download: boolean
|
||||
|
@ -18,7 +18,8 @@ interface CommonStoreState {
|
|||
users: User[]
|
||||
use_you_tube: boolean,
|
||||
song_count: number,
|
||||
song_length: number
|
||||
song_length: number,
|
||||
queue_state: QueueState
|
||||
}
|
||||
|
||||
export const commonStore = {
|
||||
|
@ -37,7 +38,13 @@ export const commonStore = {
|
|||
users: [],
|
||||
use_you_tube: false,
|
||||
song_count: 0,
|
||||
song_length: 0
|
||||
song_length: 0,
|
||||
queue_state: {
|
||||
type: 'queue-states',
|
||||
songs: [],
|
||||
current_song: null,
|
||||
playback_position: 0
|
||||
}
|
||||
}),
|
||||
|
||||
async init () {
|
||||
|
@ -54,6 +61,7 @@ export const commonStore = {
|
|||
playlistStore.init(this.state.playlists)
|
||||
playlistFolderStore.init(this.state.playlist_folders)
|
||||
settingStore.init(this.state.settings)
|
||||
queueStore.init(this.state.queue_state)
|
||||
themeStore.init()
|
||||
|
||||
return this.state
|
||||
|
|
|
@ -24,42 +24,55 @@ new class extends UnitTestCase {
|
|||
|
||||
it('queues to bottom', () => {
|
||||
const song = factory<Song>('song')
|
||||
const putMock = this.mock(http, 'put')
|
||||
queueStore.queue(song)
|
||||
|
||||
expect(queueStore.all).toHaveLength(4)
|
||||
expect(queueStore.last).toEqual(song)
|
||||
expect(putMock).toHaveBeenCalledWith('queue/state', { songs: queueStore.all.map(song => song.id) })
|
||||
})
|
||||
|
||||
it('queues to top', () => {
|
||||
const song = factory<Song>('song')
|
||||
const putMock = this.mock(http, 'put')
|
||||
queueStore.queueToTop(song)
|
||||
|
||||
expect(queueStore.all).toHaveLength(4)
|
||||
expect(queueStore.first).toEqual(song)
|
||||
expect(putMock).toHaveBeenCalledWith('queue/state', { songs: queueStore.all.map(song => song.id) })
|
||||
})
|
||||
|
||||
it('replaces the whole queue', () => {
|
||||
const newSongs = factory<Song>('song', 2)
|
||||
const putMock = this.mock(http, 'put')
|
||||
queueStore.replaceQueueWith(newSongs)
|
||||
|
||||
expect(queueStore.all).toEqual(newSongs)
|
||||
expect(putMock).toHaveBeenCalledWith('queue/state', { songs: newSongs.map(song => song.id) })
|
||||
})
|
||||
|
||||
it('removes a song from queue', () => {
|
||||
const putMock = this.mock(http, 'put')
|
||||
queueStore.unqueue(songs[1])
|
||||
|
||||
expect(queueStore.all).toEqual([songs[0], songs[2]])
|
||||
expect(putMock).toHaveBeenCalledWith('queue/state', { songs: queueStore.all.map(song => song.id) })
|
||||
})
|
||||
|
||||
it('removes multiple songs from queue', () => {
|
||||
const putMock = this.mock(http, 'put')
|
||||
queueStore.unqueue([songs[1], songs[0]])
|
||||
|
||||
expect(queueStore.all).toEqual([songs[2]])
|
||||
expect(putMock).toHaveBeenCalledWith('queue/state', { songs: queueStore.all.map(song => song.id) })
|
||||
})
|
||||
|
||||
it('clears the queue', () => {
|
||||
const putMock = this.mock(http, 'put')
|
||||
queueStore.clear()
|
||||
|
||||
expect(queueStore.state.songs).toHaveLength(0)
|
||||
expect(putMock).toHaveBeenCalledWith('queue/state', { songs: [] })
|
||||
})
|
||||
|
||||
it.each<[PlaybackState]>([['Playing'], ['Paused']])('identifies the current song by %s state', state => {
|
||||
|
@ -91,24 +104,28 @@ new class extends UnitTestCase {
|
|||
const songs = factory<Song>('song', 3)
|
||||
const getMock = this.mock(http, 'get').mockResolvedValue(songs)
|
||||
const syncMock = this.mock(songStore, 'syncWithVault', songs)
|
||||
const putMock = this.mock(http, 'put')
|
||||
|
||||
await queueStore.fetchRandom(3)
|
||||
|
||||
expect(getMock).toHaveBeenCalledWith('queue/fetch?order=rand&limit=3')
|
||||
expect(syncMock).toHaveBeenCalledWith(songs)
|
||||
expect(queueStore.all).toEqual(songs)
|
||||
expect(putMock).toHaveBeenCalledWith('queue/state', { songs: songs.map(song => song.id) })
|
||||
})
|
||||
|
||||
it('fetches random songs to queue with a custom order', async () => {
|
||||
const songs = factory<Song>('song', 3)
|
||||
const getMock = this.mock(http, 'get').mockResolvedValue(songs)
|
||||
const syncMock = this.mock(songStore, 'syncWithVault', songs)
|
||||
const putMock = this.mock(http, 'put')
|
||||
|
||||
await queueStore.fetchInOrder('title', 'desc', 3)
|
||||
|
||||
expect(getMock).toHaveBeenCalledWith('queue/fetch?order=desc&sort=title&limit=3')
|
||||
expect(syncMock).toHaveBeenCalledWith(songs)
|
||||
expect(queueStore.all).toEqual(songs)
|
||||
expect(putMock).toHaveBeenCalledWith('queue/state', { songs: songs.map(song => song.id) })
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -9,12 +9,28 @@ export const queueStore = {
|
|||
songs: []
|
||||
}),
|
||||
|
||||
init (savedState: QueueState) {
|
||||
// don't set this.all here, as it would trigger saving state
|
||||
this.state.songs = songStore.syncWithVault(savedState.songs)
|
||||
|
||||
if (!this.state.songs.length) {
|
||||
return
|
||||
}
|
||||
|
||||
if (savedState.current_song) {
|
||||
songStore.syncWithVault(savedState.current_song)[0].playback_state = 'Paused'
|
||||
} else {
|
||||
this.all[0].playback_state = 'Paused'
|
||||
}
|
||||
},
|
||||
|
||||
get all () {
|
||||
return this.state.songs
|
||||
},
|
||||
|
||||
set all (songs: Song[]) {
|
||||
this.state.songs = reactive(songs)
|
||||
this.state.songs = songStore.syncWithVault(songs)
|
||||
this.saveState()
|
||||
},
|
||||
|
||||
get first () {
|
||||
|
@ -48,7 +64,7 @@ export const queueStore = {
|
|||
},
|
||||
|
||||
replaceQueueWith (songs: Song | Song[]) {
|
||||
this.state.songs = reactive(arrayify(songs))
|
||||
this.all = arrayify(songs)
|
||||
},
|
||||
|
||||
queueAfterCurrent (songs: Song | Song[]) {
|
||||
|
@ -82,6 +98,8 @@ export const queueStore = {
|
|||
this.all.splice(this.indexOf(song), 1)
|
||||
this.all.splice(targetIndex, 0, reactive(song))
|
||||
})
|
||||
|
||||
this.saveState()
|
||||
},
|
||||
|
||||
clear () {
|
||||
|
@ -116,19 +134,21 @@ export const queueStore = {
|
|||
return this.all.find(song => song.playback_state !== 'Stopped')
|
||||
},
|
||||
|
||||
shuffle () {
|
||||
this.all = shuffle(this.all)
|
||||
},
|
||||
|
||||
async fetchRandom (limit = 500) {
|
||||
const songs = await http.get<Song[]>(`queue/fetch?order=rand&limit=${limit}`)
|
||||
this.state.songs = songStore.syncWithVault(songs)
|
||||
return this.state.songs
|
||||
this.all = await http.get<Song[]>(`queue/fetch?order=rand&limit=${limit}`)
|
||||
return this.all
|
||||
},
|
||||
|
||||
async fetchInOrder (sortField: SongListSortField, order: SortOrder, limit = 500) {
|
||||
const songs = await http.get<Song[]>(`queue/fetch?order=${order}&sort=${sortField}&limit=${limit}`)
|
||||
this.state.songs = songStore.syncWithVault(songs)
|
||||
return this.state.songs
|
||||
this.all = await http.get<Song[]>(`queue/fetch?order=${order}&sort=${sortField}&limit=${limit}`)
|
||||
return this.all
|
||||
},
|
||||
|
||||
saveState () {
|
||||
try {
|
||||
http.silently.put('queue/state', { songs: this.state.songs.map(song => song.id) })
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -86,13 +86,13 @@ export const songStore = {
|
|||
* Increase a play count for a song.
|
||||
*/
|
||||
registerPlay: async (song: Song) => {
|
||||
const interaction = await http.post<Interaction>('interaction/play', { song: song.id })
|
||||
const interaction = await http.silently.post<Interaction>('interaction/play', { song: song.id })
|
||||
|
||||
// Use the data from the server to make sure we don't miss a play from another device.
|
||||
song.play_count = interaction.play_count
|
||||
},
|
||||
|
||||
scrobble: async (song: Song) => await http.post(`songs/${song.id}/scrobble`, {
|
||||
scrobble: async (song: Song) => await http.silently.post(`songs/${song.id}/scrobble`, {
|
||||
timestamp: song.play_start_time
|
||||
}),
|
||||
|
||||
|
|
7
resources/assets/js/types.d.ts
vendored
7
resources/assets/js/types.d.ts
vendored
|
@ -152,6 +152,13 @@ interface Song {
|
|||
deleted?: boolean
|
||||
}
|
||||
|
||||
interface QueueState {
|
||||
type: 'queue-states'
|
||||
songs: Song[]
|
||||
current_song: Song | null
|
||||
playback_position: number
|
||||
}
|
||||
|
||||
interface SmartPlaylistRuleGroup {
|
||||
id: string
|
||||
rules: SmartPlaylistRule[]
|
||||
|
|
|
@ -73,6 +73,9 @@ Route::prefix('api')->middleware('api')->group(static function (): void {
|
|||
Route::get('data', [DataController::class, 'index']);
|
||||
|
||||
Route::get('queue/fetch', [QueueController::class, 'fetchSongs']);
|
||||
Route::get('queue/state', [QueueController::class, 'getState']);
|
||||
Route::put('queue/state', [QueueController::class, 'updateState']);
|
||||
Route::put('queue/playback-status', [QueueController::class, 'updatePlaybackStatus']);
|
||||
|
||||
Route::put('settings', [SettingController::class, 'update']);
|
||||
|
||||
|
|
|
@ -21,6 +21,11 @@ class DataTest extends TestCase
|
|||
'latest_version',
|
||||
'song_count',
|
||||
'song_length',
|
||||
'queue_state' => [
|
||||
'songs',
|
||||
'current_song',
|
||||
'playback_position',
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,10 +2,79 @@
|
|||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\QueueState;
|
||||
use App\Models\Song;
|
||||
use App\Models\User;
|
||||
|
||||
class QueueTest extends TestCase
|
||||
{
|
||||
public const QUEUE_STATE_JSON_STRUCTURE = [
|
||||
'current_song',
|
||||
'songs' => ['*' => SongTest::JSON_STRUCTURE],
|
||||
'playback_position',
|
||||
];
|
||||
|
||||
public function testGetEmptyState(): void
|
||||
{
|
||||
$this->getAs('api/queue/state')
|
||||
->assertJsonStructure(self::QUEUE_STATE_JSON_STRUCTURE);
|
||||
}
|
||||
|
||||
public function testGetExistingState(): void
|
||||
{
|
||||
/** @var QueueState $queueState */
|
||||
$queueState = QueueState::factory()->create([
|
||||
'current_song_id' => Song::factory(),
|
||||
'playback_position' => 123,
|
||||
]);
|
||||
|
||||
$this->getAs('api/queue/state', $queueState->user)
|
||||
->assertJsonStructure(self::QUEUE_STATE_JSON_STRUCTURE);
|
||||
}
|
||||
|
||||
public function testUpdateStateWithoutExistingState(): void
|
||||
{
|
||||
/** @var User $user */
|
||||
$user = User::factory()->create();
|
||||
|
||||
self::assertDatabaseMissing(QueueState::class, ['user_id' => $user->id]);
|
||||
|
||||
$songIds = Song::factory(3)->create()->pluck('id')->toArray();
|
||||
|
||||
$this->putAs('api/queue/state', ['songs' => $songIds], $user)
|
||||
->assertNoContent();
|
||||
|
||||
/** @var QueueState $queue */
|
||||
$queue = QueueState::query()->where('user_id', $user->id)->firstOrFail();
|
||||
self::assertEqualsCanonicalizing($songIds, $queue->song_ids);
|
||||
}
|
||||
|
||||
public function testUpdatePlaybackStatus(): void
|
||||
{
|
||||
/** @var QueueState $state */
|
||||
$state = QueueState::factory()->create();
|
||||
|
||||
/** @var Song $song */
|
||||
$song = Song::factory()->create();
|
||||
|
||||
$this->putAs('api/queue/playback-status', ['song' => $song->id, 'position' => 123], $state->user)
|
||||
->assertNoContent();
|
||||
|
||||
$state->refresh();
|
||||
self::assertSame($song->id, $state->current_song_id);
|
||||
self::assertSame(123, $state->playback_position);
|
||||
|
||||
/** @var Song $anotherSong */
|
||||
$anotherSong = Song::factory()->create();
|
||||
|
||||
$this->putAs('api/queue/playback-status', ['song' => $anotherSong->id, 'position' => 456], $state->user)
|
||||
->assertNoContent();
|
||||
|
||||
$state->refresh();
|
||||
self::assertSame($anotherSong->id, $state->current_song_id);
|
||||
self::assertSame(456, $state->playback_position);
|
||||
}
|
||||
|
||||
public function testFetchSongs(): void
|
||||
{
|
||||
Song::factory(10)->create();
|
||||
|
|
|
@ -212,9 +212,10 @@ class SongTest extends TestCase
|
|||
|
||||
/** @var array<array-key, Song>|Collection $originalSongs */
|
||||
$originalSongs = Song::query()->latest()->take(3)->get();
|
||||
$originalSongIds = $originalSongs->pluck('id')->all();
|
||||
|
||||
$this->putAs('/api/songs', [
|
||||
'songs' => $originalSongs->pluck('id')->all(),
|
||||
'songs' => $originalSongIds,
|
||||
'data' => [
|
||||
'title' => 'Foo Bar',
|
||||
'artist_name' => 'John Cena',
|
||||
|
@ -226,7 +227,7 @@ class SongTest extends TestCase
|
|||
->assertOk();
|
||||
|
||||
/** @var array<array-key, Song>|Collection $songs */
|
||||
$songs = Song::query()->whereIn('id', $originalSongs->pluck('id'))->get();
|
||||
$songs = Song::query()->whereIn('id', $originalSongIds)->get()->orderByArray($originalSongIds);
|
||||
|
||||
// Even though the album name doesn't change, a new artist should have been created
|
||||
// and thus, a new album with the same name was created as well.
|
||||
|
|
88
tests/Integration/Services/QueueServiceTest.php
Normal file
88
tests/Integration/Services/QueueServiceTest.php
Normal file
|
@ -0,0 +1,88 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Integration\Services;
|
||||
|
||||
use App\Models\QueueState;
|
||||
use App\Models\Song;
|
||||
use App\Models\User;
|
||||
use App\Services\QueueService;
|
||||
use Tests\TestCase;
|
||||
|
||||
class QueueServiceTest extends TestCase
|
||||
{
|
||||
private QueueService $service;
|
||||
|
||||
public function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->service = app(QueueService::class);
|
||||
}
|
||||
|
||||
public function testGetQueueState(): void
|
||||
{
|
||||
/** @var Song $currentSong */
|
||||
$currentSong = Song::factory()->create();
|
||||
|
||||
/** @var QueueState $state */
|
||||
$state = QueueState::factory()->create([
|
||||
'current_song_id' => $currentSong->id,
|
||||
'playback_position' => 123,
|
||||
]);
|
||||
|
||||
$dto = $this->service->getQueueState($state->user);
|
||||
|
||||
self::assertEqualsCanonicalizing($state->song_ids, $dto->songs->pluck('id')->toArray());
|
||||
self::assertSame($currentSong->id, $dto->currentSong->id);
|
||||
self::assertSame(123, $dto->playbackPosition);
|
||||
}
|
||||
|
||||
public function testCreateQueueState(): void
|
||||
{
|
||||
/** @var User $user */
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->assertDatabaseMissing(QueueState::class, [
|
||||
'user_id' => $user->id,
|
||||
]);
|
||||
|
||||
$songIds = Song::factory()->count(3)->create()->pluck('id')->toArray();
|
||||
$this->service->updateQueueState($user, $songIds);
|
||||
|
||||
/** @var QueueState $queueState */
|
||||
$queueState = QueueState::query()->where('user_id', $user->id)->firstOrFail();
|
||||
self::assertEqualsCanonicalizing($songIds, $queueState->song_ids);
|
||||
self::assertNull($queueState->current_song_id);
|
||||
self::assertSame(0, $queueState->playback_position);
|
||||
}
|
||||
|
||||
public function testUpdateQueueState(): void
|
||||
{
|
||||
/** @var QueueState $state */
|
||||
$state = QueueState::factory()->create();
|
||||
|
||||
$songIds = Song::factory()->count(3)->create()->pluck('id')->toArray();
|
||||
$this->service->updateQueueState($state->user, $songIds);
|
||||
|
||||
$state->refresh();
|
||||
|
||||
self::assertEqualsCanonicalizing($songIds, $state->song_ids);
|
||||
self::assertNull($state->current_song_id);
|
||||
self::assertEquals(0, $state->playback_position);
|
||||
}
|
||||
|
||||
public function testUpdatePlaybackStatus(): void
|
||||
{
|
||||
/** @var QueueState $state */
|
||||
$state = QueueState::factory()->create();
|
||||
|
||||
/** @var Song $song */
|
||||
$song = Song::factory()->create();
|
||||
|
||||
$this->service->updatePlaybackStatus($state->user, $song->id, 123);
|
||||
$state->refresh();
|
||||
|
||||
self::assertSame($song->id, $state->current_song_id);
|
||||
self::assertSame(123, $state->playback_position);
|
||||
}
|
||||
}
|
|
@ -8,6 +8,7 @@ use App\Models\Song;
|
|||
use DMS\PHPUnitExtensions\ArraySubset\ArraySubsetAsserts;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
|
||||
use Illuminate\Testing\TestResponse;
|
||||
use ReflectionClass;
|
||||
use Tests\Traits\CreatesApplication;
|
||||
use Tests\Traits\SandboxesTests;
|
||||
|
@ -23,6 +24,13 @@ abstract class TestCase extends BaseTestCase
|
|||
{
|
||||
parent::setUp();
|
||||
|
||||
TestResponse::macro('log', function (string $file = 'test-response.json'): TestResponse {
|
||||
/** @var TestResponse $this */
|
||||
file_put_contents(storage_path('logs/' . $file), $this->getContent());
|
||||
|
||||
return $this;
|
||||
});
|
||||
|
||||
self::createSandbox();
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in a new issue