feat: persist queue and playback state (closes #1675) (#1735)

This commit is contained in:
Phan An 2024-01-01 12:40:21 +01:00 committed by GitHub
parent ed6f01ad52
commit 5f0eaf228d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
35 changed files with 679 additions and 91 deletions

View file

@ -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)),
]);
}
}

View file

@ -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)

View 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',
];
}
}

View 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')],
];
}
}

View 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
View 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);
}
}

View file

@ -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));
});
}
}

View file

@ -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
{

View file

@ -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

View file

@ -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> */

View file

@ -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;

View file

@ -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();

View 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,
]);
}
}

View file

@ -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
View 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);
}
}

View 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,
];
}
}

View file

@ -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();
});
}
};

View file

@ -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!' })
})
})

View file

@ -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')

View file

@ -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[]) {

View file

@ -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()
})
}
}

View file

@ -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()

View file

@ -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')

View file

@ -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) {

View file

@ -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

View file

@ -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) })
})
}
}

View file

@ -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)
}
}
}

View file

@ -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
}),

View file

@ -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[]

View file

@ -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']);

View file

@ -21,6 +21,11 @@ class DataTest extends TestCase
'latest_version',
'song_count',
'song_length',
'queue_state' => [
'songs',
'current_song',
'playback_position',
],
]);
}
}

View file

@ -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();

View file

@ -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.

View 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);
}
}

View file

@ -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();
}