mirror of
https://github.com/koel/koel
synced 2024-11-10 06:34:14 +00:00
feat: allow deleting songs from file system (closes #1478)
This commit is contained in:
parent
4c7e2644a3
commit
6791624ca5
77 changed files with 423 additions and 317 deletions
|
@ -115,6 +115,9 @@ OUTPUT_BIT_RATE=128
|
||||||
# environment, such a download will (silently) fail.
|
# environment, such a download will (silently) fail.
|
||||||
ALLOW_DOWNLOAD=true
|
ALLOW_DOWNLOAD=true
|
||||||
|
|
||||||
|
# Whether to create a backup of a song instead of deleting it from the filesystem.
|
||||||
|
# If true, the song will simply be renamed into a .bak file.
|
||||||
|
BACKUP_ON_DELETE=true
|
||||||
|
|
||||||
# Koel attempts to detect if your website use HTTPS and generates secure URLs accordingly.
|
# Koel attempts to detect if your website use HTTPS and generates secure URLs accordingly.
|
||||||
# If this attempts for any reason, you can force it by setting this value to true.
|
# If this attempts for any reason, you can force it by setting this value to true.
|
||||||
|
|
20
app/Http/Controllers/V6/API/DeleteSongsController.php
Normal file
20
app/Http/Controllers/V6/API/DeleteSongsController.php
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\V6\API;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\V6\API\DeleteSongsRequest;
|
||||||
|
use App\Services\SongService;
|
||||||
|
use Illuminate\Contracts\Auth\Authenticatable;
|
||||||
|
|
||||||
|
class DeleteSongsController extends Controller
|
||||||
|
{
|
||||||
|
public function __invoke(DeleteSongsRequest $request, SongService $service, Authenticatable $user)
|
||||||
|
{
|
||||||
|
$this->authorize('admin', $user);
|
||||||
|
|
||||||
|
$service->deleteSongs($request->songs);
|
||||||
|
|
||||||
|
return response()->noContent();
|
||||||
|
}
|
||||||
|
}
|
|
@ -3,7 +3,7 @@
|
||||||
namespace App\Http\Controllers\V6\API;
|
namespace App\Http\Controllers\V6\API;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Http\Controllers\V6\Requests\SearchRequest;
|
use App\Http\Requests\V6\API\SearchRequest;
|
||||||
use App\Http\Resources\ExcerptSearchResource;
|
use App\Http\Resources\ExcerptSearchResource;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\V6\SearchService;
|
use App\Services\V6\SearchService;
|
||||||
|
|
|
@ -3,8 +3,8 @@
|
||||||
namespace App\Http\Controllers\V6\API;
|
namespace App\Http\Controllers\V6\API;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Http\Controllers\V6\Requests\PlaylistFolderStoreRequest;
|
use App\Http\Requests\V6\API\PlaylistFolderStoreRequest;
|
||||||
use App\Http\Controllers\V6\Requests\PlaylistFolderUpdateRequest;
|
use App\Http\Requests\V6\API\PlaylistFolderUpdateRequest;
|
||||||
use App\Http\Resources\PlaylistFolderResource;
|
use App\Http\Resources\PlaylistFolderResource;
|
||||||
use App\Models\PlaylistFolder;
|
use App\Models\PlaylistFolder;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
|
|
@ -3,8 +3,8 @@
|
||||||
namespace App\Http\Controllers\V6\API;
|
namespace App\Http\Controllers\V6\API;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Http\Controllers\V6\Requests\PlaylistFolderPlaylistDestroyRequest;
|
use App\Http\Requests\V6\API\PlaylistFolderPlaylistDestroyRequest;
|
||||||
use App\Http\Controllers\V6\Requests\PlaylistFolderPlaylistStoreRequest;
|
use App\Http\Requests\V6\API\PlaylistFolderPlaylistStoreRequest;
|
||||||
use App\Models\PlaylistFolder;
|
use App\Models\PlaylistFolder;
|
||||||
use App\Services\PlaylistFolderService;
|
use App\Services\PlaylistFolderService;
|
||||||
use Illuminate\Support\Arr;
|
use Illuminate\Support\Arr;
|
||||||
|
|
|
@ -3,8 +3,8 @@
|
||||||
namespace App\Http\Controllers\V6\API;
|
namespace App\Http\Controllers\V6\API;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Http\Controllers\V6\Requests\AddSongsToPlaylistRequest;
|
use App\Http\Requests\V6\API\AddSongsToPlaylistRequest;
|
||||||
use App\Http\Controllers\V6\Requests\RemoveSongsFromPlaylistRequest;
|
use App\Http\Requests\V6\API\RemoveSongsFromPlaylistRequest;
|
||||||
use App\Http\Resources\SongResource;
|
use App\Http\Resources\SongResource;
|
||||||
use App\Models\Playlist;
|
use App\Models\Playlist;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
|
|
@ -3,7 +3,7 @@
|
||||||
namespace App\Http\Controllers\V6\API;
|
namespace App\Http\Controllers\V6\API;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Http\Controllers\V6\Requests\QueueFetchSongRequest;
|
use App\Http\Requests\V6\API\QueueFetchSongRequest;
|
||||||
use App\Http\Resources\SongResource;
|
use App\Http\Resources\SongResource;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Repositories\SongRepository;
|
use App\Repositories\SongRepository;
|
||||||
|
|
|
@ -3,7 +3,7 @@
|
||||||
namespace App\Http\Controllers\V6\API;
|
namespace App\Http\Controllers\V6\API;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Http\Controllers\V6\Requests\SongListRequest;
|
use App\Http\Requests\V6\API\SongListRequest;
|
||||||
use App\Http\Resources\SongResource;
|
use App\Http\Resources\SongResource;
|
||||||
use App\Models\Song;
|
use App\Models\Song;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
|
|
@ -3,7 +3,7 @@
|
||||||
namespace App\Http\Controllers\V6\API;
|
namespace App\Http\Controllers\V6\API;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Http\Controllers\V6\Requests\SearchRequest;
|
use App\Http\Requests\V6\API\SearchRequest;
|
||||||
use App\Http\Resources\SongResource;
|
use App\Http\Resources\SongResource;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\V6\SearchService;
|
use App\Services\V6\SearchService;
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace App\Http\Controllers\V6\Requests;
|
namespace App\Http\Requests\V6\API;
|
||||||
|
|
||||||
use App\Http\Requests\API\Request;
|
use App\Http\Requests\API\Request;
|
||||||
use App\Models\Song;
|
use App\Models\Song;
|
17
app/Http/Requests/V6/API/DeleteSongsRequest.php
Normal file
17
app/Http/Requests/V6/API/DeleteSongsRequest.php
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\V6\API;
|
||||||
|
|
||||||
|
use App\Http\Requests\API\Request;
|
||||||
|
|
||||||
|
/** @property-read array<string> $songs */
|
||||||
|
class DeleteSongsRequest extends Request
|
||||||
|
{
|
||||||
|
/** @return array<mixed> */
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'songs.*' => 'required|exists:songs,id',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,6 +1,6 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace App\Http\Controllers\V6\Requests;
|
namespace App\Http\Requests\V6\API;
|
||||||
|
|
||||||
use App\Http\Requests\API\Request;
|
use App\Http\Requests\API\Request;
|
||||||
use App\Models\Playlist;
|
use App\Models\Playlist;
|
|
@ -1,6 +1,6 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace App\Http\Controllers\V6\Requests;
|
namespace App\Http\Requests\V6\API;
|
||||||
|
|
||||||
use App\Http\Requests\API\Request;
|
use App\Http\Requests\API\Request;
|
||||||
use App\Models\Playlist;
|
use App\Models\Playlist;
|
|
@ -1,6 +1,6 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace App\Http\Controllers\V6\Requests;
|
namespace App\Http\Requests\V6\API;
|
||||||
|
|
||||||
use App\Http\Requests\API\Request;
|
use App\Http\Requests\API\Request;
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace App\Http\Controllers\V6\Requests;
|
namespace App\Http\Requests\V6\API;
|
||||||
|
|
||||||
use App\Http\Requests\API\Request;
|
use App\Http\Requests\API\Request;
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace App\Http\Controllers\V6\Requests;
|
namespace App\Http\Requests\V6\API;
|
||||||
|
|
||||||
use App\Http\Requests\API\Request;
|
use App\Http\Requests\API\Request;
|
||||||
use App\Repositories\SongRepository;
|
use App\Repositories\SongRepository;
|
|
@ -1,6 +1,6 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace App\Http\Controllers\V6\Requests;
|
namespace App\Http\Requests\V6\API;
|
||||||
|
|
||||||
use App\Http\Requests\API\Request;
|
use App\Http\Requests\API\Request;
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace App\Http\Controllers\V6\Requests;
|
namespace App\Http\Requests\V6\API;
|
||||||
|
|
||||||
use App\Http\Requests\API\Request;
|
use App\Http\Requests\API\Request;
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace App\Http\Controllers\V6\Requests;
|
namespace App\Http\Requests\V6\API;
|
||||||
|
|
||||||
use App\Http\Requests\API\Request;
|
use App\Http\Requests\API\Request;
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace App\Http\Controllers\V6\Requests;
|
namespace App\Http\Requests\V6\API;
|
||||||
|
|
||||||
use App\Http\Requests\API\Request;
|
use App\Http\Requests\API\Request;
|
||||||
|
|
|
@ -2,17 +2,21 @@
|
||||||
|
|
||||||
namespace App\Services;
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Events\LibraryChanged;
|
||||||
use App\Models\Album;
|
use App\Models\Album;
|
||||||
use App\Models\Artist;
|
use App\Models\Artist;
|
||||||
use App\Models\Song;
|
use App\Models\Song;
|
||||||
use App\Repositories\SongRepository;
|
use App\Repositories\SongRepository;
|
||||||
use App\Values\SongUpdateData;
|
use App\Values\SongUpdateData;
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
class SongService
|
class SongService
|
||||||
{
|
{
|
||||||
public function __construct(private SongRepository $songRepository)
|
public function __construct(private SongRepository $songRepository, private LoggerInterface $logger)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -81,4 +85,38 @@ class SongService
|
||||||
|
|
||||||
return $this->songRepository->getOne($song->id);
|
return $this->songRepository->getOne($song->id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string>|string $ids
|
||||||
|
*/
|
||||||
|
public function deleteSongs(array|string $ids): void
|
||||||
|
{
|
||||||
|
$ids = Arr::wrap($ids);
|
||||||
|
|
||||||
|
DB::transaction(function () use ($ids): void {
|
||||||
|
$shouldBackUp = config('koel.backup_on_delete');
|
||||||
|
|
||||||
|
/** @var Collection|array<array-key, Song> $songs */
|
||||||
|
$songs = Song::query()->findMany($ids);
|
||||||
|
|
||||||
|
Song::destroy($ids);
|
||||||
|
|
||||||
|
$songs->each(function (Song $song) use ($shouldBackUp): void {
|
||||||
|
try {
|
||||||
|
if ($shouldBackUp) {
|
||||||
|
rename($song->path, $song->path . '.bak');
|
||||||
|
} else {
|
||||||
|
unlink($song->path);
|
||||||
|
}
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$this->logger->error('Failed to remove song file', [
|
||||||
|
'path' => $song->path,
|
||||||
|
'exception' => $e,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
event(new LibraryChanged());
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -126,10 +126,9 @@ return [
|
||||||
],
|
],
|
||||||
|
|
||||||
'cache_media' => env('CACHE_MEDIA', true),
|
'cache_media' => env('CACHE_MEDIA', true),
|
||||||
|
|
||||||
'memory_limit' => env('MEMORY_LIMIT'),
|
'memory_limit' => env('MEMORY_LIMIT'),
|
||||||
|
|
||||||
'force_https' => env('FORCE_HTTPS', false),
|
'force_https' => env('FORCE_HTTPS', false),
|
||||||
|
'backup_on_delete' => env('BACKUP_ON_DELETE', true),
|
||||||
|
|
||||||
'misc' => [
|
'misc' => [
|
||||||
'home_url' => 'https://koel.dev',
|
'home_url' => 'https://koel.dev',
|
||||||
|
|
|
@ -53,7 +53,7 @@ import { orderBy } from 'lodash'
|
||||||
import { onMounted, ref } from 'vue'
|
import { onMounted, ref } from 'vue'
|
||||||
import { isDemo } from '@/utils'
|
import { isDemo } from '@/utils'
|
||||||
import { useNewVersionNotification } from '@/composables'
|
import { useNewVersionNotification } from '@/composables'
|
||||||
import { httpService } from '@/services'
|
import { http } from '@/services'
|
||||||
|
|
||||||
import Btn from '@/components/ui/Btn.vue'
|
import Btn from '@/components/ui/Btn.vue'
|
||||||
|
|
||||||
|
@ -75,7 +75,7 @@ const emit = defineEmits(['close'])
|
||||||
const close = () => emit('close')
|
const close = () => emit('close')
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
credits.value = isDemo ? orderBy(await httpService.get<DemoCredits[]>('demo/credits'), 'name') : []
|
credits.value = isDemo ? orderBy(await http.get<DemoCredits[]>('demo/credits'), 'name') : []
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
@ -48,7 +48,7 @@
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { faLastfm } from '@fortawesome/free-brands-svg-icons'
|
import { faLastfm } from '@fortawesome/free-brands-svg-icons'
|
||||||
import { computed, defineAsyncComponent } from 'vue'
|
import { computed, defineAsyncComponent } from 'vue'
|
||||||
import { authService, httpService } from '@/services'
|
import { authService, http } from '@/services'
|
||||||
import { forceReloadWindow } from '@/utils'
|
import { forceReloadWindow } from '@/utils'
|
||||||
import { useAuthorization, useThirdPartyServices } from '@/composables'
|
import { useAuthorization, useThirdPartyServices } from '@/composables'
|
||||||
|
|
||||||
|
@ -71,7 +71,7 @@ const connect = () => window.open(
|
||||||
)
|
)
|
||||||
|
|
||||||
const disconnect = async () => {
|
const disconnect = async () => {
|
||||||
await httpService.delete('lastfm/disconnect')
|
await http.delete('lastfm/disconnect')
|
||||||
forceReloadWindow()
|
forceReloadWindow()
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -53,11 +53,6 @@ new class extends UnitTestCase {
|
||||||
}
|
}
|
||||||
|
|
||||||
protected test () {
|
protected test () {
|
||||||
it('renders', async () => {
|
|
||||||
const { html } = await this.renderComponent()
|
|
||||||
expect(html()).toMatchSnapshot()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('shows and hides info', async () => {
|
it('shows and hides info', async () => {
|
||||||
const { getByTitle, getByTestId, queryByTestId, html } = await this.renderComponent()
|
const { getByTitle, getByTestId, queryByTestId, html } = await this.renderComponent()
|
||||||
expect(queryByTestId('album-info')).toBeNull()
|
expect(queryByTestId('album-info')).toBeNull()
|
||||||
|
|
|
@ -13,8 +13,8 @@
|
||||||
<template v-slot:meta>
|
<template v-slot:meta>
|
||||||
<a v-if="isNormalArtist" :href="`#!/artist/${album.artist_id}`" class="artist">{{ album.artist_name }}</a>
|
<a v-if="isNormalArtist" :href="`#!/artist/${album.artist_id}`" class="artist">{{ album.artist_name }}</a>
|
||||||
<span class="nope" v-else>{{ album.artist_name }}</span>
|
<span class="nope" v-else>{{ album.artist_name }}</span>
|
||||||
<span>{{ pluralize(album.song_count, 'song') }}</span>
|
<span>{{ pluralize(songs, 'song') }}</span>
|
||||||
<span>{{ secondsToHis(album.length) }}</span>
|
<span>{{ duration }}</span>
|
||||||
<a v-if="useLastfm" class="info" href title="View album information" @click.prevent="showInfo">Info</a>
|
<a v-if="useLastfm" class="info" href title="View album information" @click.prevent="showInfo">Info</a>
|
||||||
|
|
||||||
<a
|
<a
|
||||||
|
@ -51,7 +51,7 @@
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { computed, defineAsyncComponent, onMounted, ref, toRef, toRefs } from 'vue'
|
import { computed, defineAsyncComponent, onMounted, ref, toRef, toRefs } from 'vue'
|
||||||
import { eventBus, logger, pluralize, requireInjection, secondsToHis } from '@/utils'
|
import { eventBus, logger, pluralize, requireInjection } from '@/utils'
|
||||||
import { albumStore, artistStore, commonStore, songStore } from '@/stores'
|
import { albumStore, artistStore, commonStore, songStore } from '@/stores'
|
||||||
import { downloadService } from '@/services'
|
import { downloadService } from '@/services'
|
||||||
import { useSongList } from '@/composables'
|
import { useSongList } from '@/composables'
|
||||||
|
@ -84,6 +84,7 @@ const {
|
||||||
songList,
|
songList,
|
||||||
showingControls,
|
showingControls,
|
||||||
isPhone,
|
isPhone,
|
||||||
|
duration,
|
||||||
onPressEnter,
|
onPressEnter,
|
||||||
playAll,
|
playAll,
|
||||||
playSelected,
|
playSelected,
|
||||||
|
|
|
@ -52,11 +52,6 @@ new class extends UnitTestCase {
|
||||||
}
|
}
|
||||||
|
|
||||||
protected test () {
|
protected test () {
|
||||||
it('renders', async () => {
|
|
||||||
const { html } = await this.renderComponent()
|
|
||||||
expect(html()).toMatchSnapshot()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('shows and hides info', async () => {
|
it('shows and hides info', async () => {
|
||||||
const { getByTitle, getByTestId, queryByTestId } = await this.renderComponent()
|
const { getByTitle, getByTestId, queryByTestId } = await this.renderComponent()
|
||||||
expect(queryByTestId('artist-info')).toBeNull()
|
expect(queryByTestId('artist-info')).toBeNull()
|
||||||
|
|
|
@ -12,8 +12,8 @@
|
||||||
|
|
||||||
<template v-slot:meta>
|
<template v-slot:meta>
|
||||||
<span>{{ pluralize(artist.album_count, 'album') }}</span>
|
<span>{{ pluralize(artist.album_count, 'album') }}</span>
|
||||||
<span>{{ pluralize(artist.song_count, 'song') }}</span>
|
<span>{{ pluralize(songs, 'song') }}</span>
|
||||||
<span>{{ secondsToHis(artist.length) }}</span>
|
<span>{{ duration }}</span>
|
||||||
<a v-if="useLastfm" class="info" href title="View artist information" @click.prevent="showInfo">Info</a>
|
<a v-if="useLastfm" class="info" href title="View artist information" @click.prevent="showInfo">Info</a>
|
||||||
|
|
||||||
<a
|
<a
|
||||||
|
@ -51,7 +51,7 @@
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { defineAsyncComponent, onMounted, ref, toRef, toRefs } from 'vue'
|
import { defineAsyncComponent, onMounted, ref, toRef, toRefs } from 'vue'
|
||||||
import { eventBus, logger, pluralize, requireInjection, secondsToHis } from '@/utils'
|
import { eventBus, logger, pluralize, requireInjection } from '@/utils'
|
||||||
import { artistStore, commonStore, songStore } from '@/stores'
|
import { artistStore, commonStore, songStore } from '@/stores'
|
||||||
import { downloadService } from '@/services'
|
import { downloadService } from '@/services'
|
||||||
import { useSongList, useThirdPartyServices } from '@/composables'
|
import { useSongList, useThirdPartyServices } from '@/composables'
|
||||||
|
@ -81,6 +81,7 @@ const {
|
||||||
songList,
|
songList,
|
||||||
showingControls,
|
showingControls,
|
||||||
isPhone,
|
isPhone,
|
||||||
|
duration,
|
||||||
onPressEnter,
|
onPressEnter,
|
||||||
playAll,
|
playAll,
|
||||||
playSelected,
|
playSelected,
|
||||||
|
|
|
@ -1,8 +1,10 @@
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { expect, it } from 'vitest'
|
import { expect, it } from 'vitest'
|
||||||
import UnitTestCase from '@/__tests__/UnitTestCase'
|
import UnitTestCase from '@/__tests__/UnitTestCase'
|
||||||
import { commonStore } from '@/stores'
|
import { commonStore, overviewStore } from '@/stores'
|
||||||
import { ActiveScreenKey } from '@/symbols'
|
import { ActiveScreenKey } from '@/symbols'
|
||||||
|
import { EventName } from '@/config'
|
||||||
|
import { eventBus } from '@/utils'
|
||||||
import HomeScreen from './HomeScreen.vue'
|
import HomeScreen from './HomeScreen.vue'
|
||||||
|
|
||||||
new class extends UnitTestCase {
|
new class extends UnitTestCase {
|
||||||
|
@ -38,5 +40,15 @@ new class extends UnitTestCase {
|
||||||
|
|
||||||
expect(queryByTestId('screen-empty-state')).toBeNull()
|
expect(queryByTestId('screen-empty-state')).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it.each<[EventName]>([['SONGS_UPDATED'], ['SONGS_DELETED']])
|
||||||
|
('refreshes the overviews on %s event', (eventName) => {
|
||||||
|
const refreshMock = this.mock(overviewStore, 'refresh')
|
||||||
|
this.renderComponent()
|
||||||
|
|
||||||
|
eventBus.emit(eventName)
|
||||||
|
|
||||||
|
expect(refreshMock).toHaveBeenCalled()
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -37,7 +37,7 @@
|
||||||
import { faVolumeOff } from '@fortawesome/free-solid-svg-icons'
|
import { faVolumeOff } from '@fortawesome/free-solid-svg-icons'
|
||||||
import { sample } from 'lodash'
|
import { sample } from 'lodash'
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { noop } from '@/utils'
|
import { eventBus, noop } from '@/utils'
|
||||||
import { commonStore, overviewStore, userStore } from '@/stores'
|
import { commonStore, overviewStore, userStore } from '@/stores'
|
||||||
import { useAuthorization, useInfiniteScroll, useScreen } from '@/composables'
|
import { useAuthorization, useInfiniteScroll, useScreen } from '@/composables'
|
||||||
|
|
||||||
|
@ -72,6 +72,8 @@ const libraryEmpty = computed(() => commonStore.state.song_length === 0)
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
let initialized = false
|
let initialized = false
|
||||||
|
|
||||||
|
eventBus.on(['SONGS_DELETED', 'SONGS_UPDATED'], () => overviewStore.refresh())
|
||||||
|
|
||||||
useScreen('Home').onScreenActivated(async () => {
|
useScreen('Home').onScreenActivated(async () => {
|
||||||
if (!initialized) {
|
if (!initialized) {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
|
|
|
@ -1,33 +0,0 @@
|
||||||
// Vitest Snapshot v1
|
|
||||||
|
|
||||||
exports[`renders 1`] = `
|
|
||||||
<section id="albumWrapper" data-v-748fe44c="">
|
|
||||||
<!--v-if-->
|
|
||||||
<header class="screen-header expanded" data-v-661f8f0d="" data-v-748fe44c="">
|
|
||||||
<aside class="thumbnail-wrapper" data-v-661f8f0d=""><span class="cover" data-testid="album-artist-thumbnail" data-v-901ba52c="" data-v-748fe44c=""><a class="control control-play" href="" role="button" data-v-901ba52c=""><span class="hidden" data-v-901ba52c="">Play all songs in the album Led Zeppelin IV</span><span class="icon" data-v-901ba52c=""></span></a></span></aside>
|
|
||||||
<main data-v-661f8f0d="">
|
|
||||||
<div class="heading-wrapper" data-v-661f8f0d="">
|
|
||||||
<h1 class="name" data-v-661f8f0d="">Led Zeppelin IV
|
|
||||||
<!--v-if-->
|
|
||||||
</h1><span class="meta text-secondary" data-v-661f8f0d=""><a href="#!/artist/123" class="artist" data-v-748fe44c="">Led Zeppelin</a><span data-v-748fe44c="">10 songs</span><span data-v-748fe44c="">26:43</span><a class="info" href="" title="View album information" data-v-748fe44c="">Info</a><a class="download" href="" role="button" title="Download all songs in album" data-v-748fe44c=""> Download All </a></span>
|
|
||||||
</div>
|
|
||||||
<div class="song-list-controls" data-testid="song-list-controls" data-v-cee28c08="" data-v-748fe44c=""><span class="btn-group" uppercased="" data-v-5d6fa912="" data-v-cee28c08=""><button type="button" class="btn-shuffle-all" data-testid="btn-shuffle-all" orange="" title="Shuffle all songs" data-v-27deb898="" data-v-cee28c08=""><br data-testid="icon" icon="[object Object]" data-v-cee28c08=""> All </button><!--v-if--><!--v-if--><!--v-if--><!--v-if--></span>
|
|
||||||
<div class="add-to" data-testid="add-to-menu" tabindex="0" data-v-0351ff38="" data-v-cee28c08="" style="display: none;">
|
|
||||||
<section class="existing-playlists" data-v-0351ff38="">
|
|
||||||
<p data-v-0351ff38="">Add 0 songs to</p>
|
|
||||||
<ul data-v-0351ff38="">
|
|
||||||
<li data-testid="queue" tabindex="0" data-v-0351ff38="">Queue</li>
|
|
||||||
<li class="favorites" data-testid="add-to-favorites" tabindex="0" data-v-0351ff38=""> Favorites </li>
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
<section class="new-playlist" data-testid="new-playlist" data-v-0351ff38="">
|
|
||||||
<p data-v-0351ff38="">or create a new playlist</p>
|
|
||||||
<form class="form-save form-simple form-new-playlist" data-v-0351ff38=""><input data-testid="new-playlist-name" placeholder="Playlist name" required="" type="text" data-v-0351ff38=""><button type="submit" title="Save" data-v-27deb898="" data-v-0351ff38="">⏎</button></form>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</header><br data-testid="song-list" data-v-748fe44c="">
|
|
||||||
<!--v-if-->
|
|
||||||
</section>
|
|
||||||
`;
|
|
|
@ -1,33 +0,0 @@
|
||||||
// Vitest Snapshot v1
|
|
||||||
|
|
||||||
exports[`renders 1`] = `
|
|
||||||
<section id="artistWrapper" data-v-dceda15c="">
|
|
||||||
<!--v-if-->
|
|
||||||
<header class="screen-header expanded" data-v-661f8f0d="" data-v-dceda15c="">
|
|
||||||
<aside class="thumbnail-wrapper" data-v-661f8f0d=""><span class="cover" data-testid="album-artist-thumbnail" data-v-901ba52c="" data-v-dceda15c=""><a class="control control-play" href="" role="button" data-v-901ba52c=""><span class="hidden" data-v-901ba52c="">Play all songs by Led Zeppelin</span><span class="icon" data-v-901ba52c=""></span></a></span></aside>
|
|
||||||
<main data-v-661f8f0d="">
|
|
||||||
<div class="heading-wrapper" data-v-661f8f0d="">
|
|
||||||
<h1 class="name" data-v-661f8f0d="">Led Zeppelin
|
|
||||||
<!--v-if-->
|
|
||||||
</h1><span class="meta text-secondary" data-v-661f8f0d=""><span data-v-dceda15c="">12 albums</span><span data-v-dceda15c="">53 songs</span><span data-v-dceda15c="">11:16:43</span><a class="info" href="" title="View artist information" data-v-dceda15c="">Info</a><a class="download" href="" role="button" title="Download all songs by this artist" data-v-dceda15c=""> Download All </a></span>
|
|
||||||
</div>
|
|
||||||
<div class="song-list-controls" data-testid="song-list-controls" data-v-cee28c08="" data-v-dceda15c=""><span class="btn-group" uppercased="" data-v-5d6fa912="" data-v-cee28c08=""><button type="button" class="btn-shuffle-all" data-testid="btn-shuffle-all" orange="" title="Shuffle all songs" data-v-27deb898="" data-v-cee28c08=""><br data-testid="icon" icon="[object Object]" data-v-cee28c08=""> All </button><!--v-if--><!--v-if--><!--v-if--><!--v-if--></span>
|
|
||||||
<div class="add-to" data-testid="add-to-menu" tabindex="0" data-v-0351ff38="" data-v-cee28c08="" style="display: none;">
|
|
||||||
<section class="existing-playlists" data-v-0351ff38="">
|
|
||||||
<p data-v-0351ff38="">Add 0 songs to</p>
|
|
||||||
<ul data-v-0351ff38="">
|
|
||||||
<li data-testid="queue" tabindex="0" data-v-0351ff38="">Queue</li>
|
|
||||||
<li class="favorites" data-testid="add-to-favorites" tabindex="0" data-v-0351ff38=""> Favorites </li>
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
<section class="new-playlist" data-testid="new-playlist" data-v-0351ff38="">
|
|
||||||
<p data-v-0351ff38="">or create a new playlist</p>
|
|
||||||
<form class="form-save form-simple form-new-playlist" data-v-0351ff38=""><input data-testid="new-playlist-name" placeholder="Playlist name" required="" type="text" data-v-0351ff38=""><button type="submit" title="Save" data-v-27deb898="" data-v-0351ff38="">⏎</button></form>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</header><br data-testid="song-list" data-v-dceda15c="">
|
|
||||||
<!--v-if-->
|
|
||||||
</section>
|
|
||||||
`;
|
|
|
@ -1,15 +1,15 @@
|
||||||
import { expect, it } from 'vitest'
|
import { expect, it } from 'vitest'
|
||||||
import { recentlyPlayedStore } from '@/stores'
|
|
||||||
import UnitTestCase from '@/__tests__/UnitTestCase'
|
import UnitTestCase from '@/__tests__/UnitTestCase'
|
||||||
import factory from '@/__tests__/factory'
|
import factory from '@/__tests__/factory'
|
||||||
import RecentlyPlayedSongs from './RecentlyPlayedSongs.vue'
|
|
||||||
import { fireEvent } from '@testing-library/vue'
|
import { fireEvent } from '@testing-library/vue'
|
||||||
import router from '@/router'
|
import router from '@/router'
|
||||||
|
import { overviewStore } from '@/stores'
|
||||||
|
import RecentlyPlayedSongs from './RecentlyPlayedSongs.vue'
|
||||||
|
|
||||||
new class extends UnitTestCase {
|
new class extends UnitTestCase {
|
||||||
protected test () {
|
protected test () {
|
||||||
it('displays the songs', () => {
|
it('displays the songs', () => {
|
||||||
recentlyPlayedStore.excerptState.songs = factory<Song>('song', 6)
|
overviewStore.state.recentlyPlayed = factory<Song>('song', 6)
|
||||||
expect(this.render(RecentlyPlayedSongs).getAllByTestId('song-card')).toHaveLength(6)
|
expect(this.render(RecentlyPlayedSongs).getAllByTestId('song-card')).toHaveLength(6)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
@ -33,7 +33,7 @@
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { toRef, toRefs } from 'vue'
|
import { toRef, toRefs } from 'vue'
|
||||||
import router from '@/router'
|
import router from '@/router'
|
||||||
import { recentlyPlayedStore } from '@/stores'
|
import { overviewStore } from '@/stores'
|
||||||
|
|
||||||
import Btn from '@/components/ui/Btn.vue'
|
import Btn from '@/components/ui/Btn.vue'
|
||||||
import SongCard from '@/components/song/SongCard.vue'
|
import SongCard from '@/components/song/SongCard.vue'
|
||||||
|
@ -42,7 +42,7 @@ import SongCardSkeleton from '@/components/ui/skeletons/SongCardSkeleton.vue'
|
||||||
const props = withDefaults(defineProps<{ loading?: boolean }>(), { loading: false })
|
const props = withDefaults(defineProps<{ loading?: boolean }>(), { loading: false })
|
||||||
const { loading } = toRefs(props)
|
const { loading } = toRefs(props)
|
||||||
|
|
||||||
const songs = toRef(recentlyPlayedStore.excerptState, 'songs')
|
const songs = toRef(overviewStore.state, 'recentlyPlayed')
|
||||||
|
|
||||||
const goToRecentlyPlayedScreen = () => router.go('recently-played')
|
const goToRecentlyPlayedScreen = () => router.go('recently-played')
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -84,6 +84,7 @@
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { faSearch } from '@fortawesome/free-solid-svg-icons'
|
import { faSearch } from '@fortawesome/free-solid-svg-icons'
|
||||||
|
import { intersectionBy } from 'lodash'
|
||||||
import { ref, toRef } from 'vue'
|
import { ref, toRef } from 'vue'
|
||||||
import { eventBus } from '@/utils'
|
import { eventBus } from '@/utils'
|
||||||
import { searchStore } from '@/stores'
|
import { searchStore } from '@/stores'
|
||||||
|
@ -104,11 +105,23 @@ const searching = ref(false)
|
||||||
|
|
||||||
const goToSongResults = () => router.go(`search/songs/${q.value}`)
|
const goToSongResults = () => router.go(`search/songs/${q.value}`)
|
||||||
|
|
||||||
eventBus.on('SEARCH_KEYWORDS_CHANGED', async (_q: string) => {
|
const doSearch = async () => {
|
||||||
q.value = _q
|
|
||||||
searching.value = true
|
searching.value = true
|
||||||
await searchStore.excerptSearch(q.value)
|
await searchStore.excerptSearch(q.value)
|
||||||
searching.value = false
|
searching.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
eventBus.on({
|
||||||
|
SEARCH_KEYWORDS_CHANGED: async (_q: string) => {
|
||||||
|
q.value = _q
|
||||||
|
await doSearch()
|
||||||
|
},
|
||||||
|
|
||||||
|
SONGS_DELETED: async (songs: Song[]) => {
|
||||||
|
if (intersectionBy(songs, excerpt.value.songs, 'id').length !== 0) {
|
||||||
|
await doSearch()
|
||||||
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { expect, it } from 'vitest'
|
import { expect, it } from 'vitest'
|
||||||
import factory from '@/__tests__/factory'
|
import factory from '@/__tests__/factory'
|
||||||
import UnitTestCase from '@/__tests__/UnitTestCase'
|
import UnitTestCase from '@/__tests__/UnitTestCase'
|
||||||
import { arrayify } from '@/utils'
|
import { arrayify, eventBus } from '@/utils'
|
||||||
import { EditSongFormInitialTabKey, SongsKey } from '@/symbols'
|
import { EditSongFormInitialTabKey, SongsKey } from '@/symbols'
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { fireEvent } from '@testing-library/vue'
|
import { fireEvent } from '@testing-library/vue'
|
||||||
|
@ -32,6 +32,7 @@ new class extends UnitTestCase {
|
||||||
protected test () {
|
protected test () {
|
||||||
it('edits a single song', async () => {
|
it('edits a single song', async () => {
|
||||||
const updateMock = this.mock(songStore, 'update')
|
const updateMock = this.mock(songStore, 'update')
|
||||||
|
const emitMock = this.mock(eventBus, 'emit')
|
||||||
const alertMock = this.mock(MessageToasterStub.value, 'success')
|
const alertMock = this.mock(MessageToasterStub.value, 'success')
|
||||||
|
|
||||||
const { html, getByTestId, getByRole } = await this.renderComponent(factory<Song>('song', {
|
const { html, getByTestId, getByRole } = await this.renderComponent(factory<Song>('song', {
|
||||||
|
@ -64,10 +65,12 @@ new class extends UnitTestCase {
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(alertMock).toHaveBeenCalledWith('Updated 1 song.')
|
expect(alertMock).toHaveBeenCalledWith('Updated 1 song.')
|
||||||
|
expect(emitMock).toHaveBeenCalledWith('SONGS_UPDATED')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('edits multiple songs', async () => {
|
it('edits multiple songs', async () => {
|
||||||
const updateMock = this.mock(songStore, 'update')
|
const updateMock = this.mock(songStore, 'update')
|
||||||
|
const emitMock = this.mock(eventBus, 'emit')
|
||||||
const alertMock = this.mock(MessageToasterStub.value, 'success')
|
const alertMock = this.mock(MessageToasterStub.value, 'success')
|
||||||
|
|
||||||
const { html, getByTestId, getByRole, queryByTestId } = await this.renderComponent(factory<Song>('song', 3))
|
const { html, getByTestId, getByRole, queryByTestId } = await this.renderComponent(factory<Song>('song', 3))
|
||||||
|
@ -93,6 +96,7 @@ new class extends UnitTestCase {
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(alertMock).toHaveBeenCalledWith('Updated 3 songs.')
|
expect(alertMock).toHaveBeenCalledWith('Updated 3 songs.')
|
||||||
|
expect(emitMock).toHaveBeenCalledWith('SONGS_UPDATED')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('displays artist name if all songs have the same artist', async () => {
|
it('displays artist name if all songs have the same artist', async () => {
|
||||||
|
|
|
@ -172,7 +172,7 @@
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { computed, onMounted, reactive, ref } from 'vue'
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
import { isEqual } from 'lodash'
|
import { isEqual } from 'lodash'
|
||||||
import { defaultCover, pluralize, requireInjection } from '@/utils'
|
import { defaultCover, eventBus, pluralize, requireInjection } from '@/utils'
|
||||||
import { songStore, SongUpdateData } from '@/stores'
|
import { songStore, SongUpdateData } from '@/stores'
|
||||||
import { DialogBoxKey, EditSongFormInitialTabKey, MessageToasterKey, SongsKey } from '@/symbols'
|
import { DialogBoxKey, EditSongFormInitialTabKey, MessageToasterKey, SongsKey } from '@/symbols'
|
||||||
|
|
||||||
|
@ -287,6 +287,7 @@ const submit = async () => {
|
||||||
try {
|
try {
|
||||||
await songStore.update(mutatedSongs.value, formData)
|
await songStore.update(mutatedSongs.value, formData)
|
||||||
toaster.value.success(`Updated ${pluralize(mutatedSongs.value, 'song')}.`)
|
toaster.value.success(`Updated ${pluralize(mutatedSongs.value, 'song')}.`)
|
||||||
|
eventBus.emit('SONGS_UPDATED')
|
||||||
close()
|
close()
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
|
|
|
@ -2,11 +2,11 @@ import { expect, it } from 'vitest'
|
||||||
import factory from '@/__tests__/factory'
|
import factory from '@/__tests__/factory'
|
||||||
import UnitTestCase from '@/__tests__/UnitTestCase'
|
import UnitTestCase from '@/__tests__/UnitTestCase'
|
||||||
import { arrayify, eventBus } from '@/utils'
|
import { arrayify, eventBus } from '@/utils'
|
||||||
import { fireEvent } from '@testing-library/vue'
|
import { fireEvent, waitFor } from '@testing-library/vue'
|
||||||
import router from '@/router'
|
import router from '@/router'
|
||||||
import { downloadService, playbackService } from '@/services'
|
import { downloadService, playbackService } from '@/services'
|
||||||
import { favoriteStore, playlistStore, queueStore } from '@/stores'
|
import { favoriteStore, playlistStore, queueStore, songStore } from '@/stores'
|
||||||
import { MessageToasterStub } from '@/__tests__/stubs'
|
import { DialogBoxStub, MessageToasterStub } from '@/__tests__/stubs'
|
||||||
import SongContextMenu from './SongContextMenu.vue'
|
import SongContextMenu from './SongContextMenu.vue'
|
||||||
|
|
||||||
let songs: Song[]
|
let songs: Song[]
|
||||||
|
@ -179,5 +179,28 @@ new class extends UnitTestCase {
|
||||||
|
|
||||||
getByText('Copy Shareable URL')
|
getByText('Copy Shareable URL')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('deletes song', async () => {
|
||||||
|
const confirmMock = this.mock(DialogBoxStub.value, 'confirm', true)
|
||||||
|
const toasterMock = this.mock(MessageToasterStub.value, 'success')
|
||||||
|
const deleteMock = this.mock(songStore, 'deleteFromFilesystem')
|
||||||
|
const { getByText } = await this.actingAsAdmin().renderComponent()
|
||||||
|
|
||||||
|
const emitMock = this.mock(eventBus, 'emit')
|
||||||
|
|
||||||
|
await fireEvent.click(getByText('Delete from Filesystem'))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(confirmMock).toHaveBeenCalled()
|
||||||
|
expect(deleteMock).toHaveBeenCalledWith(songs)
|
||||||
|
expect(toasterMock).toHaveBeenCalledWith('Deleted 5 songs from the filesystem.')
|
||||||
|
expect(emitMock).toHaveBeenCalledWith('SONGS_DELETED', songs)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not have an option to delete songs if current user is not admin', async () => {
|
||||||
|
const { queryByText } = await this.actingAs().renderComponent()
|
||||||
|
expect(queryByText('Delete from Filesystem')).toBeNull()
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,57 +1,48 @@
|
||||||
<template>
|
<template>
|
||||||
<ContextMenuBase ref="base" data-testid="song-context-menu" extra-class="song-menu">
|
<ContextMenuBase ref="base" data-testid="song-context-menu" extra-class="song-menu">
|
||||||
<template v-if="onlyOneSongSelected">
|
<template v-if="onlyOneSongSelected">
|
||||||
<li class="playback" @click.stop.prevent="doPlayback">
|
<li @click.stop.prevent="doPlayback">
|
||||||
<span v-if="firstSongPlaying">Pause</span>
|
<span v-if="firstSongPlaying">Pause</span>
|
||||||
<span v-else>Play</span>
|
<span v-else>Play</span>
|
||||||
</li>
|
</li>
|
||||||
<li class="go-to-album" @click="viewAlbumDetails(songs[0].album_id)">Go to Album</li>
|
<li @click="viewAlbumDetails(songs[0].album_id)">Go to Album</li>
|
||||||
<li class="go-to-artist" @click="viewArtistDetails(songs[0].artist_id)">Go to Artist</li>
|
<li @click="viewArtistDetails(songs[0].artist_id)">Go to Artist</li>
|
||||||
</template>
|
</template>
|
||||||
<li class="has-sub">
|
<li class="has-sub">
|
||||||
Add To
|
Add To
|
||||||
<ul class="menu submenu menu-add-to">
|
<ul class="menu submenu menu-add-to">
|
||||||
<template v-if="queue.length">
|
<template v-if="queue.length">
|
||||||
<li v-if="currentSong" class="after-current" @click="queueSongsAfterCurrent">After Current Song</li>
|
<li v-if="currentSong" @click="queueSongsAfterCurrent">After Current Song</li>
|
||||||
<li class="bottom-queue" @click="queueSongsToBottom">Bottom of Queue</li>
|
<li @click="queueSongsToBottom">Bottom of Queue</li>
|
||||||
<li class="top-queue" @click="queueSongsToTop">Top of Queue</li>
|
<li @click="queueSongsToTop">Top of Queue</li>
|
||||||
</template>
|
</template>
|
||||||
<li v-else @click="queueSongsToBottom">Queue</li>
|
<li v-else @click="queueSongsToBottom">Queue</li>
|
||||||
<li class="separator"></li>
|
<li class="separator"/>
|
||||||
<li class="favorite" @click="addSongsToFavorite">Favorites</li>
|
<li @click="addSongsToFavorite">Favorites</li>
|
||||||
<li class="separator" v-if="normalPlaylists.length"></li>
|
<li class="separator" v-if="normalPlaylists.length"/>
|
||||||
<li
|
<li v-for="p in normalPlaylists" :key="p.id" @click="addSongsToExistingPlaylist(p)">{{ p.name }}</li>
|
||||||
class="playlist"
|
|
||||||
v-for="p in normalPlaylists"
|
|
||||||
:key="p.id"
|
|
||||||
@click="addSongsToExistingPlaylist(p)"
|
|
||||||
>{{ p.name }}
|
|
||||||
</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
<li class="open-edit-form" v-if="isAdmin" @click="openEditForm">Edit</li>
|
<li v-if="isAdmin" @click="openEditForm">Edit</li>
|
||||||
<li class="download" v-if="allowDownload" @click="download">Download</li>
|
<li v-if="allowDownload" @click="download">Download</li>
|
||||||
<li
|
<li v-if="onlyOneSongSelected" @click="copyUrl">Copy Shareable URL</li>
|
||||||
class="copy-url"
|
<li class="separator"/>
|
||||||
v-if="onlyOneSongSelected"
|
<li v-if="isAdmin" @click="deleteFromFilesystem">Delete from Filesystem</li>
|
||||||
@click="copyUrl"
|
|
||||||
>
|
|
||||||
Copy Shareable URL
|
|
||||||
</li>
|
|
||||||
</ContextMenuBase>
|
</ContextMenuBase>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { computed, ref, toRef } from 'vue'
|
import { computed, ref, toRef } from 'vue'
|
||||||
import { arrayify, copyText, eventBus, requireInjection } from '@/utils'
|
import { arrayify, copyText, eventBus, pluralize, requireInjection } from '@/utils'
|
||||||
import { commonStore, playlistStore, queueStore, songStore, userStore } from '@/stores'
|
import { commonStore, playlistStore, queueStore, songStore, userStore } from '@/stores'
|
||||||
import { downloadService, playbackService } from '@/services'
|
import { downloadService, playbackService } from '@/services'
|
||||||
import router from '@/router'
|
import router from '@/router'
|
||||||
import { useAuthorization, useContextMenu, useSongMenuMethods } from '@/composables'
|
import { useAuthorization, useContextMenu, useSongMenuMethods } from '@/composables'
|
||||||
import { MessageToasterKey } from '@/symbols'
|
import { DialogBoxKey, MessageToasterKey } from '@/symbols'
|
||||||
|
|
||||||
const { context, base, ContextMenuBase, open, close, trigger } = useContextMenu()
|
const { context, base, ContextMenuBase, open, close, trigger } = useContextMenu()
|
||||||
|
|
||||||
|
const dialogBox = requireInjection(DialogBoxKey)
|
||||||
const toaster = requireInjection(MessageToasterKey)
|
const toaster = requireInjection(MessageToasterKey)
|
||||||
const songs = ref<Song[]>([])
|
const songs = ref<Song[]>([])
|
||||||
|
|
||||||
|
@ -104,6 +95,18 @@ const copyUrl = () => trigger(() => {
|
||||||
toaster.value.success('URL copied to clipboard.')
|
toaster.value.success('URL copied to clipboard.')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const deleteFromFilesystem = () => trigger(async () => {
|
||||||
|
const confirmed = await dialogBox.value.confirm(
|
||||||
|
'Delete selected song(s) from the filesystem? This action is NOT reversible!'
|
||||||
|
)
|
||||||
|
|
||||||
|
if (confirmed) {
|
||||||
|
await songStore.deleteFromFilesystem(songs.value)
|
||||||
|
toaster.value.success(`Deleted ${pluralize(songs.value, 'song')} from the filesystem.`)
|
||||||
|
eventBus.emit('SONGS_DELETED', songs.value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
eventBus.on('SONG_CONTEXT_MENU_REQUESTED', async (e: MouseEvent, _songs: Song | Song[]) => {
|
eventBus.on('SONG_CONTEXT_MENU_REQUESTED', async (e: MouseEvent, _songs: Song | Song[]) => {
|
||||||
songs.value = arrayify(_songs)
|
songs.value = arrayify(_songs)
|
||||||
await open(e.pageY, e.pageX, { songs: songs.value })
|
await open(e.pageY, e.pageX, { songs: songs.value })
|
||||||
|
|
|
@ -36,16 +36,17 @@ const message = ref('')
|
||||||
|
|
||||||
const showCancelButton = computed(() => type.value === 'confirm')
|
const showCancelButton = computed(() => type.value === 'confirm')
|
||||||
|
|
||||||
const close = () => dialog.value.close()
|
// @ts-ignore
|
||||||
const cancel = () => dialog.value.dispatchEvent(new Event('cancel'))
|
const close = () => dialog.value?.close()
|
||||||
|
const cancel = () => dialog.value?.dispatchEvent(new Event('cancel'))
|
||||||
|
|
||||||
const waitForInput = () => new Promise(resolve => {
|
const waitForInput = () => new Promise(resolve => {
|
||||||
dialog.value.addEventListener('cancel', () => {
|
dialog.value?.addEventListener('cancel', () => {
|
||||||
close()
|
close()
|
||||||
resolve(false)
|
resolve(false)
|
||||||
}, { once: true })
|
}, { once: true })
|
||||||
|
|
||||||
dialog.value.querySelector('[name=ok]')!.addEventListener('click', () => {
|
dialog.value?.querySelector('[name=ok]')!.addEventListener('click', () => {
|
||||||
close()
|
close()
|
||||||
resolve(true)
|
resolve(true)
|
||||||
}, { once: true })
|
}, { once: true })
|
||||||
|
@ -56,6 +57,7 @@ const show = async (_type: DialogType, _message: string, _title: string = '') =>
|
||||||
message.value = _message
|
message.value = _message
|
||||||
title.value = _title
|
title.value = _title
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
dialog.value.showModal()
|
dialog.value.showModal()
|
||||||
|
|
||||||
return waitForInput()
|
return waitForInput()
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { orderBy, sampleSize, take } from 'lodash'
|
import { differenceBy, orderBy, sampleSize, take } from 'lodash'
|
||||||
import isMobile from 'ismobilejs'
|
import isMobile from 'ismobilejs'
|
||||||
import { computed, reactive, Ref, ref } from 'vue'
|
import { computed, reactive, Ref, ref } from 'vue'
|
||||||
import { playbackService } from '@/services'
|
import { playbackService } from '@/services'
|
||||||
|
@ -18,7 +18,7 @@ import ControlsToggle from '@/components/ui/ScreenControlsToggle.vue'
|
||||||
import SongList from '@/components/song/SongList.vue'
|
import SongList from '@/components/song/SongList.vue'
|
||||||
import SongListControls from '@/components/song/SongListControls.vue'
|
import SongListControls from '@/components/song/SongListControls.vue'
|
||||||
import ThumbnailStack from '@/components/ui/ThumbnailStack.vue'
|
import ThumbnailStack from '@/components/ui/ThumbnailStack.vue'
|
||||||
import { provideReadonly } from '@/utils'
|
import { eventBus, provideReadonly } from '@/utils'
|
||||||
|
|
||||||
export const useSongList = (songs: Ref<Song[]>, type: SongListType, config: Partial<SongListConfig> = {}) => {
|
export const useSongList = (songs: Ref<Song[]>, type: SongListType, config: Partial<SongListConfig> = {}) => {
|
||||||
const songList = ref<InstanceType<typeof SongList>>()
|
const songList = ref<InstanceType<typeof SongList>>()
|
||||||
|
@ -91,6 +91,10 @@ export const useSongList = (songs: Ref<Song[]>, type: SongListType, config: Part
|
||||||
songs.value = orderBy(songs.value, sortFields, order)
|
songs.value = orderBy(songs.value, sortFields, order)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
eventBus.on('SONGS_DELETED', (deletedSongs: Song[]) => {
|
||||||
|
songs.value = differenceBy(songs.value, deletedSongs, 'id')
|
||||||
|
})
|
||||||
|
|
||||||
provideReadonly(SongListTypeKey, type)
|
provideReadonly(SongListTypeKey, type)
|
||||||
provideReadonly(SongsKey, songs, false)
|
provideReadonly(SongsKey, songs, false)
|
||||||
provideReadonly(SelectedSongsKey, selectedSongs, false)
|
provideReadonly(SelectedSongsKey, selectedSongs, false)
|
||||||
|
|
|
@ -34,6 +34,7 @@ export type EventName =
|
||||||
| 'SMART_PLAYLIST_UPDATED'
|
| 'SMART_PLAYLIST_UPDATED'
|
||||||
| 'SONG_STARTED'
|
| 'SONG_STARTED'
|
||||||
| 'SONGS_UPDATED'
|
| 'SONGS_UPDATED'
|
||||||
|
| 'SONGS_DELETED'
|
||||||
| 'SONG_QUEUED_FROM_ROUTE'
|
| 'SONG_QUEUED_FROM_ROUTE'
|
||||||
|
|
||||||
// socket events
|
// socket events
|
||||||
|
|
|
@ -80,4 +80,4 @@ class Http {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const httpService = new Http()
|
export const http = new Http()
|
|
@ -1,4 +1,4 @@
|
||||||
export * from './httpService'
|
export * from './http'
|
||||||
export * from './downloadService'
|
export * from './downloadService'
|
||||||
export * from './localStorageService'
|
export * from './localStorageService'
|
||||||
export * from './playbackService'
|
export * from './playbackService'
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { expect, it } from 'vitest'
|
import { expect, it } from 'vitest'
|
||||||
import factory from '@/__tests__/factory'
|
import factory from '@/__tests__/factory'
|
||||||
import UnitTestCase from '@/__tests__/UnitTestCase'
|
import UnitTestCase from '@/__tests__/UnitTestCase'
|
||||||
import { cache, httpService } from '@/services'
|
import { cache, http } from '@/services'
|
||||||
import { albumStore, artistStore } from '@/stores'
|
import { albumStore, artistStore } from '@/stores'
|
||||||
import { mediaInfoService } from './mediaInfoService'
|
import { mediaInfoService } from './mediaInfoService'
|
||||||
|
|
||||||
|
@ -10,7 +10,7 @@ new class extends UnitTestCase {
|
||||||
it('fetches the artist info', async () => {
|
it('fetches the artist info', async () => {
|
||||||
const artist = artistStore.syncWithVault(factory<Artist>('artist', { id: 42 }))[0]
|
const artist = artistStore.syncWithVault(factory<Artist>('artist', { id: 42 }))[0]
|
||||||
const artistInfo = factory<ArtistInfo>('artist-info')
|
const artistInfo = factory<ArtistInfo>('artist-info')
|
||||||
const getMock = this.mock(httpService, 'get').mockResolvedValue(artistInfo)
|
const getMock = this.mock(http, 'get').mockResolvedValue(artistInfo)
|
||||||
const hasCacheMock = this.mock(cache, 'has', false)
|
const hasCacheMock = this.mock(cache, 'has', false)
|
||||||
const setCacheMock = this.mock(cache, 'set')
|
const setCacheMock = this.mock(cache, 'set')
|
||||||
|
|
||||||
|
@ -26,7 +26,7 @@ new class extends UnitTestCase {
|
||||||
const artistInfo = factory<ArtistInfo>('artist-info')
|
const artistInfo = factory<ArtistInfo>('artist-info')
|
||||||
const hasCacheMock = this.mock(cache, 'has', true)
|
const hasCacheMock = this.mock(cache, 'has', true)
|
||||||
const getCacheMock = this.mock(cache, 'get', artistInfo)
|
const getCacheMock = this.mock(cache, 'get', artistInfo)
|
||||||
const getMock = this.mock(httpService, 'get')
|
const getMock = this.mock(http, 'get')
|
||||||
|
|
||||||
expect(await mediaInfoService.fetchForArtist(factory<Artist>('artist', { id: 42 }))).toBe(artistInfo)
|
expect(await mediaInfoService.fetchForArtist(factory<Artist>('artist', { id: 42 }))).toBe(artistInfo)
|
||||||
expect(hasCacheMock).toHaveBeenCalledWith(['artist.info', 42])
|
expect(hasCacheMock).toHaveBeenCalledWith(['artist.info', 42])
|
||||||
|
@ -37,7 +37,7 @@ new class extends UnitTestCase {
|
||||||
it('fetches the album info', async () => {
|
it('fetches the album info', async () => {
|
||||||
const album = albumStore.syncWithVault(factory<Album>('album', { id: 42 }))[0]
|
const album = albumStore.syncWithVault(factory<Album>('album', { id: 42 }))[0]
|
||||||
const albumInfo = factory<AlbumInfo>('album-info')
|
const albumInfo = factory<AlbumInfo>('album-info')
|
||||||
const getMock = this.mock(httpService, 'get').mockResolvedValue(albumInfo)
|
const getMock = this.mock(http, 'get').mockResolvedValue(albumInfo)
|
||||||
const hasCacheMock = this.mock(cache, 'has', false)
|
const hasCacheMock = this.mock(cache, 'has', false)
|
||||||
const setCacheMock = this.mock(cache, 'set')
|
const setCacheMock = this.mock(cache, 'set')
|
||||||
|
|
||||||
|
@ -53,7 +53,7 @@ new class extends UnitTestCase {
|
||||||
const albumInfo = factory<AlbumInfo>('album-info')
|
const albumInfo = factory<AlbumInfo>('album-info')
|
||||||
const hasCacheMock = this.mock(cache, 'has', true)
|
const hasCacheMock = this.mock(cache, 'has', true)
|
||||||
const getCacheMock = this.mock(cache, 'get', albumInfo)
|
const getCacheMock = this.mock(cache, 'get', albumInfo)
|
||||||
const getMock = this.mock(httpService, 'get')
|
const getMock = this.mock(http, 'get')
|
||||||
|
|
||||||
expect(await mediaInfoService.fetchForAlbum(factory<Album>('album', { id: 42 }))).toBe(albumInfo)
|
expect(await mediaInfoService.fetchForAlbum(factory<Album>('album', { id: 42 }))).toBe(albumInfo)
|
||||||
expect(hasCacheMock).toHaveBeenCalledWith(['album.info', 42])
|
expect(hasCacheMock).toHaveBeenCalledWith(['album.info', 42])
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { cache, httpService } from '@/services'
|
import { cache, http } from '@/services'
|
||||||
import { albumStore, artistStore, songStore } from '@/stores'
|
import { albumStore, artistStore, songStore } from '@/stores'
|
||||||
|
|
||||||
export const mediaInfoService = {
|
export const mediaInfoService = {
|
||||||
|
@ -7,7 +7,7 @@ export const mediaInfoService = {
|
||||||
const cacheKey = ['artist.info', artist.id]
|
const cacheKey = ['artist.info', artist.id]
|
||||||
if (cache.has(cacheKey)) return cache.get<ArtistInfo>(cacheKey)
|
if (cache.has(cacheKey)) return cache.get<ArtistInfo>(cacheKey)
|
||||||
|
|
||||||
const info = await httpService.get<ArtistInfo | null>(`artists/${artist.id}/information`)
|
const info = await http.get<ArtistInfo | null>(`artists/${artist.id}/information`)
|
||||||
|
|
||||||
info && cache.set(cacheKey, info)
|
info && cache.set(cacheKey, info)
|
||||||
info?.image && (artist.image = info.image)
|
info?.image && (artist.image = info.image)
|
||||||
|
@ -20,7 +20,7 @@ export const mediaInfoService = {
|
||||||
const cacheKey = ['album.info', album.id]
|
const cacheKey = ['album.info', album.id]
|
||||||
if (cache.has(cacheKey)) return cache.get<AlbumInfo>(cacheKey)
|
if (cache.has(cacheKey)) return cache.get<AlbumInfo>(cacheKey)
|
||||||
|
|
||||||
const info = await httpService.get<AlbumInfo | null>(`albums/${album.id}/information`)
|
const info = await http.get<AlbumInfo | null>(`albums/${album.id}/information`)
|
||||||
info && cache.set(cacheKey, info)
|
info && cache.set(cacheKey, info)
|
||||||
|
|
||||||
if (info?.cover) {
|
if (info?.cover) {
|
||||||
|
|
|
@ -134,6 +134,17 @@ class PlaybackService {
|
||||||
* We'll let them come true
|
* We'll let them come true
|
||||||
*/
|
*/
|
||||||
public async play (song: Song) {
|
public async play (song: Song) {
|
||||||
|
// If for any reason (most likely a bug), the requested song has been deleted, just attempt the next song.
|
||||||
|
if (song.deleted) {
|
||||||
|
logger.warn('Attempted to play a deleted song', song)
|
||||||
|
|
||||||
|
if (this.next && this.next.id !== song.id) {
|
||||||
|
await this.playNext()
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
document.title = `${song.title} ♫ Koel`
|
document.title = `${song.title} ♫ Koel`
|
||||||
this.player.media.setAttribute('title', `${song.artist_name} - ${song.title}`)
|
this.player.media.setAttribute('title', `${song.artist_name} - ${song.title}`)
|
||||||
|
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { without } from 'lodash'
|
import { without } from 'lodash'
|
||||||
import { reactive } from 'vue'
|
import { reactive } from 'vue'
|
||||||
import { UploadFile } from '@/config'
|
import { UploadFile } from '@/config'
|
||||||
import { httpService } from '@/services'
|
import { http } from '@/services'
|
||||||
import { albumStore, overviewStore, songStore } from '@/stores'
|
import { albumStore, overviewStore, songStore } from '@/stores'
|
||||||
import { logger } from '@/utils'
|
import { logger } from '@/utils'
|
||||||
|
|
||||||
|
@ -63,7 +63,7 @@ export const uploadService = {
|
||||||
file.status = 'Uploading'
|
file.status = 'Uploading'
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await httpService.post<UploadResult>('upload', formData, (progressEvent: ProgressEvent) => {
|
const result = await http.post<UploadResult>('upload', formData, (progressEvent: ProgressEvent) => {
|
||||||
file.progress = progressEvent.loaded * 100 / progressEvent.total
|
file.progress = progressEvent.loaded * 100 / progressEvent.total
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { cache, httpService } from '@/services'
|
import { cache, http } from '@/services'
|
||||||
import { eventBus } from '@/utils'
|
import { eventBus } from '@/utils'
|
||||||
import router from '@/router'
|
import router from '@/router'
|
||||||
|
|
||||||
|
@ -11,7 +11,7 @@ export const youTubeService = {
|
||||||
searchVideosBySong: async (song: Song, nextPageToken: string) => {
|
searchVideosBySong: async (song: Song, nextPageToken: string) => {
|
||||||
return await cache.remember<YouTubeSearchResult>(
|
return await cache.remember<YouTubeSearchResult>(
|
||||||
['youtube.search', song.id, nextPageToken],
|
['youtube.search', song.id, nextPageToken],
|
||||||
async () => await httpService.get<YouTubeSearchResult>(
|
async () => await http.get<YouTubeSearchResult>(
|
||||||
`youtube/search/song/${song.id}?pageToken=${nextPageToken}`
|
`youtube/search/song/${song.id}?pageToken=${nextPageToken}`
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { expect, it } from 'vitest'
|
import { expect, it } from 'vitest'
|
||||||
import UnitTestCase from '@/__tests__/UnitTestCase'
|
import UnitTestCase from '@/__tests__/UnitTestCase'
|
||||||
import factory from '@/__tests__/factory'
|
import factory from '@/__tests__/factory'
|
||||||
import { httpService } from '@/services'
|
import { http } from '@/services'
|
||||||
import { albumStore, songStore } from '.'
|
import { albumStore, songStore } from '.'
|
||||||
|
|
||||||
new class extends UnitTestCase {
|
new class extends UnitTestCase {
|
||||||
|
@ -57,7 +57,7 @@ new class extends UnitTestCase {
|
||||||
const album = factory<Album>('album')
|
const album = factory<Album>('album')
|
||||||
albumStore.syncWithVault(album)
|
albumStore.syncWithVault(album)
|
||||||
const songsInAlbum = factory<Song>('song', 3, { album_id: album.id })
|
const songsInAlbum = factory<Song>('song', 3, { album_id: album.id })
|
||||||
const putMock = this.mock(httpService, 'put').mockResolvedValue({ coverUrl: 'http://localhost/cover.jpg' })
|
const putMock = this.mock(http, 'put').mockResolvedValue({ coverUrl: 'http://localhost/cover.jpg' })
|
||||||
this.mock(songStore, 'byAlbum', songsInAlbum)
|
this.mock(songStore, 'byAlbum', songsInAlbum)
|
||||||
|
|
||||||
await albumStore.uploadCover(album, 'data://cover')
|
await albumStore.uploadCover(album, 'data://cover')
|
||||||
|
@ -69,7 +69,7 @@ new class extends UnitTestCase {
|
||||||
})
|
})
|
||||||
|
|
||||||
it('fetches an album thumbnail', async () => {
|
it('fetches an album thumbnail', async () => {
|
||||||
const getMock = this.mock(httpService, 'get').mockResolvedValue({ thumbnailUrl: 'http://localhost/thumbnail.jpg' })
|
const getMock = this.mock(http, 'get').mockResolvedValue({ thumbnailUrl: 'http://localhost/thumbnail.jpg' })
|
||||||
const album = factory<Album>('album')
|
const album = factory<Album>('album')
|
||||||
|
|
||||||
const url = await albumStore.fetchThumbnail(album.id)
|
const url = await albumStore.fetchThumbnail(album.id)
|
||||||
|
@ -80,7 +80,7 @@ new class extends UnitTestCase {
|
||||||
|
|
||||||
it('resolves an album', async () => {
|
it('resolves an album', async () => {
|
||||||
const album = factory<Album>('album')
|
const album = factory<Album>('album')
|
||||||
const getMock = this.mock(httpService, 'get').mockResolvedValueOnce(album)
|
const getMock = this.mock(http, 'get').mockResolvedValueOnce(album)
|
||||||
|
|
||||||
expect(await albumStore.resolve(album.id)).toEqual(album)
|
expect(await albumStore.resolve(album.id)).toEqual(album)
|
||||||
expect(getMock).toHaveBeenCalledWith(`albums/${album.id}`)
|
expect(getMock).toHaveBeenCalledWith(`albums/${album.id}`)
|
||||||
|
@ -93,7 +93,7 @@ new class extends UnitTestCase {
|
||||||
it('paginates', async () => {
|
it('paginates', async () => {
|
||||||
const albums = factory<Album>('album', 3)
|
const albums = factory<Album>('album', 3)
|
||||||
|
|
||||||
this.mock(httpService, 'get').mockResolvedValueOnce({
|
this.mock(http, 'get').mockResolvedValueOnce({
|
||||||
data: albums,
|
data: albums,
|
||||||
links: {
|
links: {
|
||||||
next: '/albums?page=2'
|
next: '/albums?page=2'
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { reactive, UnwrapNestedRefs } from 'vue'
|
import { reactive, UnwrapNestedRefs } from 'vue'
|
||||||
import { differenceBy, merge, orderBy, take, unionBy } from 'lodash'
|
import { differenceBy, merge, orderBy, take, unionBy } from 'lodash'
|
||||||
import { cache, httpService } from '@/services'
|
import { cache, http } from '@/services'
|
||||||
import { arrayify, logger } from '@/utils'
|
import { arrayify, logger } from '@/utils'
|
||||||
import { songStore } from '@/stores'
|
import { songStore } from '@/stores'
|
||||||
|
|
||||||
|
@ -19,7 +19,10 @@ export const albumStore = {
|
||||||
|
|
||||||
removeByIds (ids: number[]) {
|
removeByIds (ids: number[]) {
|
||||||
this.state.albums = differenceBy(this.state.albums, ids.map(id => this.byId(id)), 'id')
|
this.state.albums = differenceBy(this.state.albums, ids.map(id => this.byId(id)), 'id')
|
||||||
ids.forEach(id => this.vault.delete(id))
|
ids.forEach(id => {
|
||||||
|
this.vault.delete(id)
|
||||||
|
cache.remove(['album', id])
|
||||||
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
isUnknown: (album: Album | number) => {
|
isUnknown: (album: Album | number) => {
|
||||||
|
@ -44,7 +47,7 @@ export const albumStore = {
|
||||||
* @param {string} cover The content data string of the cover
|
* @param {string} cover The content data string of the cover
|
||||||
*/
|
*/
|
||||||
async uploadCover (album: Album, cover: string) {
|
async uploadCover (album: Album, cover: string) {
|
||||||
album.cover = (await httpService.put<{ coverUrl: string }>(`album/${album.id}/cover`, { cover })).coverUrl
|
album.cover = (await http.put<{ coverUrl: string }>(`album/${album.id}/cover`, { cover })).coverUrl
|
||||||
songStore.byAlbum(album).forEach(song => song.album_cover = album.cover)
|
songStore.byAlbum(album).forEach(song => song.album_cover = album.cover)
|
||||||
|
|
||||||
// sync to vault
|
// sync to vault
|
||||||
|
@ -57,7 +60,7 @@ export const albumStore = {
|
||||||
* Fetch the (blurry) thumbnail-sized version of an album's cover.
|
* Fetch the (blurry) thumbnail-sized version of an album's cover.
|
||||||
*/
|
*/
|
||||||
fetchThumbnail: async (id: number) => {
|
fetchThumbnail: async (id: number) => {
|
||||||
return (await httpService.get<{ thumbnailUrl: string }>(`album/${id}/thumbnail`)).thumbnailUrl
|
return (await http.get<{ thumbnailUrl: string }>(`album/${id}/thumbnail`)).thumbnailUrl
|
||||||
},
|
},
|
||||||
|
|
||||||
async resolve (id: number) {
|
async resolve (id: number) {
|
||||||
|
@ -66,7 +69,7 @@ export const albumStore = {
|
||||||
if (!album) {
|
if (!album) {
|
||||||
try {
|
try {
|
||||||
album = this.syncWithVault(
|
album = this.syncWithVault(
|
||||||
await cache.remember<Album>(['album', id], async () => await httpService.get<Album>(`albums/${id}`))
|
await cache.remember<Album>(['album', id], async () => await http.get<Album>(`albums/${id}`))
|
||||||
)[0]
|
)[0]
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error(e)
|
logger.error(e)
|
||||||
|
@ -77,7 +80,7 @@ export const albumStore = {
|
||||||
},
|
},
|
||||||
|
|
||||||
async paginate (page: number) {
|
async paginate (page: number) {
|
||||||
const resource = await httpService.get<PaginatorResource>(`albums?page=${page}`)
|
const resource = await http.get<PaginatorResource>(`albums?page=${page}`)
|
||||||
this.state.albums = unionBy(this.state.albums, this.syncWithVault(resource.data), 'id')
|
this.state.albums = unionBy(this.state.albums, this.syncWithVault(resource.data), 'id')
|
||||||
|
|
||||||
return resource.links.next ? ++resource.meta.current_page : null
|
return resource.links.next ? ++resource.meta.current_page : null
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { expect, it } from 'vitest'
|
import { expect, it } from 'vitest'
|
||||||
import UnitTestCase from '@/__tests__/UnitTestCase'
|
import UnitTestCase from '@/__tests__/UnitTestCase'
|
||||||
import factory from '@/__tests__/factory'
|
import factory from '@/__tests__/factory'
|
||||||
import { httpService } from '@/services'
|
import { http } from '@/services'
|
||||||
import { artistStore } from '.'
|
import { artistStore } from '.'
|
||||||
|
|
||||||
new class extends UnitTestCase {
|
new class extends UnitTestCase {
|
||||||
|
@ -70,7 +70,7 @@ new class extends UnitTestCase {
|
||||||
it('uploads an image for an artist', async () => {
|
it('uploads an image for an artist', async () => {
|
||||||
const artist = factory<Artist>('artist')
|
const artist = factory<Artist>('artist')
|
||||||
artistStore.syncWithVault(artist)
|
artistStore.syncWithVault(artist)
|
||||||
const putMock = this.mock(httpService, 'put').mockResolvedValue({ imageUrl: 'http://localhost/img.jpg' })
|
const putMock = this.mock(http, 'put').mockResolvedValue({ imageUrl: 'http://localhost/img.jpg' })
|
||||||
|
|
||||||
await artistStore.uploadImage(artist, 'data://image')
|
await artistStore.uploadImage(artist, 'data://image')
|
||||||
|
|
||||||
|
@ -81,7 +81,7 @@ new class extends UnitTestCase {
|
||||||
|
|
||||||
it('resolves an artist', async () => {
|
it('resolves an artist', async () => {
|
||||||
const artist = factory<Artist>('artist')
|
const artist = factory<Artist>('artist')
|
||||||
const getMock = this.mock(httpService, 'get').mockResolvedValueOnce(artist)
|
const getMock = this.mock(http, 'get').mockResolvedValueOnce(artist)
|
||||||
|
|
||||||
expect(await artistStore.resolve(artist.id)).toEqual(artist)
|
expect(await artistStore.resolve(artist.id)).toEqual(artist)
|
||||||
expect(getMock).toHaveBeenCalledWith(`artists/${artist.id}`)
|
expect(getMock).toHaveBeenCalledWith(`artists/${artist.id}`)
|
||||||
|
@ -94,7 +94,7 @@ new class extends UnitTestCase {
|
||||||
it('paginates', async () => {
|
it('paginates', async () => {
|
||||||
const artists = factory<Artist>('artist', 3)
|
const artists = factory<Artist>('artist', 3)
|
||||||
|
|
||||||
this.mock(httpService, 'get').mockResolvedValueOnce({
|
this.mock(http, 'get').mockResolvedValueOnce({
|
||||||
data: artists,
|
data: artists,
|
||||||
links: {
|
links: {
|
||||||
next: '/artists?page=2'
|
next: '/artists?page=2'
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { reactive, UnwrapNestedRefs } from 'vue'
|
import { reactive, UnwrapNestedRefs } from 'vue'
|
||||||
import { differenceBy, orderBy, take, unionBy } from 'lodash'
|
import { differenceBy, orderBy, take, unionBy } from 'lodash'
|
||||||
import { cache, httpService } from '@/services'
|
import { cache, http } from '@/services'
|
||||||
import { arrayify, logger } from '@/utils'
|
import { arrayify, logger } from '@/utils'
|
||||||
|
|
||||||
const UNKNOWN_ARTIST_ID = 1
|
const UNKNOWN_ARTIST_ID = 1
|
||||||
|
@ -35,7 +35,7 @@ export const artistStore = {
|
||||||
},
|
},
|
||||||
|
|
||||||
async uploadImage (artist: Artist, image: string) {
|
async uploadImage (artist: Artist, image: string) {
|
||||||
artist.image = (await httpService.put<{ imageUrl: string }>(`artist/${artist.id}/image`, { image })).imageUrl
|
artist.image = (await http.put<{ imageUrl: string }>(`artist/${artist.id}/image`, { image })).imageUrl
|
||||||
|
|
||||||
// sync to vault
|
// sync to vault
|
||||||
this.byId(artist.id)!.image = artist.image
|
this.byId(artist.id)!.image = artist.image
|
||||||
|
@ -59,7 +59,7 @@ export const artistStore = {
|
||||||
if (!artist) {
|
if (!artist) {
|
||||||
try {
|
try {
|
||||||
artist = this.syncWithVault(
|
artist = this.syncWithVault(
|
||||||
await cache.remember<Artist>(['artist', id], async () => await httpService.get<Artist>(`artists/${id}`))
|
await cache.remember<Artist>(['artist', id], async () => await http.get<Artist>(`artists/${id}`))
|
||||||
)[0]
|
)[0]
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error(e)
|
logger.error(e)
|
||||||
|
@ -70,7 +70,7 @@ export const artistStore = {
|
||||||
},
|
},
|
||||||
|
|
||||||
async paginate (page: number) {
|
async paginate (page: number) {
|
||||||
const resource = await httpService.get<PaginatorResource>(`artists?page=${page}`)
|
const resource = await http.get<PaginatorResource>(`artists?page=${page}`)
|
||||||
this.state.artists = unionBy(this.state.artists, this.syncWithVault(resource.data), 'id')
|
this.state.artists = unionBy(this.state.artists, this.syncWithVault(resource.data), 'id')
|
||||||
|
|
||||||
return resource.links.next ? ++resource.meta.current_page : null
|
return resource.links.next ? ++resource.meta.current_page : null
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import isMobile from 'ismobilejs'
|
import isMobile from 'ismobilejs'
|
||||||
import { reactive } from 'vue'
|
import { reactive } from 'vue'
|
||||||
import { httpService } from '@/services'
|
import { http } from '@/services'
|
||||||
import { playlistFolderStore, playlistStore, preferenceStore, settingStore, themeStore, userStore } from '.'
|
import { playlistFolderStore, playlistStore, preferenceStore, settingStore, themeStore, userStore } from '.'
|
||||||
|
|
||||||
interface CommonStoreState {
|
interface CommonStoreState {
|
||||||
|
@ -41,7 +41,7 @@ export const commonStore = {
|
||||||
}),
|
}),
|
||||||
|
|
||||||
async init () {
|
async init () {
|
||||||
Object.assign(this.state, await httpService.get<CommonStoreState>('data'))
|
Object.assign(this.state, await http.get<CommonStoreState>('data'))
|
||||||
|
|
||||||
// Always disable YouTube integration on mobile.
|
// Always disable YouTube integration on mobile.
|
||||||
this.state.use_you_tube = this.state.use_you_tube && !isMobile.phone
|
this.state.use_you_tube = this.state.use_you_tube && !isMobile.phone
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { expect, it } from 'vitest'
|
import { expect, it } from 'vitest'
|
||||||
import factory from '@/__tests__/factory'
|
import factory from '@/__tests__/factory'
|
||||||
import UnitTestCase from '@/__tests__/UnitTestCase'
|
import UnitTestCase from '@/__tests__/UnitTestCase'
|
||||||
import { httpService } from '@/services'
|
import { http } from '@/services'
|
||||||
import { favoriteStore } from '.'
|
import { favoriteStore } from '.'
|
||||||
|
|
||||||
new class extends UnitTestCase {
|
new class extends UnitTestCase {
|
||||||
|
@ -13,7 +13,7 @@ new class extends UnitTestCase {
|
||||||
it('toggles one song', async () => {
|
it('toggles one song', async () => {
|
||||||
const addMock = this.mock(favoriteStore, 'add')
|
const addMock = this.mock(favoriteStore, 'add')
|
||||||
const removeMock = this.mock(favoriteStore, 'remove')
|
const removeMock = this.mock(favoriteStore, 'remove')
|
||||||
const postMock = this.mock(httpService, 'post')
|
const postMock = this.mock(http, 'post')
|
||||||
const song = factory<Song>('song', { liked: false })
|
const song = factory<Song>('song', { liked: false })
|
||||||
|
|
||||||
await favoriteStore.toggleOne(song)
|
await favoriteStore.toggleOne(song)
|
||||||
|
@ -48,7 +48,7 @@ new class extends UnitTestCase {
|
||||||
it('likes several songs', async () => {
|
it('likes several songs', async () => {
|
||||||
const songs = factory<Song>('song', 3)
|
const songs = factory<Song>('song', 3)
|
||||||
const addMock = this.mock(favoriteStore, 'add')
|
const addMock = this.mock(favoriteStore, 'add')
|
||||||
const postMock = this.mock(httpService, 'post')
|
const postMock = this.mock(http, 'post')
|
||||||
|
|
||||||
await favoriteStore.like(songs)
|
await favoriteStore.like(songs)
|
||||||
|
|
||||||
|
@ -59,7 +59,7 @@ new class extends UnitTestCase {
|
||||||
it('unlikes several songs', async () => {
|
it('unlikes several songs', async () => {
|
||||||
const songs = factory<Song>('song', 3)
|
const songs = factory<Song>('song', 3)
|
||||||
const removeMock = this.mock(favoriteStore, 'remove')
|
const removeMock = this.mock(favoriteStore, 'remove')
|
||||||
const postMock = this.mock(httpService, 'post')
|
const postMock = this.mock(http, 'post')
|
||||||
|
|
||||||
await favoriteStore.unlike(songs)
|
await favoriteStore.unlike(songs)
|
||||||
|
|
||||||
|
@ -69,7 +69,7 @@ new class extends UnitTestCase {
|
||||||
|
|
||||||
it('fetches favorites', async () => {
|
it('fetches favorites', async () => {
|
||||||
const songs = factory<Song>('song', 3)
|
const songs = factory<Song>('song', 3)
|
||||||
const getMock = this.mock(httpService, 'get').mockResolvedValue(songs)
|
const getMock = this.mock(http, 'get').mockResolvedValue(songs)
|
||||||
|
|
||||||
await favoriteStore.fetch()
|
await favoriteStore.fetch()
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { reactive } from 'vue'
|
import { reactive } from 'vue'
|
||||||
import { differenceBy, unionBy } from 'lodash'
|
import { differenceBy, unionBy } from 'lodash'
|
||||||
import { httpService } from '@/services'
|
import { http } from '@/services'
|
||||||
import { arrayify } from '@/utils'
|
import { arrayify } from '@/utils'
|
||||||
import { songStore } from '@/stores'
|
import { songStore } from '@/stores'
|
||||||
|
|
||||||
|
@ -15,7 +15,7 @@ export const favoriteStore = {
|
||||||
song.liked = !song.liked
|
song.liked = !song.liked
|
||||||
song.liked ? this.add(song) : this.remove(song)
|
song.liked ? this.add(song) : this.remove(song)
|
||||||
|
|
||||||
await httpService.post<Song>('interaction/like', { song: song.id })
|
await http.post<Song>('interaction/like', { song: song.id })
|
||||||
},
|
},
|
||||||
|
|
||||||
add (songs: Song | Song[]) {
|
add (songs: Song | Song[]) {
|
||||||
|
@ -32,17 +32,17 @@ export const favoriteStore = {
|
||||||
songs.forEach(song => (song.liked = true))
|
songs.forEach(song => (song.liked = true))
|
||||||
this.add(songs)
|
this.add(songs)
|
||||||
|
|
||||||
await httpService.post('interaction/batch/like', { songs: songs.map(song => song.id) })
|
await http.post('interaction/batch/like', { songs: songs.map(song => song.id) })
|
||||||
},
|
},
|
||||||
|
|
||||||
async unlike (songs: Song[]) {
|
async unlike (songs: Song[]) {
|
||||||
songs.forEach(song => (song.liked = false))
|
songs.forEach(song => (song.liked = false))
|
||||||
this.remove(songs)
|
this.remove(songs)
|
||||||
|
|
||||||
await httpService.post('interaction/batch/unlike', { songs: songs.map(song => song.id) })
|
await http.post('interaction/batch/unlike', { songs: songs.map(song => song.id) })
|
||||||
},
|
},
|
||||||
|
|
||||||
async fetch () {
|
async fetch () {
|
||||||
this.state.songs = songStore.syncWithVault(await httpService.get<Song[]>('songs/favorite'))
|
this.state.songs = songStore.syncWithVault(await http.get<Song[]>('songs/favorite'))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { expect, it } from 'vitest'
|
import { expect, it } from 'vitest'
|
||||||
import factory from '@/__tests__/factory'
|
import factory from '@/__tests__/factory'
|
||||||
import UnitTestCase from '@/__tests__/UnitTestCase'
|
import UnitTestCase from '@/__tests__/UnitTestCase'
|
||||||
import { httpService } from '@/services'
|
import { http } from '@/services'
|
||||||
import { albumStore, artistStore, overviewStore, recentlyPlayedStore, songStore } from '.'
|
import { albumStore, artistStore, overviewStore, recentlyPlayedStore, songStore } from '.'
|
||||||
|
|
||||||
new class extends UnitTestCase {
|
new class extends UnitTestCase {
|
||||||
|
@ -32,7 +32,7 @@ new class extends UnitTestCase {
|
||||||
const recentlyAddedAlbums = factory<Album>('album', 6)
|
const recentlyAddedAlbums = factory<Album>('album', 6)
|
||||||
const recentlyPlayedSongs = factory<Song>('song', 9)
|
const recentlyPlayedSongs = factory<Song>('song', 9)
|
||||||
|
|
||||||
const getMock = this.mock(httpService, 'get').mockResolvedValueOnce({
|
const getMock = this.mock(http, 'get').mockResolvedValueOnce({
|
||||||
most_played_songs: mostPlayedSongs,
|
most_played_songs: mostPlayedSongs,
|
||||||
most_played_albums: mostPlayedAlbums,
|
most_played_albums: mostPlayedAlbums,
|
||||||
most_played_artists: mostPlayedArtists,
|
most_played_artists: mostPlayedArtists,
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { reactive } from 'vue'
|
import { reactive } from 'vue'
|
||||||
import { httpService } from '@/services'
|
import { http } from '@/services'
|
||||||
import { songStore } from '@/stores/songStore'
|
import { songStore } from '@/stores/songStore'
|
||||||
import { albumStore } from '@/stores/albumStore'
|
import { albumStore } from '@/stores/albumStore'
|
||||||
import { artistStore } from '@/stores/artistStore'
|
import { artistStore } from '@/stores/artistStore'
|
||||||
|
@ -16,7 +16,7 @@ export const overviewStore = {
|
||||||
}),
|
}),
|
||||||
|
|
||||||
async init () {
|
async init () {
|
||||||
const resource = await httpService.get<{
|
const resource = await http.get<{
|
||||||
most_played_songs: Song[],
|
most_played_songs: Song[],
|
||||||
most_played_albums: Album[],
|
most_played_albums: Album[],
|
||||||
most_played_artists: Artist[],
|
most_played_artists: Artist[],
|
||||||
|
@ -40,7 +40,6 @@ export const overviewStore = {
|
||||||
this.state.mostPlayedArtists = artistStore.getMostPlayed(6)
|
this.state.mostPlayedArtists = artistStore.getMostPlayed(6)
|
||||||
this.state.recentlyAddedSongs = songStore.getRecentlyAdded(9)
|
this.state.recentlyAddedSongs = songStore.getRecentlyAdded(9)
|
||||||
this.state.recentlyAddedAlbums = albumStore.getRecentlyAdded(6)
|
this.state.recentlyAddedAlbums = albumStore.getRecentlyAdded(6)
|
||||||
|
this.state.recentlyPlayed = recentlyPlayedStore.excerptState.songs.filter(song => !song.deleted)
|
||||||
this.state.recentlyPlayed = recentlyPlayedStore.excerptState.songs
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { reactive, UnwrapNestedRefs } from 'vue'
|
import { reactive, UnwrapNestedRefs } from 'vue'
|
||||||
import { httpService } from '@/services'
|
import { http } from '@/services'
|
||||||
import { differenceBy, orderBy } from 'lodash'
|
import { differenceBy, orderBy } from 'lodash'
|
||||||
import { playlistStore } from '@/stores/playlistStore'
|
import { playlistStore } from '@/stores/playlistStore'
|
||||||
|
|
||||||
|
@ -13,7 +13,7 @@ export const playlistFolderStore = {
|
||||||
},
|
},
|
||||||
|
|
||||||
async store (name: string) {
|
async store (name: string) {
|
||||||
const folder = reactive(await httpService.post<PlaylistFolder>('playlist-folders', { name }))
|
const folder = reactive(await http.post<PlaylistFolder>('playlist-folders', { name }))
|
||||||
|
|
||||||
this.state.folders.push(folder)
|
this.state.folders.push(folder)
|
||||||
this.state.folders = orderBy(this.state.folders, 'name')
|
this.state.folders = orderBy(this.state.folders, 'name')
|
||||||
|
@ -22,27 +22,27 @@ export const playlistFolderStore = {
|
||||||
},
|
},
|
||||||
|
|
||||||
async delete (folder: PlaylistFolder) {
|
async delete (folder: PlaylistFolder) {
|
||||||
await httpService.delete(`playlist-folders/${folder.id}`)
|
await http.delete(`playlist-folders/${folder.id}`)
|
||||||
this.state.folders = differenceBy(this.state.folders, [folder], 'id')
|
this.state.folders = differenceBy(this.state.folders, [folder], 'id')
|
||||||
playlistStore.byFolder(folder).forEach(playlist => (playlist.folder_id = null))
|
playlistStore.byFolder(folder).forEach(playlist => (playlist.folder_id = null))
|
||||||
},
|
},
|
||||||
|
|
||||||
async rename (folder: PlaylistFolder, name: string) {
|
async rename (folder: PlaylistFolder, name: string) {
|
||||||
await httpService.put(`playlist-folders/${folder.id}`, { name })
|
await http.put(`playlist-folders/${folder.id}`, { name })
|
||||||
},
|
},
|
||||||
|
|
||||||
async addPlaylistToFolder (folder: PlaylistFolder, playlist: Playlist) {
|
async addPlaylistToFolder (folder: PlaylistFolder, playlist: Playlist) {
|
||||||
// Update the folder ID right away, so that the UI can be refreshed immediately.
|
// Update the folder ID right away, so that the UI can be refreshed immediately.
|
||||||
// The actual HTTP request will be done in the background.
|
// The actual HTTP request will be done in the background.
|
||||||
playlist.folder_id = folder.id
|
playlist.folder_id = folder.id
|
||||||
await httpService.post(`playlist-folders/${folder.id}/playlists`, { playlists: [playlist.id] })
|
await http.post(`playlist-folders/${folder.id}/playlists`, { playlists: [playlist.id] })
|
||||||
},
|
},
|
||||||
|
|
||||||
async removePlaylistFromFolder (folder: PlaylistFolder, playlist: Playlist) {
|
async removePlaylistFromFolder (folder: PlaylistFolder, playlist: Playlist) {
|
||||||
// Update the folder ID right away, so that the UI can be updated immediately.
|
// Update the folder ID right away, so that the UI can be updated immediately.
|
||||||
// The actual update will be done in the background.
|
// The actual update will be done in the background.
|
||||||
playlist.folder_id = null
|
playlist.folder_id = null
|
||||||
await httpService.delete(`playlist-folders/${folder.id}/playlists`, { playlists: [playlist.id] })
|
await http.delete(`playlist-folders/${folder.id}/playlists`, { playlists: [playlist.id] })
|
||||||
},
|
},
|
||||||
|
|
||||||
sort: (folders: PlaylistFolder[] | UnwrapNestedRefs<PlaylistFolder>[]) => orderBy(folders, 'name')
|
sort: (folders: PlaylistFolder[] | UnwrapNestedRefs<PlaylistFolder>[]) => orderBy(folders, 'name')
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import UnitTestCase from '@/__tests__/UnitTestCase'
|
import UnitTestCase from '@/__tests__/UnitTestCase'
|
||||||
import factory from '@/__tests__/factory'
|
import factory from '@/__tests__/factory'
|
||||||
import { expect, it } from 'vitest'
|
import { expect, it } from 'vitest'
|
||||||
import { cache, httpService } from '@/services'
|
import { cache, http } from '@/services'
|
||||||
import { playlistStore } from '.'
|
import { playlistStore } from '.'
|
||||||
|
|
||||||
const ruleGroups: SmartPlaylistRuleGroup[] = [
|
const ruleGroups: SmartPlaylistRuleGroup[] = [
|
||||||
|
@ -82,7 +82,7 @@ new class extends UnitTestCase {
|
||||||
it('stores a playlist', async () => {
|
it('stores a playlist', async () => {
|
||||||
const songs = factory<Song>('song', 3)
|
const songs = factory<Song>('song', 3)
|
||||||
const playlist = factory<Playlist>('playlist')
|
const playlist = factory<Playlist>('playlist')
|
||||||
const postMock = this.mock(httpService, 'post').mockResolvedValue(playlist)
|
const postMock = this.mock(http, 'post').mockResolvedValue(playlist)
|
||||||
const serializeMock = this.mock(playlistStore, 'serializeSmartPlaylistRulesForStorage', null)
|
const serializeMock = this.mock(playlistStore, 'serializeSmartPlaylistRulesForStorage', null)
|
||||||
|
|
||||||
await playlistStore.store('New Playlist', songs, [])
|
await playlistStore.store('New Playlist', songs, [])
|
||||||
|
@ -100,7 +100,7 @@ new class extends UnitTestCase {
|
||||||
|
|
||||||
it('deletes a playlist', async () => {
|
it('deletes a playlist', async () => {
|
||||||
const playlist = factory<Playlist>('playlist', { id: 12 })
|
const playlist = factory<Playlist>('playlist', { id: 12 })
|
||||||
const deleteMock = this.mock(httpService, 'delete')
|
const deleteMock = this.mock(http, 'delete')
|
||||||
playlistStore.state.playlists = [factory<Playlist>('playlist'), playlist]
|
playlistStore.state.playlists = [factory<Playlist>('playlist'), playlist]
|
||||||
|
|
||||||
await playlistStore.delete(playlist)
|
await playlistStore.delete(playlist)
|
||||||
|
@ -113,7 +113,7 @@ new class extends UnitTestCase {
|
||||||
it('adds songs to a playlist', async () => {
|
it('adds songs to a playlist', async () => {
|
||||||
const playlist = factory<Playlist>('playlist', { id: 12 })
|
const playlist = factory<Playlist>('playlist', { id: 12 })
|
||||||
const songs = factory<Song>('song', 3)
|
const songs = factory<Song>('song', 3)
|
||||||
const postMock = this.mock(httpService, 'post').mockResolvedValue(playlist)
|
const postMock = this.mock(http, 'post').mockResolvedValue(playlist)
|
||||||
const removeMock = this.mock(cache, 'remove')
|
const removeMock = this.mock(cache, 'remove')
|
||||||
|
|
||||||
await playlistStore.addSongs(playlist, songs)
|
await playlistStore.addSongs(playlist, songs)
|
||||||
|
@ -125,7 +125,7 @@ new class extends UnitTestCase {
|
||||||
it('removes songs from a playlist', async () => {
|
it('removes songs from a playlist', async () => {
|
||||||
const playlist = factory<Playlist>('playlist', { id: 12 })
|
const playlist = factory<Playlist>('playlist', { id: 12 })
|
||||||
const songs = factory<Song>('song', 3)
|
const songs = factory<Song>('song', 3)
|
||||||
const deleteMock = this.mock(httpService, 'delete').mockResolvedValue(playlist)
|
const deleteMock = this.mock(http, 'delete').mockResolvedValue(playlist)
|
||||||
const removeMock = this.mock(cache, 'remove')
|
const removeMock = this.mock(cache, 'remove')
|
||||||
|
|
||||||
await playlistStore.removeSongs(playlist, songs)
|
await playlistStore.removeSongs(playlist, songs)
|
||||||
|
@ -136,7 +136,7 @@ new class extends UnitTestCase {
|
||||||
|
|
||||||
it('does not modify a smart playlist content', async () => {
|
it('does not modify a smart playlist content', async () => {
|
||||||
const playlist = factory.states('smart')<Playlist>('playlist')
|
const playlist = factory.states('smart')<Playlist>('playlist')
|
||||||
const postMock = this.mock(httpService, 'post')
|
const postMock = this.mock(http, 'post')
|
||||||
|
|
||||||
await playlistStore.addSongs(playlist, factory<Song>('song', 3))
|
await playlistStore.addSongs(playlist, factory<Song>('song', 3))
|
||||||
expect(postMock).not.toHaveBeenCalled()
|
expect(postMock).not.toHaveBeenCalled()
|
||||||
|
@ -147,7 +147,7 @@ new class extends UnitTestCase {
|
||||||
|
|
||||||
it('updates a standard playlist', async () => {
|
it('updates a standard playlist', async () => {
|
||||||
const playlist = factory<Playlist>('playlist', { id: 12 })
|
const playlist = factory<Playlist>('playlist', { id: 12 })
|
||||||
const putMock = this.mock(httpService, 'put').mockResolvedValue(playlist)
|
const putMock = this.mock(http, 'put').mockResolvedValue(playlist)
|
||||||
|
|
||||||
await playlistStore.update(playlist, { name: 'Foo' })
|
await playlistStore.update(playlist, { name: 'Foo' })
|
||||||
|
|
||||||
|
@ -159,7 +159,7 @@ new class extends UnitTestCase {
|
||||||
const playlist = factory.states('smart')<Playlist>('playlist', { id: 12 })
|
const playlist = factory.states('smart')<Playlist>('playlist', { id: 12 })
|
||||||
const rules = factory<SmartPlaylistRuleGroup>('smart-playlist-rule-group', 2)
|
const rules = factory<SmartPlaylistRuleGroup>('smart-playlist-rule-group', 2)
|
||||||
const serializeMock = this.mock(playlistStore, 'serializeSmartPlaylistRulesForStorage', ['Whatever'])
|
const serializeMock = this.mock(playlistStore, 'serializeSmartPlaylistRulesForStorage', ['Whatever'])
|
||||||
const putMock = this.mock(httpService, 'put').mockResolvedValue(playlist)
|
const putMock = this.mock(http, 'put').mockResolvedValue(playlist)
|
||||||
const removeMock = this.mock(cache, 'remove')
|
const removeMock = this.mock(cache, 'remove')
|
||||||
|
|
||||||
await playlistStore.update(playlist, { name: 'Foo', rules })
|
await playlistStore.update(playlist, { name: 'Foo', rules })
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { differenceBy, orderBy } from 'lodash'
|
import { differenceBy, orderBy } from 'lodash'
|
||||||
import { reactive, UnwrapNestedRefs } from 'vue'
|
import { reactive, UnwrapNestedRefs } from 'vue'
|
||||||
import { logger } from '@/utils'
|
import { logger } from '@/utils'
|
||||||
import { cache, httpService } from '@/services'
|
import { cache, http } from '@/services'
|
||||||
import models from '@/config/smart-playlist/models'
|
import models from '@/config/smart-playlist/models'
|
||||||
import operators from '@/config/smart-playlist/operators'
|
import operators from '@/config/smart-playlist/operators'
|
||||||
|
|
||||||
|
@ -52,7 +52,7 @@ export const playlistStore = {
|
||||||
},
|
},
|
||||||
|
|
||||||
async store (name: string, songs: Song[] = [], rules: SmartPlaylistRuleGroup[] = []) {
|
async store (name: string, songs: Song[] = [], rules: SmartPlaylistRuleGroup[] = []) {
|
||||||
const playlist = reactive(await httpService.post<Playlist>('playlists', {
|
const playlist = reactive(await http.post<Playlist>('playlists', {
|
||||||
name,
|
name,
|
||||||
songs: songs.map(song => song.id),
|
songs: songs.map(song => song.id),
|
||||||
rules: this.serializeSmartPlaylistRulesForStorage(rules)
|
rules: this.serializeSmartPlaylistRulesForStorage(rules)
|
||||||
|
@ -67,7 +67,7 @@ export const playlistStore = {
|
||||||
},
|
},
|
||||||
|
|
||||||
async delete (playlist: Playlist) {
|
async delete (playlist: Playlist) {
|
||||||
await httpService.delete(`playlists/${playlist.id}`)
|
await http.delete(`playlists/${playlist.id}`)
|
||||||
this.state.playlists = differenceBy(this.state.playlists, [playlist], 'id')
|
this.state.playlists = differenceBy(this.state.playlists, [playlist], 'id')
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@ -76,7 +76,7 @@ export const playlistStore = {
|
||||||
return playlist
|
return playlist
|
||||||
}
|
}
|
||||||
|
|
||||||
await httpService.post(`playlists/${playlist.id}/songs`, { songs: songs.map(song => song.id) })
|
await http.post(`playlists/${playlist.id}/songs`, { songs: songs.map(song => song.id) })
|
||||||
cache.remove(['playlist.songs', playlist.id])
|
cache.remove(['playlist.songs', playlist.id])
|
||||||
|
|
||||||
return playlist
|
return playlist
|
||||||
|
@ -87,14 +87,14 @@ export const playlistStore = {
|
||||||
return playlist
|
return playlist
|
||||||
}
|
}
|
||||||
|
|
||||||
await httpService.delete(`playlists/${playlist.id}/songs`, { songs: songs.map(song => song.id) })
|
await http.delete(`playlists/${playlist.id}/songs`, { songs: songs.map(song => song.id) })
|
||||||
cache.remove(['playlist.songs', playlist.id])
|
cache.remove(['playlist.songs', playlist.id])
|
||||||
|
|
||||||
return playlist
|
return playlist
|
||||||
},
|
},
|
||||||
|
|
||||||
async update (playlist: Playlist, data: Partial<Pick<Playlist, 'name' | 'rules'>>) {
|
async update (playlist: Playlist, data: Partial<Pick<Playlist, 'name' | 'rules'>>) {
|
||||||
await httpService.put(`playlists/${playlist.id}`, {
|
await http.put(`playlists/${playlist.id}`, {
|
||||||
name: data.name,
|
name: data.name,
|
||||||
rules: this.serializeSmartPlaylistRulesForStorage(data.rules || [])
|
rules: this.serializeSmartPlaylistRulesForStorage(data.rules || [])
|
||||||
})
|
})
|
||||||
|
|
|
@ -2,7 +2,7 @@ import { reactive } from 'vue'
|
||||||
import UnitTestCase from '@/__tests__/UnitTestCase'
|
import UnitTestCase from '@/__tests__/UnitTestCase'
|
||||||
import { expect, it } from 'vitest'
|
import { expect, it } from 'vitest'
|
||||||
import factory from 'factoria'
|
import factory from 'factoria'
|
||||||
import { httpService } from '@/services'
|
import { http } from '@/services'
|
||||||
import { queueStore, songStore } from '.'
|
import { queueStore, songStore } from '.'
|
||||||
|
|
||||||
let songs
|
let songs
|
||||||
|
@ -89,7 +89,7 @@ new class extends UnitTestCase {
|
||||||
|
|
||||||
it('fetches random songs to queue', async () => {
|
it('fetches random songs to queue', async () => {
|
||||||
const songs = factory<Song>('song', 3)
|
const songs = factory<Song>('song', 3)
|
||||||
const getMock = this.mock(httpService, 'get').mockResolvedValue(songs)
|
const getMock = this.mock(http, 'get').mockResolvedValue(songs)
|
||||||
const syncMock = this.mock(songStore, 'syncWithVault', songs)
|
const syncMock = this.mock(songStore, 'syncWithVault', songs)
|
||||||
|
|
||||||
await queueStore.fetchRandom(3)
|
await queueStore.fetchRandom(3)
|
||||||
|
@ -101,7 +101,7 @@ new class extends UnitTestCase {
|
||||||
|
|
||||||
it('fetches random songs to queue with a custom order', async () => {
|
it('fetches random songs to queue with a custom order', async () => {
|
||||||
const songs = factory<Song>('song', 3)
|
const songs = factory<Song>('song', 3)
|
||||||
const getMock = this.mock(httpService, 'get').mockResolvedValue(songs)
|
const getMock = this.mock(http, 'get').mockResolvedValue(songs)
|
||||||
const syncMock = this.mock(songStore, 'syncWithVault', songs)
|
const syncMock = this.mock(songStore, 'syncWithVault', songs)
|
||||||
|
|
||||||
await queueStore.fetchInOrder('title', 'desc', 3)
|
await queueStore.fetchInOrder('title', 'desc', 3)
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { reactive } from 'vue'
|
import { reactive } from 'vue'
|
||||||
import { differenceBy, shuffle, unionBy } from 'lodash'
|
import { differenceBy, shuffle, unionBy } from 'lodash'
|
||||||
import { arrayify } from '@/utils'
|
import { arrayify } from '@/utils'
|
||||||
import { httpService } from '@/services'
|
import { http } from '@/services'
|
||||||
import { songStore } from '@/stores'
|
import { songStore } from '@/stores'
|
||||||
|
|
||||||
export const queueStore = {
|
export const queueStore = {
|
||||||
|
@ -144,12 +144,12 @@ export const queueStore = {
|
||||||
},
|
},
|
||||||
|
|
||||||
async fetchRandom (limit = 500) {
|
async fetchRandom (limit = 500) {
|
||||||
const songs = await httpService.get<Song[]>(`queue/fetch?order=rand&limit=${limit}`)
|
const songs = await http.get<Song[]>(`queue/fetch?order=rand&limit=${limit}`)
|
||||||
this.state.songs = songStore.syncWithVault(songs)
|
this.state.songs = songStore.syncWithVault(songs)
|
||||||
},
|
},
|
||||||
|
|
||||||
async fetchInOrder (sortField: SongListSortField, order: SortOrder, limit = 500) {
|
async fetchInOrder (sortField: SongListSortField, order: SortOrder, limit = 500) {
|
||||||
const songs = await httpService.get<Song[]>(`queue/fetch?order=${order}&sort=${sortField}&limit=${limit}`)
|
const songs = await http.get<Song[]>(`queue/fetch?order=${order}&sort=${sortField}&limit=${limit}`)
|
||||||
this.state.songs = songStore.syncWithVault(songs)
|
this.state.songs = songStore.syncWithVault(songs)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,14 +1,14 @@
|
||||||
import { expect, it } from 'vitest'
|
import { expect, it } from 'vitest'
|
||||||
import factory from '@/__tests__/factory'
|
import factory from '@/__tests__/factory'
|
||||||
import UnitTestCase from '@/__tests__/UnitTestCase'
|
import UnitTestCase from '@/__tests__/UnitTestCase'
|
||||||
import { httpService } from '@/services'
|
import { http } from '@/services'
|
||||||
import { recentlyPlayedStore, songStore } from '.'
|
import { recentlyPlayedStore, songStore } from '.'
|
||||||
|
|
||||||
new class extends UnitTestCase {
|
new class extends UnitTestCase {
|
||||||
protected test () {
|
protected test () {
|
||||||
it('fetches the recently played songs', async () => {
|
it('fetches the recently played songs', async () => {
|
||||||
const songs = factory<Song>('song', 3)
|
const songs = factory<Song>('song', 3)
|
||||||
const getMock = this.mock(httpService, 'get').mockResolvedValue(songs)
|
const getMock = this.mock(http, 'get').mockResolvedValue(songs)
|
||||||
const syncMock = this.mock(songStore, 'syncWithVault', songs)
|
const syncMock = this.mock(songStore, 'syncWithVault', songs)
|
||||||
|
|
||||||
await recentlyPlayedStore.fetch()
|
await recentlyPlayedStore.fetch()
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { reactive } from 'vue'
|
import { reactive } from 'vue'
|
||||||
import { httpService } from '@/services'
|
import { http } from '@/services'
|
||||||
import { remove } from 'lodash'
|
import { remove } from 'lodash'
|
||||||
import { songStore } from '@/stores'
|
import { songStore } from '@/stores'
|
||||||
|
|
||||||
|
@ -15,7 +15,7 @@ export const recentlyPlayedStore = {
|
||||||
}),
|
}),
|
||||||
|
|
||||||
async fetch () {
|
async fetch () {
|
||||||
this.state.songs = songStore.syncWithVault(await httpService.get<Song[]>('songs/recently-played'))
|
this.state.songs = songStore.syncWithVault(await http.get<Song[]>('songs/recently-played'))
|
||||||
},
|
},
|
||||||
|
|
||||||
async add (song: Song) {
|
async add (song: Song) {
|
||||||
|
|
|
@ -2,7 +2,7 @@ import { expect, it } from 'vitest'
|
||||||
import UnitTestCase from '@/__tests__/UnitTestCase'
|
import UnitTestCase from '@/__tests__/UnitTestCase'
|
||||||
import { reactive } from 'vue'
|
import { reactive } from 'vue'
|
||||||
import factory from '@/__tests__/factory'
|
import factory from '@/__tests__/factory'
|
||||||
import { httpService } from '@/services'
|
import { http } from '@/services'
|
||||||
import { albumStore, artistStore, ExcerptSearchResult, searchStore, songStore } from '.'
|
import { albumStore, artistStore, ExcerptSearchResult, searchStore, songStore } from '.'
|
||||||
|
|
||||||
new class extends UnitTestCase {
|
new class extends UnitTestCase {
|
||||||
|
@ -27,7 +27,7 @@ new class extends UnitTestCase {
|
||||||
artists: factory<Artist>('artist', 3)
|
artists: factory<Artist>('artist', 3)
|
||||||
}
|
}
|
||||||
|
|
||||||
const getMock = this.mock(httpService, 'get').mockResolvedValue(result)
|
const getMock = this.mock(http, 'get').mockResolvedValue(result)
|
||||||
const syncSongsMock = this.mock(songStore, 'syncWithVault', result.songs)
|
const syncSongsMock = this.mock(songStore, 'syncWithVault', result.songs)
|
||||||
const syncAlbumsMock = this.mock(albumStore, 'syncWithVault', result.albums)
|
const syncAlbumsMock = this.mock(albumStore, 'syncWithVault', result.albums)
|
||||||
const syncArtistsMock = this.mock(artistStore, 'syncWithVault', result.artists)
|
const syncArtistsMock = this.mock(artistStore, 'syncWithVault', result.artists)
|
||||||
|
@ -47,7 +47,7 @@ new class extends UnitTestCase {
|
||||||
it('performs a song search', async () => {
|
it('performs a song search', async () => {
|
||||||
const songs = factory<Song>('song', 3)
|
const songs = factory<Song>('song', 3)
|
||||||
|
|
||||||
const getMock = this.mock(httpService, 'get').mockResolvedValue(songs)
|
const getMock = this.mock(http, 'get').mockResolvedValue(songs)
|
||||||
const syncMock = this.mock(songStore, 'syncWithVault', songs)
|
const syncMock = this.mock(songStore, 'syncWithVault', songs)
|
||||||
|
|
||||||
await searchStore.songSearch('test')
|
await searchStore.songSearch('test')
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { reactive } from 'vue'
|
import { reactive } from 'vue'
|
||||||
import { httpService } from '@/services'
|
import { http } from '@/services'
|
||||||
import { albumStore, artistStore, songStore } from '@/stores'
|
import { albumStore, artistStore, songStore } from '@/stores'
|
||||||
|
|
||||||
type ExcerptState = {
|
type ExcerptState = {
|
||||||
|
@ -21,7 +21,7 @@ export const searchStore = {
|
||||||
}),
|
}),
|
||||||
|
|
||||||
async excerptSearch (q: string) {
|
async excerptSearch (q: string) {
|
||||||
const result = await httpService.get<ExcerptSearchResult>(`search?q=${q}`)
|
const result = await http.get<ExcerptSearchResult>(`search?q=${q}`)
|
||||||
|
|
||||||
this.state.excerpt.songs = songStore.syncWithVault(result.songs)
|
this.state.excerpt.songs = songStore.syncWithVault(result.songs)
|
||||||
this.state.excerpt.albums = albumStore.syncWithVault(result.albums)
|
this.state.excerpt.albums = albumStore.syncWithVault(result.albums)
|
||||||
|
@ -29,7 +29,7 @@ export const searchStore = {
|
||||||
},
|
},
|
||||||
|
|
||||||
async songSearch (q: string) {
|
async songSearch (q: string) {
|
||||||
this.state.songs = songStore.syncWithVault(await httpService.get<Song[]>(`search/songs?q=${q}`))
|
this.state.songs = songStore.syncWithVault(await http.get<Song[]>(`search/songs?q=${q}`))
|
||||||
},
|
},
|
||||||
|
|
||||||
resetSongResultState () {
|
resetSongResultState () {
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { expect, it } from 'vitest'
|
import { expect, it } from 'vitest'
|
||||||
import UnitTestCase from '@/__tests__/UnitTestCase'
|
import UnitTestCase from '@/__tests__/UnitTestCase'
|
||||||
import { httpService } from '@/services'
|
import { http } from '@/services'
|
||||||
import { settingStore } from '.'
|
import { settingStore } from '.'
|
||||||
|
|
||||||
new class extends UnitTestCase {
|
new class extends UnitTestCase {
|
||||||
|
@ -11,7 +11,7 @@ new class extends UnitTestCase {
|
||||||
})
|
})
|
||||||
|
|
||||||
it('updates the media path', async () => {
|
it('updates the media path', async () => {
|
||||||
this.mock(httpService, 'put')
|
this.mock(http, 'put')
|
||||||
await settingStore.update({ media_path: '/dev/null' })
|
await settingStore.update({ media_path: '/dev/null' })
|
||||||
expect(settingStore.state.media_path).toEqual('/dev/null')
|
expect(settingStore.state.media_path).toEqual('/dev/null')
|
||||||
})
|
})
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { reactive } from 'vue'
|
import { reactive } from 'vue'
|
||||||
import { httpService } from '@/services'
|
import { http } from '@/services'
|
||||||
import { merge } from 'lodash'
|
import { merge } from 'lodash'
|
||||||
|
|
||||||
export const settingStore = {
|
export const settingStore = {
|
||||||
|
@ -12,7 +12,7 @@ export const settingStore = {
|
||||||
},
|
},
|
||||||
|
|
||||||
async update (settings: Settings) {
|
async update (settings: Settings) {
|
||||||
await httpService.put('settings', settings)
|
await http.put('settings', settings)
|
||||||
merge(this.state, settings)
|
merge(this.state, settings)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,8 +3,7 @@ import isMobile from 'ismobilejs'
|
||||||
import UnitTestCase from '@/__tests__/UnitTestCase'
|
import UnitTestCase from '@/__tests__/UnitTestCase'
|
||||||
import { expect, it } from 'vitest'
|
import { expect, it } from 'vitest'
|
||||||
import factory from '@/__tests__/factory'
|
import factory from '@/__tests__/factory'
|
||||||
import { authService, httpService } from '@/services'
|
import { authService, http } from '@/services'
|
||||||
import { eventBus } from '@/utils'
|
|
||||||
import { albumStore, artistStore, commonStore, overviewStore, preferenceStore, songStore, SongUpdateResult } from '.'
|
import { albumStore, artistStore, commonStore, overviewStore, preferenceStore, songStore, SongUpdateResult } from '.'
|
||||||
|
|
||||||
new class extends UnitTestCase {
|
new class extends UnitTestCase {
|
||||||
|
@ -53,7 +52,7 @@ new class extends UnitTestCase {
|
||||||
|
|
||||||
it('resolves a song', async () => {
|
it('resolves a song', async () => {
|
||||||
const song = factory<Song>('song')
|
const song = factory<Song>('song')
|
||||||
const getMock = this.mock(httpService, 'get').mockResolvedValueOnce(song)
|
const getMock = this.mock(http, 'get').mockResolvedValueOnce(song)
|
||||||
|
|
||||||
expect(await songStore.resolve(song.id)).toEqual(song)
|
expect(await songStore.resolve(song.id)).toEqual(song)
|
||||||
expect(getMock).toHaveBeenCalledWith(`songs/${song.id}`)
|
expect(getMock).toHaveBeenCalledWith(`songs/${song.id}`)
|
||||||
|
@ -74,7 +73,7 @@ new class extends UnitTestCase {
|
||||||
it('registers a play', async () => {
|
it('registers a play', async () => {
|
||||||
const song = factory<Song>('song', { play_count: 42 })
|
const song = factory<Song>('song', { play_count: 42 })
|
||||||
|
|
||||||
const postMock = this.mock(httpService, 'post').mockResolvedValueOnce(factory<Interaction>('interaction', {
|
const postMock = this.mock(http, 'post').mockResolvedValueOnce(factory<Interaction>('interaction', {
|
||||||
song_id: song.id,
|
song_id: song.id,
|
||||||
play_count: 50
|
play_count: 50
|
||||||
}))
|
}))
|
||||||
|
@ -87,7 +86,7 @@ new class extends UnitTestCase {
|
||||||
it('scrobbles', async () => {
|
it('scrobbles', async () => {
|
||||||
const song = factory<Song>('song')
|
const song = factory<Song>('song')
|
||||||
song.play_start_time = 123456789
|
song.play_start_time = 123456789
|
||||||
const postMock = this.mock(httpService, 'post')
|
const postMock = this.mock(http, 'post')
|
||||||
|
|
||||||
await songStore.scrobble(song)
|
await songStore.scrobble(song)
|
||||||
|
|
||||||
|
@ -123,9 +122,7 @@ new class extends UnitTestCase {
|
||||||
const syncArtistsMock = this.mock(artistStore, 'syncWithVault')
|
const syncArtistsMock = this.mock(artistStore, 'syncWithVault')
|
||||||
const removeAlbumsMock = this.mock(albumStore, 'removeByIds')
|
const removeAlbumsMock = this.mock(albumStore, 'removeByIds')
|
||||||
const removeArtistsMock = this.mock(artistStore, 'removeByIds')
|
const removeArtistsMock = this.mock(artistStore, 'removeByIds')
|
||||||
const emitMock = this.mock(eventBus, 'emit')
|
const putMock = this.mock(http, 'put').mockResolvedValueOnce(result)
|
||||||
const refreshMock = this.mock(overviewStore, 'refresh')
|
|
||||||
const putMock = this.mock(httpService, 'put').mockResolvedValueOnce(result)
|
|
||||||
|
|
||||||
await songStore.update(songs, {
|
await songStore.update(songs, {
|
||||||
album_name: 'Updated Album',
|
album_name: 'Updated Album',
|
||||||
|
@ -145,8 +142,6 @@ new class extends UnitTestCase {
|
||||||
expect(syncArtistsMock).toHaveBeenCalledWith(result.artists)
|
expect(syncArtistsMock).toHaveBeenCalledWith(result.artists)
|
||||||
expect(removeAlbumsMock).toHaveBeenCalledWith([10])
|
expect(removeAlbumsMock).toHaveBeenCalledWith([10])
|
||||||
expect(removeArtistsMock).toHaveBeenCalledWith([42])
|
expect(removeArtistsMock).toHaveBeenCalledWith([42])
|
||||||
expect(emitMock).toHaveBeenCalledWith('SONGS_UPDATED')
|
|
||||||
expect(refreshMock).toHaveBeenCalled()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('gets source URL', () => {
|
it('gets source URL', () => {
|
||||||
|
@ -171,7 +166,7 @@ new class extends UnitTestCase {
|
||||||
playback_state: null
|
playback_state: null
|
||||||
})
|
})
|
||||||
|
|
||||||
const trackPlayCountMock = this.mock(songStore, 'trackPlayCount')
|
const trackPlayCountMock = this.mock(songStore, 'setUpPlayCountTracking')
|
||||||
|
|
||||||
expect(songStore.syncWithVault(song)).toEqual([reactive(song)])
|
expect(songStore.syncWithVault(song)).toEqual([reactive(song)])
|
||||||
expect(songStore.vault.has(song.id)).toBe(true)
|
expect(songStore.vault.has(song.id)).toBe(true)
|
||||||
|
@ -183,7 +178,7 @@ new class extends UnitTestCase {
|
||||||
expect(trackPlayCountMock).toHaveBeenCalledOnce()
|
expect(trackPlayCountMock).toHaveBeenCalledOnce()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('tracks play count', async () => {
|
it('sets up play count tracking', async () => {
|
||||||
const refreshMock = this.mock(overviewStore, 'refresh')
|
const refreshMock = this.mock(overviewStore, 'refresh')
|
||||||
const artist = reactive(factory<Artist>('artist', { id: 42, play_count: 100 }))
|
const artist = reactive(factory<Artist>('artist', { id: 42, play_count: 100 }))
|
||||||
const album = reactive(factory<Album>('album', { id: 10, play_count: 120 }))
|
const album = reactive(factory<Album>('album', { id: 10, play_count: 120 }))
|
||||||
|
@ -200,7 +195,7 @@ new class extends UnitTestCase {
|
||||||
play_count: 98
|
play_count: 98
|
||||||
}))
|
}))
|
||||||
|
|
||||||
songStore.trackPlayCount(song)
|
songStore.setUpPlayCountTracking(song)
|
||||||
song.play_count = 100
|
song.play_count = 100
|
||||||
|
|
||||||
await this.tick()
|
await this.tick()
|
||||||
|
@ -214,8 +209,8 @@ new class extends UnitTestCase {
|
||||||
it('fetches for album', async () => {
|
it('fetches for album', async () => {
|
||||||
const songs = factory<Song>('song', 3)
|
const songs = factory<Song>('song', 3)
|
||||||
const album = factory<Album>('album', { id: 42 })
|
const album = factory<Album>('album', { id: 42 })
|
||||||
const getMock = this.mock(httpService, 'get').mockResolvedValueOnce(songs)
|
const getMock = this.mock(http, 'get').mockResolvedValueOnce(songs)
|
||||||
const syncMock = this.mock(songStore, 'syncWithVault')
|
const syncMock = this.mock(songStore, 'syncWithVault', songs)
|
||||||
|
|
||||||
await songStore.fetchForAlbum(album)
|
await songStore.fetchForAlbum(album)
|
||||||
|
|
||||||
|
@ -226,8 +221,8 @@ new class extends UnitTestCase {
|
||||||
it('fetches for artist', async () => {
|
it('fetches for artist', async () => {
|
||||||
const songs = factory<Song>('song', 3)
|
const songs = factory<Song>('song', 3)
|
||||||
const artist = factory<Artist>('artist', { id: 42 })
|
const artist = factory<Artist>('artist', { id: 42 })
|
||||||
const getMock = this.mock(httpService, 'get').mockResolvedValueOnce(songs)
|
const getMock = this.mock(http, 'get').mockResolvedValueOnce(songs)
|
||||||
const syncMock = this.mock(songStore, 'syncWithVault')
|
const syncMock = this.mock(songStore, 'syncWithVault', songs)
|
||||||
|
|
||||||
await songStore.fetchForArtist(artist)
|
await songStore.fetchForArtist(artist)
|
||||||
|
|
||||||
|
@ -238,8 +233,8 @@ new class extends UnitTestCase {
|
||||||
it('fetches for playlist', async () => {
|
it('fetches for playlist', async () => {
|
||||||
const songs = factory<Song>('song', 3)
|
const songs = factory<Song>('song', 3)
|
||||||
const playlist = factory<Playlist>('playlist', { id: 42 })
|
const playlist = factory<Playlist>('playlist', { id: 42 })
|
||||||
const getMock = this.mock(httpService, 'get').mockResolvedValueOnce(songs)
|
const getMock = this.mock(http, 'get').mockResolvedValueOnce(songs)
|
||||||
const syncMock = this.mock(songStore, 'syncWithVault')
|
const syncMock = this.mock(songStore, 'syncWithVault', songs)
|
||||||
|
|
||||||
await songStore.fetchForPlaylist(playlist)
|
await songStore.fetchForPlaylist(playlist)
|
||||||
|
|
||||||
|
@ -250,7 +245,7 @@ new class extends UnitTestCase {
|
||||||
it('paginates', async () => {
|
it('paginates', async () => {
|
||||||
const songs = factory<Song>('song', 3)
|
const songs = factory<Song>('song', 3)
|
||||||
|
|
||||||
const getMock = this.mock(httpService, 'get').mockResolvedValueOnce({
|
const getMock = this.mock(http, 'get').mockResolvedValueOnce({
|
||||||
data: songs,
|
data: songs,
|
||||||
links: {
|
links: {
|
||||||
next: 'http://localhost/api/v1/songs?page=3'
|
next: 'http://localhost/api/v1/songs?page=3'
|
||||||
|
|
|
@ -2,8 +2,8 @@ import isMobile from 'ismobilejs'
|
||||||
import slugify from 'slugify'
|
import slugify from 'slugify'
|
||||||
import { merge, orderBy, sumBy, take, unionBy, uniqBy } from 'lodash'
|
import { merge, orderBy, sumBy, take, unionBy, uniqBy } from 'lodash'
|
||||||
import { reactive, UnwrapNestedRefs, watch } from 'vue'
|
import { reactive, UnwrapNestedRefs, watch } from 'vue'
|
||||||
import { arrayify, eventBus, logger, secondsToHis, use } from '@/utils'
|
import { arrayify, logger, secondsToHis, use } from '@/utils'
|
||||||
import { authService, cache, httpService } from '@/services'
|
import { authService, cache, http } from '@/services'
|
||||||
import { albumStore, artistStore, commonStore, overviewStore, playlistStore, preferenceStore } from '@/stores'
|
import { albumStore, artistStore, commonStore, overviewStore, playlistStore, preferenceStore } from '@/stores'
|
||||||
|
|
||||||
export type SongUpdateData = {
|
export type SongUpdateData = {
|
||||||
|
@ -36,7 +36,8 @@ export const songStore = {
|
||||||
getFormattedLength: (songs: Song | Song[]) => secondsToHis(sumBy(arrayify(songs), 'length')),
|
getFormattedLength: (songs: Song | Song[]) => secondsToHis(sumBy(arrayify(songs), 'length')),
|
||||||
|
|
||||||
byId (id: string) {
|
byId (id: string) {
|
||||||
return this.vault.get(id)
|
const song = this.vault.get(id)
|
||||||
|
return song?.deleted ? undefined : song
|
||||||
},
|
},
|
||||||
|
|
||||||
byIds (ids: string[]) {
|
byIds (ids: string[]) {
|
||||||
|
@ -54,7 +55,7 @@ export const songStore = {
|
||||||
|
|
||||||
if (!song) {
|
if (!song) {
|
||||||
try {
|
try {
|
||||||
song = this.syncWithVault(await httpService.get<Song>(`songs/${id}`))[0]
|
song = this.syncWithVault(await http.get<Song>(`songs/${id}`))[0]
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error(e)
|
logger.error(e)
|
||||||
}
|
}
|
||||||
|
@ -83,18 +84,18 @@ export const songStore = {
|
||||||
* Increase a play count for a song.
|
* Increase a play count for a song.
|
||||||
*/
|
*/
|
||||||
registerPlay: async (song: Song) => {
|
registerPlay: async (song: Song) => {
|
||||||
const interaction = await httpService.post<Interaction>('interaction/play', { song: song.id })
|
const interaction = await http.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.
|
// Use the data from the server to make sure we don't miss a play from another device.
|
||||||
song.play_count = interaction.play_count
|
song.play_count = interaction.play_count
|
||||||
},
|
},
|
||||||
|
|
||||||
scrobble: async (song: Song) => await httpService.post(`songs/${song.id}/scrobble`, {
|
scrobble: async (song: Song) => await http.post(`songs/${song.id}/scrobble`, {
|
||||||
timestamp: song.play_start_time
|
timestamp: song.play_start_time
|
||||||
}),
|
}),
|
||||||
|
|
||||||
async update (songsToUpdate: Song[], data: SongUpdateData) {
|
async update (songsToUpdate: Song[], data: SongUpdateData) {
|
||||||
const { songs, artists, albums, removed } = await httpService.put<SongUpdateResult>('songs', {
|
const { songs, artists, albums, removed } = await http.put<SongUpdateResult>('songs', {
|
||||||
data,
|
data,
|
||||||
songs: songsToUpdate.map(song => song.id)
|
songs: songsToUpdate.map(song => song.id)
|
||||||
})
|
})
|
||||||
|
@ -106,10 +107,6 @@ export const songStore = {
|
||||||
|
|
||||||
albumStore.removeByIds(removed.albums.map(album => album.id))
|
albumStore.removeByIds(removed.albums.map(album => album.id))
|
||||||
artistStore.removeByIds(removed.artists.map(artist => artist.id))
|
artistStore.removeByIds(removed.artists.map(artist => artist.id))
|
||||||
|
|
||||||
eventBus.emit('SONGS_UPDATED')
|
|
||||||
|
|
||||||
overviewStore.refresh()
|
|
||||||
},
|
},
|
||||||
|
|
||||||
getSourceUrl: (song: Song) => {
|
getSourceUrl: (song: Song) => {
|
||||||
|
@ -129,7 +126,7 @@ export const songStore = {
|
||||||
} else {
|
} else {
|
||||||
local = reactive(song)
|
local = reactive(song)
|
||||||
local.playback_state = 'Stopped'
|
local.playback_state = 'Stopped'
|
||||||
this.trackPlayCount(local)
|
this.setUpPlayCountTracking(local)
|
||||||
this.vault.set(local.id, local)
|
this.vault.set(local.id, local)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -137,7 +134,7 @@ export const songStore = {
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
trackPlayCount: (song: UnwrapNestedRefs<Song>) => {
|
setUpPlayCountTracking: (song: UnwrapNestedRefs<Song>) => {
|
||||||
watch(() => song.play_count, (newCount, oldCount) => {
|
watch(() => song.play_count, (newCount, oldCount) => {
|
||||||
const album = albumStore.byId(song.album_id)
|
const album = albumStore.byId(song.album_id)
|
||||||
album && (album.play_count += (newCount - oldCount))
|
album && (album.play_count += (newCount - oldCount))
|
||||||
|
@ -154,29 +151,23 @@ export const songStore = {
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async cacheable (key: any, fetcher: Promise<Song[]>) {
|
||||||
|
const songs = await cache.remember<Song[]>(key, async () => this.syncWithVault(await fetcher))
|
||||||
|
return songs.filter(song => !song.deleted)
|
||||||
|
},
|
||||||
|
|
||||||
async fetchForAlbum (album: Album | number) {
|
async fetchForAlbum (album: Album | number) {
|
||||||
const id = typeof album === 'number' ? album : album.id
|
const id = typeof album === 'number' ? album : album.id
|
||||||
|
return await this.cacheable(['album.songs', id], http.get<Song[]>(`albums/${id}/songs`))
|
||||||
return await cache.remember<Song[]>(
|
|
||||||
[`album.songs`, id],
|
|
||||||
async () => this.syncWithVault(await httpService.get<Song[]>(`albums/${id}/songs`))
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async fetchForArtist (artist: Artist | number) {
|
async fetchForArtist (artist: Artist | number) {
|
||||||
const id = typeof artist === 'number' ? artist : artist.id
|
const id = typeof artist === 'number' ? artist : artist.id
|
||||||
|
return await this.cacheable(['artist.songs', id], http.get<Song[]>(`artists/${id}/songs`))
|
||||||
return await cache.remember<Song[]>(
|
|
||||||
['artist.songs', id],
|
|
||||||
async () => this.syncWithVault(await httpService.get<Song[]>(`artists/${id}/songs`))
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async fetchForPlaylist (playlist: Playlist) {
|
async fetchForPlaylist (playlist: Playlist) {
|
||||||
return await cache.remember<Song[]>(
|
return await this.cacheable(['playlist.songs', playlist.id], http.get<Song[]>(`playlists/${playlist.id}/songs`))
|
||||||
[`playlist.songs`, playlist.id],
|
|
||||||
async () => this.syncWithVault(await httpService.get<Song[]>(`playlists/${playlist.id}/songs`))
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async fetchForPlaylistFolder (folder: PlaylistFolder) {
|
async fetchForPlaylistFolder (folder: PlaylistFolder) {
|
||||||
|
@ -190,7 +181,7 @@ export const songStore = {
|
||||||
},
|
},
|
||||||
|
|
||||||
async paginate (sortField: SongListSortField, sortOrder: SortOrder, page: number) {
|
async paginate (sortField: SongListSortField, sortOrder: SortOrder, page: number) {
|
||||||
const resource = await httpService.get<PaginatorResource>(
|
const resource = await http.get<PaginatorResource>(
|
||||||
`songs?page=${page}&sort=${sortField}&order=${sortOrder}`
|
`songs?page=${page}&sort=${sortField}&order=${sortOrder}`
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -200,10 +191,21 @@ export const songStore = {
|
||||||
},
|
},
|
||||||
|
|
||||||
getMostPlayed (count: number) {
|
getMostPlayed (count: number) {
|
||||||
return take(orderBy(Array.from(this.vault.values()), 'play_count', 'desc'), count)
|
return take(orderBy(Array.from(this.vault.values()).filter(song => !song.deleted), 'play_count', 'desc'), count)
|
||||||
},
|
},
|
||||||
|
|
||||||
getRecentlyAdded (count: number) {
|
getRecentlyAdded (count: number) {
|
||||||
return take(orderBy(Array.from(this.vault.values()), 'created_at', 'desc'), count)
|
return take(orderBy(Array.from(this.vault.values()).filter(song => !song.deleted), 'created_at', 'desc'), count)
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteFromFilesystem (songs: Song[]) {
|
||||||
|
const ids = songs.map(song => {
|
||||||
|
// Whenever a vault sync is requested (e.g. upon playlist/album/artist fetching)
|
||||||
|
// songs marked as "deleted" will be excluded.
|
||||||
|
song.deleted = true
|
||||||
|
return song.id
|
||||||
|
})
|
||||||
|
|
||||||
|
await http.delete('songs', { songs: ids })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { expect, it } from 'vitest'
|
import { expect, it } from 'vitest'
|
||||||
import UnitTestCase from '@/__tests__/UnitTestCase'
|
import UnitTestCase from '@/__tests__/UnitTestCase'
|
||||||
import factory from '@/__tests__/factory'
|
import factory from '@/__tests__/factory'
|
||||||
import { httpService } from '@/services'
|
import { http } from '@/services'
|
||||||
import { CreateUserData, UpdateCurrentProfileData, UpdateUserData, userStore } from '.'
|
import { CreateUserData, UpdateCurrentProfileData, UpdateUserData, userStore } from '.'
|
||||||
|
|
||||||
const currentUser = factory<User>('user', {
|
const currentUser = factory<User>('user', {
|
||||||
|
@ -35,7 +35,7 @@ new class extends UnitTestCase {
|
||||||
|
|
||||||
it('fetches users', async () => {
|
it('fetches users', async () => {
|
||||||
const users = factory<User>('user', 3)
|
const users = factory<User>('user', 3)
|
||||||
const getMock = this.mock(httpService, 'get').mockResolvedValue(users)
|
const getMock = this.mock(http, 'get').mockResolvedValue(users)
|
||||||
|
|
||||||
await userStore.fetch()
|
await userStore.fetch()
|
||||||
|
|
||||||
|
@ -51,21 +51,21 @@ new class extends UnitTestCase {
|
||||||
})
|
})
|
||||||
|
|
||||||
it('logs in', async () => {
|
it('logs in', async () => {
|
||||||
const postMock = this.mock(httpService, 'post')
|
const postMock = this.mock(http, 'post')
|
||||||
await userStore.login('john@doe.com', 'curry-wurst')
|
await userStore.login('john@doe.com', 'curry-wurst')
|
||||||
|
|
||||||
expect(postMock).toHaveBeenCalledWith('me', { email: 'john@doe.com', password: 'curry-wurst' })
|
expect(postMock).toHaveBeenCalledWith('me', { email: 'john@doe.com', password: 'curry-wurst' })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('logs out', async () => {
|
it('logs out', async () => {
|
||||||
const deleteMock = this.mock(httpService, 'delete')
|
const deleteMock = this.mock(http, 'delete')
|
||||||
await userStore.logout()
|
await userStore.logout()
|
||||||
|
|
||||||
expect(deleteMock).toHaveBeenCalledWith('me')
|
expect(deleteMock).toHaveBeenCalledWith('me')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('gets profile', async () => {
|
it('gets profile', async () => {
|
||||||
const getMock = this.mock(httpService, 'get')
|
const getMock = this.mock(http, 'get')
|
||||||
await userStore.getProfile()
|
await userStore.getProfile()
|
||||||
|
|
||||||
expect(getMock).toHaveBeenCalledWith('me')
|
expect(getMock).toHaveBeenCalledWith('me')
|
||||||
|
@ -78,7 +78,7 @@ new class extends UnitTestCase {
|
||||||
email: 'jane@doe.com'
|
email: 'jane@doe.com'
|
||||||
})
|
})
|
||||||
|
|
||||||
const putMock = this.mock(httpService, 'put').mockResolvedValue(updated)
|
const putMock = this.mock(http, 'put').mockResolvedValue(updated)
|
||||||
|
|
||||||
const data: UpdateCurrentProfileData = {
|
const data: UpdateCurrentProfileData = {
|
||||||
current_password: 'curry-wurst',
|
current_password: 'curry-wurst',
|
||||||
|
@ -102,7 +102,7 @@ new class extends UnitTestCase {
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = factory<User>('user', data)
|
const user = factory<User>('user', data)
|
||||||
const postMock = this.mock(httpService, 'post').mockResolvedValue(user)
|
const postMock = this.mock(http, 'post').mockResolvedValue(user)
|
||||||
|
|
||||||
expect(await userStore.store(data)).toEqual(user)
|
expect(await userStore.store(data)).toEqual(user)
|
||||||
expect(postMock).toHaveBeenCalledWith('users', data)
|
expect(postMock).toHaveBeenCalledWith('users', data)
|
||||||
|
@ -122,7 +122,7 @@ new class extends UnitTestCase {
|
||||||
}
|
}
|
||||||
|
|
||||||
const updated = { ...user, ...data }
|
const updated = { ...user, ...data }
|
||||||
const putMock = this.mock(httpService, 'put').mockResolvedValue(updated)
|
const putMock = this.mock(http, 'put').mockResolvedValue(updated)
|
||||||
|
|
||||||
await userStore.update(user, data)
|
await userStore.update(user, data)
|
||||||
|
|
||||||
|
@ -131,7 +131,7 @@ new class extends UnitTestCase {
|
||||||
})
|
})
|
||||||
|
|
||||||
it('deletes a user', async () => {
|
it('deletes a user', async () => {
|
||||||
const deleteMock = this.mock(httpService, 'delete')
|
const deleteMock = this.mock(http, 'delete')
|
||||||
|
|
||||||
const user = factory<User>('user', { id: 2 })
|
const user = factory<User>('user', { id: 2 })
|
||||||
userStore.state.users.push(...userStore.syncWithVault(user))
|
userStore.state.users.push(...userStore.syncWithVault(user))
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { differenceBy, merge } from 'lodash'
|
import { differenceBy, merge } from 'lodash'
|
||||||
import { httpService } from '@/services'
|
import { http } from '@/services'
|
||||||
import { reactive } from 'vue'
|
import { reactive } from 'vue'
|
||||||
import { arrayify } from '@/utils'
|
import { arrayify } from '@/utils'
|
||||||
import { UnwrapNestedRefs } from '@vue/reactivity'
|
import { UnwrapNestedRefs } from '@vue/reactivity'
|
||||||
|
@ -50,7 +50,7 @@ export const userStore = {
|
||||||
},
|
},
|
||||||
|
|
||||||
async fetch () {
|
async fetch () {
|
||||||
this.state.users = this.syncWithVault(await httpService.get<User[]>('users'))
|
this.state.users = this.syncWithVault(await http.get<User[]>('users'))
|
||||||
},
|
},
|
||||||
|
|
||||||
byId (id: number) {
|
byId (id: number) {
|
||||||
|
@ -61,26 +61,26 @@ export const userStore = {
|
||||||
return this.state.current
|
return this.state.current
|
||||||
},
|
},
|
||||||
|
|
||||||
login: async (email: string, password: string) => await httpService.post<User>('me', { email, password }),
|
login: async (email: string, password: string) => await http.post<User>('me', { email, password }),
|
||||||
logout: async () => await httpService.delete('me'),
|
logout: async () => await http.delete('me'),
|
||||||
getProfile: async () => await httpService.get<User>('me'),
|
getProfile: async () => await http.get<User>('me'),
|
||||||
|
|
||||||
async updateProfile (data: UpdateCurrentProfileData) {
|
async updateProfile (data: UpdateCurrentProfileData) {
|
||||||
merge(this.current, (await httpService.put<User>('me', data)))
|
merge(this.current, (await http.put<User>('me', data)))
|
||||||
},
|
},
|
||||||
|
|
||||||
async store (data: CreateUserData) {
|
async store (data: CreateUserData) {
|
||||||
const user = await httpService.post<User>('users', data)
|
const user = await http.post<User>('users', data)
|
||||||
this.state.users.push(...this.syncWithVault(user))
|
this.state.users.push(...this.syncWithVault(user))
|
||||||
return this.byId(user.id)
|
return this.byId(user.id)
|
||||||
},
|
},
|
||||||
|
|
||||||
async update (user: User, data: UpdateUserData) {
|
async update (user: User, data: UpdateUserData) {
|
||||||
this.syncWithVault(await httpService.put<User>(`users/${user.id}`, data))
|
this.syncWithVault(await http.put<User>(`users/${user.id}`, data))
|
||||||
},
|
},
|
||||||
|
|
||||||
async destroy (user: User) {
|
async destroy (user: User) {
|
||||||
await httpService.delete(`users/${user.id}`)
|
await http.delete(`users/${user.id}`)
|
||||||
this.state.users = differenceBy(this.state.users, [user], 'id')
|
this.state.users = differenceBy(this.state.users, [user], 'id')
|
||||||
this.vault.delete(user.id)
|
this.vault.delete(user.id)
|
||||||
|
|
||||||
|
|
1
resources/assets/js/types.d.ts
vendored
1
resources/assets/js/types.d.ts
vendored
|
@ -161,6 +161,7 @@ interface Song {
|
||||||
play_start_time?: number
|
play_start_time?: number
|
||||||
fmt_length?: string
|
fmt_length?: string
|
||||||
created_at: string
|
created_at: string
|
||||||
|
deleted?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SmartPlaylistRuleGroup {
|
interface SmartPlaylistRuleGroup {
|
||||||
|
|
|
@ -6,6 +6,7 @@ use App\Http\Controllers\V6\API\AlbumSongController;
|
||||||
use App\Http\Controllers\V6\API\ArtistController;
|
use App\Http\Controllers\V6\API\ArtistController;
|
||||||
use App\Http\Controllers\V6\API\ArtistSongController;
|
use App\Http\Controllers\V6\API\ArtistSongController;
|
||||||
use App\Http\Controllers\V6\API\DataController;
|
use App\Http\Controllers\V6\API\DataController;
|
||||||
|
use App\Http\Controllers\V6\API\DeleteSongsController;
|
||||||
use App\Http\Controllers\V6\API\ExcerptSearchController;
|
use App\Http\Controllers\V6\API\ExcerptSearchController;
|
||||||
use App\Http\Controllers\V6\API\FavoriteSongController;
|
use App\Http\Controllers\V6\API\FavoriteSongController;
|
||||||
use App\Http\Controllers\V6\API\FetchAlbumInformationController;
|
use App\Http\Controllers\V6\API\FetchAlbumInformationController;
|
||||||
|
@ -50,6 +51,7 @@ Route::prefix('api')->middleware('api')->group(static function (): void {
|
||||||
Route::apiResource('songs', SongController::class)->where(['song' => Song::ID_REGEX]);
|
Route::apiResource('songs', SongController::class)->where(['song' => Song::ID_REGEX]);
|
||||||
Route::get('songs/recently-played', [RecentlyPlayedSongController::class, 'index']);
|
Route::get('songs/recently-played', [RecentlyPlayedSongController::class, 'index']);
|
||||||
Route::get('songs/favorite', [FavoriteSongController::class, 'index']);
|
Route::get('songs/favorite', [FavoriteSongController::class, 'index']);
|
||||||
|
Route::delete('songs', DeleteSongsController::class);
|
||||||
|
|
||||||
Route::apiResource('users', UserController::class);
|
Route::apiResource('users', UserController::class);
|
||||||
|
|
||||||
|
|
|
@ -3,6 +3,8 @@
|
||||||
namespace Tests\Feature\V6;
|
namespace Tests\Feature\V6;
|
||||||
|
|
||||||
use App\Models\Song;
|
use App\Models\Song;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
class SongTest extends TestCase
|
class SongTest extends TestCase
|
||||||
{
|
{
|
||||||
|
@ -60,4 +62,29 @@ class SongTest extends TestCase
|
||||||
|
|
||||||
$this->getAs('api/songs/' . $song->id)->assertJsonStructure(self::JSON_STRUCTURE);
|
$this->getAs('api/songs/' . $song->id)->assertJsonStructure(self::JSON_STRUCTURE);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testDelete(): void
|
||||||
|
{
|
||||||
|
/** @var Collection|array<array-key, Song> $songs */
|
||||||
|
$songs = Song::factory(3)->create();
|
||||||
|
|
||||||
|
/** @var User $admin */
|
||||||
|
$admin = User::factory()->admin()->create();
|
||||||
|
|
||||||
|
$this->deleteAs('api/songs', ['songs' => $songs->pluck('id')->toArray()], $admin)
|
||||||
|
->assertNoContent();
|
||||||
|
|
||||||
|
$songs->each(fn (Song $song) => $this->assertModelMissing($song));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testUnauthorizedDelete(): void
|
||||||
|
{
|
||||||
|
/** @var Collection|array<array-key, Song> $songs */
|
||||||
|
$songs = Song::factory(3)->create();
|
||||||
|
|
||||||
|
$this->deleteAs('api/songs', ['songs' => $songs->pluck('id')->toArray()])
|
||||||
|
->assertForbidden();
|
||||||
|
|
||||||
|
$songs->each(fn (Song $song) => $this->assertModelExists($song));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -47,7 +47,6 @@ class FileSynchronizerTest extends TestCase
|
||||||
self::assertEqualsWithDelta(10, $info->length, 0.1);
|
self::assertEqualsWithDelta(10, $info->length, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @test */
|
|
||||||
public function testGetFileInfoVorbisCommentsFlac(): void
|
public function testGetFileInfoVorbisCommentsFlac(): void
|
||||||
{
|
{
|
||||||
$flacPath = __DIR__ . '/../../songs/full-vorbis-comments.flac';
|
$flacPath = __DIR__ . '/../../songs/full-vorbis-comments.flac';
|
||||||
|
@ -77,7 +76,6 @@ class FileSynchronizerTest extends TestCase
|
||||||
self::assertEqualsWithDelta(10, $info->length, 0.1);
|
self::assertEqualsWithDelta(10, $info->length, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @test */
|
|
||||||
public function testSongWithoutTitleHasFileNameAsTitle(): void
|
public function testSongWithoutTitleHasFileNameAsTitle(): void
|
||||||
{
|
{
|
||||||
$this->fileSynchronizer->setFile(__DIR__ . '/../../songs/blank.mp3');
|
$this->fileSynchronizer->setFile(__DIR__ . '/../../songs/blank.mp3');
|
||||||
|
|
Loading…
Reference in a new issue