feat: allow deleting songs from file system (closes #1478)

This commit is contained in:
Phan An 2022-09-15 16:07:25 +07:00
parent 4c7e2644a3
commit 6791624ca5
No known key found for this signature in database
GPG key ID: A81E4477F0BB6FDC
77 changed files with 423 additions and 317 deletions

View file

@ -115,6 +115,9 @@ OUTPUT_BIT_RATE=128
# environment, such a download will (silently) fail.
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.
# If this attempts for any reason, you can force it by setting this value to true.

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

View file

@ -3,7 +3,7 @@
namespace App\Http\Controllers\V6\API;
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\Models\User;
use App\Services\V6\SearchService;

View file

@ -3,8 +3,8 @@
namespace App\Http\Controllers\V6\API;
use App\Http\Controllers\Controller;
use App\Http\Controllers\V6\Requests\PlaylistFolderStoreRequest;
use App\Http\Controllers\V6\Requests\PlaylistFolderUpdateRequest;
use App\Http\Requests\V6\API\PlaylistFolderStoreRequest;
use App\Http\Requests\V6\API\PlaylistFolderUpdateRequest;
use App\Http\Resources\PlaylistFolderResource;
use App\Models\PlaylistFolder;
use App\Models\User;

View file

@ -3,8 +3,8 @@
namespace App\Http\Controllers\V6\API;
use App\Http\Controllers\Controller;
use App\Http\Controllers\V6\Requests\PlaylistFolderPlaylistDestroyRequest;
use App\Http\Controllers\V6\Requests\PlaylistFolderPlaylistStoreRequest;
use App\Http\Requests\V6\API\PlaylistFolderPlaylistDestroyRequest;
use App\Http\Requests\V6\API\PlaylistFolderPlaylistStoreRequest;
use App\Models\PlaylistFolder;
use App\Services\PlaylistFolderService;
use Illuminate\Support\Arr;

View file

@ -3,8 +3,8 @@
namespace App\Http\Controllers\V6\API;
use App\Http\Controllers\Controller;
use App\Http\Controllers\V6\Requests\AddSongsToPlaylistRequest;
use App\Http\Controllers\V6\Requests\RemoveSongsFromPlaylistRequest;
use App\Http\Requests\V6\API\AddSongsToPlaylistRequest;
use App\Http\Requests\V6\API\RemoveSongsFromPlaylistRequest;
use App\Http\Resources\SongResource;
use App\Models\Playlist;
use App\Models\User;

View file

@ -3,7 +3,7 @@
namespace App\Http\Controllers\V6\API;
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\Models\User;
use App\Repositories\SongRepository;

View file

@ -3,7 +3,7 @@
namespace App\Http\Controllers\V6\API;
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\Models\Song;
use App\Models\User;

View file

@ -3,7 +3,7 @@
namespace App\Http\Controllers\V6\API;
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\Models\User;
use App\Services\V6\SearchService;

View file

@ -1,6 +1,6 @@
<?php
namespace App\Http\Controllers\V6\Requests;
namespace App\Http\Requests\V6\API;
use App\Http\Requests\API\Request;
use App\Models\Song;

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

View file

@ -1,6 +1,6 @@
<?php
namespace App\Http\Controllers\V6\Requests;
namespace App\Http\Requests\V6\API;
use App\Http\Requests\API\Request;
use App\Models\Playlist;

View file

@ -1,6 +1,6 @@
<?php
namespace App\Http\Controllers\V6\Requests;
namespace App\Http\Requests\V6\API;
use App\Http\Requests\API\Request;
use App\Models\Playlist;

View file

@ -1,6 +1,6 @@
<?php
namespace App\Http\Controllers\V6\Requests;
namespace App\Http\Requests\V6\API;
use App\Http\Requests\API\Request;

View file

@ -1,6 +1,6 @@
<?php
namespace App\Http\Controllers\V6\Requests;
namespace App\Http\Requests\V6\API;
use App\Http\Requests\API\Request;

View file

@ -1,6 +1,6 @@
<?php
namespace App\Http\Controllers\V6\Requests;
namespace App\Http\Requests\V6\API;
use App\Http\Requests\API\Request;
use App\Repositories\SongRepository;

View file

@ -1,6 +1,6 @@
<?php
namespace App\Http\Controllers\V6\Requests;
namespace App\Http\Requests\V6\API;
use App\Http\Requests\API\Request;

View file

@ -1,6 +1,6 @@
<?php
namespace App\Http\Controllers\V6\Requests;
namespace App\Http\Requests\V6\API;
use App\Http\Requests\API\Request;

View file

@ -1,6 +1,6 @@
<?php
namespace App\Http\Controllers\V6\Requests;
namespace App\Http\Requests\V6\API;
use App\Http\Requests\API\Request;

View file

@ -1,6 +1,6 @@
<?php
namespace App\Http\Controllers\V6\Requests;
namespace App\Http\Requests\V6\API;
use App\Http\Requests\API\Request;

View file

@ -2,17 +2,21 @@
namespace App\Services;
use App\Events\LibraryChanged;
use App\Models\Album;
use App\Models\Artist;
use App\Models\Song;
use App\Repositories\SongRepository;
use App\Values\SongUpdateData;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Psr\Log\LoggerInterface;
use Throwable;
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);
}
/**
* @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());
});
}
}

View file

@ -126,10 +126,9 @@ return [
],
'cache_media' => env('CACHE_MEDIA', true),
'memory_limit' => env('MEMORY_LIMIT'),
'force_https' => env('FORCE_HTTPS', false),
'backup_on_delete' => env('BACKUP_ON_DELETE', true),
'misc' => [
'home_url' => 'https://koel.dev',

View file

@ -53,7 +53,7 @@ import { orderBy } from 'lodash'
import { onMounted, ref } from 'vue'
import { isDemo } from '@/utils'
import { useNewVersionNotification } from '@/composables'
import { httpService } from '@/services'
import { http } from '@/services'
import Btn from '@/components/ui/Btn.vue'
@ -75,7 +75,7 @@ const emit = defineEmits(['close'])
const close = () => emit('close')
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>

View file

@ -48,7 +48,7 @@
<script lang="ts" setup>
import { faLastfm } from '@fortawesome/free-brands-svg-icons'
import { computed, defineAsyncComponent } from 'vue'
import { authService, httpService } from '@/services'
import { authService, http } from '@/services'
import { forceReloadWindow } from '@/utils'
import { useAuthorization, useThirdPartyServices } from '@/composables'
@ -71,7 +71,7 @@ const connect = () => window.open(
)
const disconnect = async () => {
await httpService.delete('lastfm/disconnect')
await http.delete('lastfm/disconnect')
forceReloadWindow()
}
</script>

View file

@ -53,11 +53,6 @@ new class extends UnitTestCase {
}
protected test () {
it('renders', async () => {
const { html } = await this.renderComponent()
expect(html()).toMatchSnapshot()
})
it('shows and hides info', async () => {
const { getByTitle, getByTestId, queryByTestId, html } = await this.renderComponent()
expect(queryByTestId('album-info')).toBeNull()

View file

@ -13,8 +13,8 @@
<template v-slot:meta>
<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>{{ pluralize(album.song_count, 'song') }}</span>
<span>{{ secondsToHis(album.length) }}</span>
<span>{{ pluralize(songs, 'song') }}</span>
<span>{{ duration }}</span>
<a v-if="useLastfm" class="info" href title="View album information" @click.prevent="showInfo">Info</a>
<a
@ -51,7 +51,7 @@
<script lang="ts" setup>
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 { downloadService } from '@/services'
import { useSongList } from '@/composables'
@ -84,6 +84,7 @@ const {
songList,
showingControls,
isPhone,
duration,
onPressEnter,
playAll,
playSelected,

View file

@ -52,11 +52,6 @@ new class extends UnitTestCase {
}
protected test () {
it('renders', async () => {
const { html } = await this.renderComponent()
expect(html()).toMatchSnapshot()
})
it('shows and hides info', async () => {
const { getByTitle, getByTestId, queryByTestId } = await this.renderComponent()
expect(queryByTestId('artist-info')).toBeNull()

View file

@ -12,8 +12,8 @@
<template v-slot:meta>
<span>{{ pluralize(artist.album_count, 'album') }}</span>
<span>{{ pluralize(artist.song_count, 'song') }}</span>
<span>{{ secondsToHis(artist.length) }}</span>
<span>{{ pluralize(songs, 'song') }}</span>
<span>{{ duration }}</span>
<a v-if="useLastfm" class="info" href title="View artist information" @click.prevent="showInfo">Info</a>
<a
@ -51,7 +51,7 @@
<script lang="ts" setup>
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 { downloadService } from '@/services'
import { useSongList, useThirdPartyServices } from '@/composables'
@ -81,6 +81,7 @@ const {
songList,
showingControls,
isPhone,
duration,
onPressEnter,
playAll,
playSelected,

View file

@ -1,8 +1,10 @@
import { ref } from 'vue'
import { expect, it } from 'vitest'
import UnitTestCase from '@/__tests__/UnitTestCase'
import { commonStore } from '@/stores'
import { commonStore, overviewStore } from '@/stores'
import { ActiveScreenKey } from '@/symbols'
import { EventName } from '@/config'
import { eventBus } from '@/utils'
import HomeScreen from './HomeScreen.vue'
new class extends UnitTestCase {
@ -38,5 +40,15 @@ new class extends UnitTestCase {
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()
})
}
}

View file

@ -37,7 +37,7 @@
import { faVolumeOff } from '@fortawesome/free-solid-svg-icons'
import { sample } from 'lodash'
import { computed, ref } from 'vue'
import { noop } from '@/utils'
import { eventBus, noop } from '@/utils'
import { commonStore, overviewStore, userStore } from '@/stores'
import { useAuthorization, useInfiniteScroll, useScreen } from '@/composables'
@ -72,6 +72,8 @@ const libraryEmpty = computed(() => commonStore.state.song_length === 0)
const loading = ref(false)
let initialized = false
eventBus.on(['SONGS_DELETED', 'SONGS_UPDATED'], () => overviewStore.refresh())
useScreen('Home').onScreenActivated(async () => {
if (!initialized) {
loading.value = true

View file

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

View file

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

View file

@ -1,15 +1,15 @@
import { expect, it } from 'vitest'
import { recentlyPlayedStore } from '@/stores'
import UnitTestCase from '@/__tests__/UnitTestCase'
import factory from '@/__tests__/factory'
import RecentlyPlayedSongs from './RecentlyPlayedSongs.vue'
import { fireEvent } from '@testing-library/vue'
import router from '@/router'
import { overviewStore } from '@/stores'
import RecentlyPlayedSongs from './RecentlyPlayedSongs.vue'
new class extends UnitTestCase {
protected test () {
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)
})

View file

@ -33,7 +33,7 @@
<script lang="ts" setup>
import { toRef, toRefs } from 'vue'
import router from '@/router'
import { recentlyPlayedStore } from '@/stores'
import { overviewStore } from '@/stores'
import Btn from '@/components/ui/Btn.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 { loading } = toRefs(props)
const songs = toRef(recentlyPlayedStore.excerptState, 'songs')
const songs = toRef(overviewStore.state, 'recentlyPlayed')
const goToRecentlyPlayedScreen = () => router.go('recently-played')
</script>

View file

@ -84,6 +84,7 @@
<script lang="ts" setup>
import { faSearch } from '@fortawesome/free-solid-svg-icons'
import { intersectionBy } from 'lodash'
import { ref, toRef } from 'vue'
import { eventBus } from '@/utils'
import { searchStore } from '@/stores'
@ -104,11 +105,23 @@ const searching = ref(false)
const goToSongResults = () => router.go(`search/songs/${q.value}`)
eventBus.on('SEARCH_KEYWORDS_CHANGED', async (_q: string) => {
q.value = _q
const doSearch = async () => {
searching.value = true
await searchStore.excerptSearch(q.value)
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>

View file

@ -1,7 +1,7 @@
import { expect, it } from 'vitest'
import factory from '@/__tests__/factory'
import UnitTestCase from '@/__tests__/UnitTestCase'
import { arrayify } from '@/utils'
import { arrayify, eventBus } from '@/utils'
import { EditSongFormInitialTabKey, SongsKey } from '@/symbols'
import { ref } from 'vue'
import { fireEvent } from '@testing-library/vue'
@ -32,6 +32,7 @@ new class extends UnitTestCase {
protected test () {
it('edits a single song', async () => {
const updateMock = this.mock(songStore, 'update')
const emitMock = this.mock(eventBus, 'emit')
const alertMock = this.mock(MessageToasterStub.value, 'success')
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(emitMock).toHaveBeenCalledWith('SONGS_UPDATED')
})
it('edits multiple songs', async () => {
const updateMock = this.mock(songStore, 'update')
const emitMock = this.mock(eventBus, 'emit')
const alertMock = this.mock(MessageToasterStub.value, 'success')
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(emitMock).toHaveBeenCalledWith('SONGS_UPDATED')
})
it('displays artist name if all songs have the same artist', async () => {

View file

@ -172,7 +172,7 @@
<script lang="ts" setup>
import { computed, onMounted, reactive, ref } from 'vue'
import { isEqual } from 'lodash'
import { defaultCover, pluralize, requireInjection } from '@/utils'
import { defaultCover, eventBus, pluralize, requireInjection } from '@/utils'
import { songStore, SongUpdateData } from '@/stores'
import { DialogBoxKey, EditSongFormInitialTabKey, MessageToasterKey, SongsKey } from '@/symbols'
@ -287,6 +287,7 @@ const submit = async () => {
try {
await songStore.update(mutatedSongs.value, formData)
toaster.value.success(`Updated ${pluralize(mutatedSongs.value, 'song')}.`)
eventBus.emit('SONGS_UPDATED')
close()
} finally {
loading.value = false

View file

@ -2,11 +2,11 @@ import { expect, it } from 'vitest'
import factory from '@/__tests__/factory'
import UnitTestCase from '@/__tests__/UnitTestCase'
import { arrayify, eventBus } from '@/utils'
import { fireEvent } from '@testing-library/vue'
import { fireEvent, waitFor } from '@testing-library/vue'
import router from '@/router'
import { downloadService, playbackService } from '@/services'
import { favoriteStore, playlistStore, queueStore } from '@/stores'
import { MessageToasterStub } from '@/__tests__/stubs'
import { favoriteStore, playlistStore, queueStore, songStore } from '@/stores'
import { DialogBoxStub, MessageToasterStub } from '@/__tests__/stubs'
import SongContextMenu from './SongContextMenu.vue'
let songs: Song[]
@ -179,5 +179,28 @@ new class extends UnitTestCase {
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()
})
}
}

View file

@ -1,57 +1,48 @@
<template>
<ContextMenuBase ref="base" data-testid="song-context-menu" extra-class="song-menu">
<template v-if="onlyOneSongSelected">
<li class="playback" @click.stop.prevent="doPlayback">
<li @click.stop.prevent="doPlayback">
<span v-if="firstSongPlaying">Pause</span>
<span v-else>Play</span>
</li>
<li class="go-to-album" @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="viewAlbumDetails(songs[0].album_id)">Go to Album</li>
<li @click="viewArtistDetails(songs[0].artist_id)">Go to Artist</li>
</template>
<li class="has-sub">
Add To
<ul class="menu submenu menu-add-to">
<template v-if="queue.length">
<li v-if="currentSong" class="after-current" @click="queueSongsAfterCurrent">After Current Song</li>
<li class="bottom-queue" @click="queueSongsToBottom">Bottom of Queue</li>
<li class="top-queue" @click="queueSongsToTop">Top of Queue</li>
<li v-if="currentSong" @click="queueSongsAfterCurrent">After Current Song</li>
<li @click="queueSongsToBottom">Bottom of Queue</li>
<li @click="queueSongsToTop">Top of Queue</li>
</template>
<li v-else @click="queueSongsToBottom">Queue</li>
<li class="separator"></li>
<li class="favorite" @click="addSongsToFavorite">Favorites</li>
<li class="separator" v-if="normalPlaylists.length"></li>
<li
class="playlist"
v-for="p in normalPlaylists"
:key="p.id"
@click="addSongsToExistingPlaylist(p)"
>{{ p.name }}
</li>
<li class="separator"/>
<li @click="addSongsToFavorite">Favorites</li>
<li class="separator" v-if="normalPlaylists.length"/>
<li v-for="p in normalPlaylists" :key="p.id" @click="addSongsToExistingPlaylist(p)">{{ p.name }}</li>
</ul>
</li>
<li class="open-edit-form" v-if="isAdmin" @click="openEditForm">Edit</li>
<li class="download" v-if="allowDownload" @click="download">Download</li>
<li
class="copy-url"
v-if="onlyOneSongSelected"
@click="copyUrl"
>
Copy Shareable URL
</li>
<li v-if="isAdmin" @click="openEditForm">Edit</li>
<li v-if="allowDownload" @click="download">Download</li>
<li v-if="onlyOneSongSelected" @click="copyUrl">Copy Shareable URL</li>
<li class="separator"/>
<li v-if="isAdmin" @click="deleteFromFilesystem">Delete from Filesystem</li>
</ContextMenuBase>
</template>
<script lang="ts" setup>
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 { downloadService, playbackService } from '@/services'
import router from '@/router'
import { useAuthorization, useContextMenu, useSongMenuMethods } from '@/composables'
import { MessageToasterKey } from '@/symbols'
import { DialogBoxKey, MessageToasterKey } from '@/symbols'
const { context, base, ContextMenuBase, open, close, trigger } = useContextMenu()
const dialogBox = requireInjection(DialogBoxKey)
const toaster = requireInjection(MessageToasterKey)
const songs = ref<Song[]>([])
@ -104,6 +95,18 @@ const copyUrl = () => trigger(() => {
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[]) => {
songs.value = arrayify(_songs)
await open(e.pageY, e.pageX, { songs: songs.value })

View file

@ -36,16 +36,17 @@ const message = ref('')
const showCancelButton = computed(() => type.value === 'confirm')
const close = () => dialog.value.close()
const cancel = () => dialog.value.dispatchEvent(new Event('cancel'))
// @ts-ignore
const close = () => dialog.value?.close()
const cancel = () => dialog.value?.dispatchEvent(new Event('cancel'))
const waitForInput = () => new Promise(resolve => {
dialog.value.addEventListener('cancel', () => {
dialog.value?.addEventListener('cancel', () => {
close()
resolve(false)
}, { once: true })
dialog.value.querySelector('[name=ok]')!.addEventListener('click', () => {
dialog.value?.querySelector('[name=ok]')!.addEventListener('click', () => {
close()
resolve(true)
}, { once: true })
@ -56,6 +57,7 @@ const show = async (_type: DialogType, _message: string, _title: string = '') =>
message.value = _message
title.value = _title
// @ts-ignore
dialog.value.showModal()
return waitForInput()

View file

@ -1,4 +1,4 @@
import { orderBy, sampleSize, take } from 'lodash'
import { differenceBy, orderBy, sampleSize, take } from 'lodash'
import isMobile from 'ismobilejs'
import { computed, reactive, Ref, ref } from 'vue'
import { playbackService } from '@/services'
@ -18,7 +18,7 @@ import ControlsToggle from '@/components/ui/ScreenControlsToggle.vue'
import SongList from '@/components/song/SongList.vue'
import SongListControls from '@/components/song/SongListControls.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> = {}) => {
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)
}
eventBus.on('SONGS_DELETED', (deletedSongs: Song[]) => {
songs.value = differenceBy(songs.value, deletedSongs, 'id')
})
provideReadonly(SongListTypeKey, type)
provideReadonly(SongsKey, songs, false)
provideReadonly(SelectedSongsKey, selectedSongs, false)

View file

@ -34,6 +34,7 @@ export type EventName =
| 'SMART_PLAYLIST_UPDATED'
| 'SONG_STARTED'
| 'SONGS_UPDATED'
| 'SONGS_DELETED'
| 'SONG_QUEUED_FROM_ROUTE'
// socket events

View file

@ -80,4 +80,4 @@ class Http {
}
}
export const httpService = new Http()
export const http = new Http()

View file

@ -1,4 +1,4 @@
export * from './httpService'
export * from './http'
export * from './downloadService'
export * from './localStorageService'
export * from './playbackService'

View file

@ -1,7 +1,7 @@
import { expect, it } from 'vitest'
import factory from '@/__tests__/factory'
import UnitTestCase from '@/__tests__/UnitTestCase'
import { cache, httpService } from '@/services'
import { cache, http } from '@/services'
import { albumStore, artistStore } from '@/stores'
import { mediaInfoService } from './mediaInfoService'
@ -10,7 +10,7 @@ new class extends UnitTestCase {
it('fetches the artist info', async () => {
const artist = artistStore.syncWithVault(factory<Artist>('artist', { id: 42 }))[0]
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 setCacheMock = this.mock(cache, 'set')
@ -26,7 +26,7 @@ new class extends UnitTestCase {
const artistInfo = factory<ArtistInfo>('artist-info')
const hasCacheMock = this.mock(cache, 'has', true)
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(hasCacheMock).toHaveBeenCalledWith(['artist.info', 42])
@ -37,7 +37,7 @@ new class extends UnitTestCase {
it('fetches the album info', async () => {
const album = albumStore.syncWithVault(factory<Album>('album', { id: 42 }))[0]
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 setCacheMock = this.mock(cache, 'set')
@ -53,7 +53,7 @@ new class extends UnitTestCase {
const albumInfo = factory<AlbumInfo>('album-info')
const hasCacheMock = this.mock(cache, 'has', true)
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(hasCacheMock).toHaveBeenCalledWith(['album.info', 42])

View file

@ -1,4 +1,4 @@
import { cache, httpService } from '@/services'
import { cache, http } from '@/services'
import { albumStore, artistStore, songStore } from '@/stores'
export const mediaInfoService = {
@ -7,7 +7,7 @@ export const mediaInfoService = {
const cacheKey = ['artist.info', artist.id]
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?.image && (artist.image = info.image)
@ -20,7 +20,7 @@ export const mediaInfoService = {
const cacheKey = ['album.info', album.id]
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)
if (info?.cover) {

View file

@ -134,6 +134,17 @@ class PlaybackService {
* We'll let them come true
*/
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`
this.player.media.setAttribute('title', `${song.artist_name} - ${song.title}`)

View file

@ -1,7 +1,7 @@
import { without } from 'lodash'
import { reactive } from 'vue'
import { UploadFile } from '@/config'
import { httpService } from '@/services'
import { http } from '@/services'
import { albumStore, overviewStore, songStore } from '@/stores'
import { logger } from '@/utils'
@ -63,7 +63,7 @@ export const uploadService = {
file.status = 'Uploading'
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
})

View file

@ -1,4 +1,4 @@
import { cache, httpService } from '@/services'
import { cache, http } from '@/services'
import { eventBus } from '@/utils'
import router from '@/router'
@ -11,7 +11,7 @@ export const youTubeService = {
searchVideosBySong: async (song: Song, nextPageToken: string) => {
return await cache.remember<YouTubeSearchResult>(
['youtube.search', song.id, nextPageToken],
async () => await httpService.get<YouTubeSearchResult>(
async () => await http.get<YouTubeSearchResult>(
`youtube/search/song/${song.id}?pageToken=${nextPageToken}`
)
)

View file

@ -1,7 +1,7 @@
import { expect, it } from 'vitest'
import UnitTestCase from '@/__tests__/UnitTestCase'
import factory from '@/__tests__/factory'
import { httpService } from '@/services'
import { http } from '@/services'
import { albumStore, songStore } from '.'
new class extends UnitTestCase {
@ -57,7 +57,7 @@ new class extends UnitTestCase {
const album = factory<Album>('album')
albumStore.syncWithVault(album)
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)
await albumStore.uploadCover(album, 'data://cover')
@ -69,7 +69,7 @@ new class extends UnitTestCase {
})
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 url = await albumStore.fetchThumbnail(album.id)
@ -80,7 +80,7 @@ new class extends UnitTestCase {
it('resolves an album', async () => {
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(getMock).toHaveBeenCalledWith(`albums/${album.id}`)
@ -93,7 +93,7 @@ new class extends UnitTestCase {
it('paginates', async () => {
const albums = factory<Album>('album', 3)
this.mock(httpService, 'get').mockResolvedValueOnce({
this.mock(http, 'get').mockResolvedValueOnce({
data: albums,
links: {
next: '/albums?page=2'

View file

@ -1,6 +1,6 @@
import { reactive, UnwrapNestedRefs } from 'vue'
import { differenceBy, merge, orderBy, take, unionBy } from 'lodash'
import { cache, httpService } from '@/services'
import { cache, http } from '@/services'
import { arrayify, logger } from '@/utils'
import { songStore } from '@/stores'
@ -19,7 +19,10 @@ export const albumStore = {
removeByIds (ids: number[]) {
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) => {
@ -44,7 +47,7 @@ export const albumStore = {
* @param {string} cover The content data string of the cover
*/
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)
// sync to vault
@ -57,7 +60,7 @@ export const albumStore = {
* Fetch the (blurry) thumbnail-sized version of an album's cover.
*/
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) {
@ -66,7 +69,7 @@ export const albumStore = {
if (!album) {
try {
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]
} catch (e) {
logger.error(e)
@ -77,7 +80,7 @@ export const albumStore = {
},
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')
return resource.links.next ? ++resource.meta.current_page : null

View file

@ -1,7 +1,7 @@
import { expect, it } from 'vitest'
import UnitTestCase from '@/__tests__/UnitTestCase'
import factory from '@/__tests__/factory'
import { httpService } from '@/services'
import { http } from '@/services'
import { artistStore } from '.'
new class extends UnitTestCase {
@ -70,7 +70,7 @@ new class extends UnitTestCase {
it('uploads an image for an artist', async () => {
const artist = factory<Artist>('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')
@ -81,7 +81,7 @@ new class extends UnitTestCase {
it('resolves an artist', async () => {
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(getMock).toHaveBeenCalledWith(`artists/${artist.id}`)
@ -94,7 +94,7 @@ new class extends UnitTestCase {
it('paginates', async () => {
const artists = factory<Artist>('artist', 3)
this.mock(httpService, 'get').mockResolvedValueOnce({
this.mock(http, 'get').mockResolvedValueOnce({
data: artists,
links: {
next: '/artists?page=2'

View file

@ -1,6 +1,6 @@
import { reactive, UnwrapNestedRefs } from 'vue'
import { differenceBy, orderBy, take, unionBy } from 'lodash'
import { cache, httpService } from '@/services'
import { cache, http } from '@/services'
import { arrayify, logger } from '@/utils'
const UNKNOWN_ARTIST_ID = 1
@ -35,7 +35,7 @@ export const artistStore = {
},
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
this.byId(artist.id)!.image = artist.image
@ -59,7 +59,7 @@ export const artistStore = {
if (!artist) {
try {
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]
} catch (e) {
logger.error(e)
@ -70,7 +70,7 @@ export const artistStore = {
},
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')
return resource.links.next ? ++resource.meta.current_page : null

View file

@ -1,6 +1,6 @@
import isMobile from 'ismobilejs'
import { reactive } from 'vue'
import { httpService } from '@/services'
import { http } from '@/services'
import { playlistFolderStore, playlistStore, preferenceStore, settingStore, themeStore, userStore } from '.'
interface CommonStoreState {
@ -41,7 +41,7 @@ export const commonStore = {
}),
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.
this.state.use_you_tube = this.state.use_you_tube && !isMobile.phone

View file

@ -1,7 +1,7 @@
import { expect, it } from 'vitest'
import factory from '@/__tests__/factory'
import UnitTestCase from '@/__tests__/UnitTestCase'
import { httpService } from '@/services'
import { http } from '@/services'
import { favoriteStore } from '.'
new class extends UnitTestCase {
@ -13,7 +13,7 @@ new class extends UnitTestCase {
it('toggles one song', async () => {
const addMock = this.mock(favoriteStore, 'add')
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 })
await favoriteStore.toggleOne(song)
@ -48,7 +48,7 @@ new class extends UnitTestCase {
it('likes several songs', async () => {
const songs = factory<Song>('song', 3)
const addMock = this.mock(favoriteStore, 'add')
const postMock = this.mock(httpService, 'post')
const postMock = this.mock(http, 'post')
await favoriteStore.like(songs)
@ -59,7 +59,7 @@ new class extends UnitTestCase {
it('unlikes several songs', async () => {
const songs = factory<Song>('song', 3)
const removeMock = this.mock(favoriteStore, 'remove')
const postMock = this.mock(httpService, 'post')
const postMock = this.mock(http, 'post')
await favoriteStore.unlike(songs)
@ -69,7 +69,7 @@ new class extends UnitTestCase {
it('fetches favorites', async () => {
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()

View file

@ -1,6 +1,6 @@
import { reactive } from 'vue'
import { differenceBy, unionBy } from 'lodash'
import { httpService } from '@/services'
import { http } from '@/services'
import { arrayify } from '@/utils'
import { songStore } from '@/stores'
@ -15,7 +15,7 @@ export const favoriteStore = {
song.liked = !song.liked
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[]) {
@ -32,17 +32,17 @@ export const favoriteStore = {
songs.forEach(song => (song.liked = true))
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[]) {
songs.forEach(song => (song.liked = false))
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 () {
this.state.songs = songStore.syncWithVault(await httpService.get<Song[]>('songs/favorite'))
this.state.songs = songStore.syncWithVault(await http.get<Song[]>('songs/favorite'))
}
}

View file

@ -1,7 +1,7 @@
import { expect, it } from 'vitest'
import factory from '@/__tests__/factory'
import UnitTestCase from '@/__tests__/UnitTestCase'
import { httpService } from '@/services'
import { http } from '@/services'
import { albumStore, artistStore, overviewStore, recentlyPlayedStore, songStore } from '.'
new class extends UnitTestCase {
@ -32,7 +32,7 @@ new class extends UnitTestCase {
const recentlyAddedAlbums = factory<Album>('album', 6)
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_albums: mostPlayedAlbums,
most_played_artists: mostPlayedArtists,

View file

@ -1,5 +1,5 @@
import { reactive } from 'vue'
import { httpService } from '@/services'
import { http } from '@/services'
import { songStore } from '@/stores/songStore'
import { albumStore } from '@/stores/albumStore'
import { artistStore } from '@/stores/artistStore'
@ -16,7 +16,7 @@ export const overviewStore = {
}),
async init () {
const resource = await httpService.get<{
const resource = await http.get<{
most_played_songs: Song[],
most_played_albums: Album[],
most_played_artists: Artist[],
@ -40,7 +40,6 @@ export const overviewStore = {
this.state.mostPlayedArtists = artistStore.getMostPlayed(6)
this.state.recentlyAddedSongs = songStore.getRecentlyAdded(9)
this.state.recentlyAddedAlbums = albumStore.getRecentlyAdded(6)
this.state.recentlyPlayed = recentlyPlayedStore.excerptState.songs
this.state.recentlyPlayed = recentlyPlayedStore.excerptState.songs.filter(song => !song.deleted)
}
}

View file

@ -1,5 +1,5 @@
import { reactive, UnwrapNestedRefs } from 'vue'
import { httpService } from '@/services'
import { http } from '@/services'
import { differenceBy, orderBy } from 'lodash'
import { playlistStore } from '@/stores/playlistStore'
@ -13,7 +13,7 @@ export const playlistFolderStore = {
},
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 = orderBy(this.state.folders, 'name')
@ -22,27 +22,27 @@ export const playlistFolderStore = {
},
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')
playlistStore.byFolder(folder).forEach(playlist => (playlist.folder_id = null))
},
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) {
// Update the folder ID right away, so that the UI can be refreshed immediately.
// The actual HTTP request will be done in the background.
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) {
// Update the folder ID right away, so that the UI can be updated immediately.
// The actual update will be done in the background.
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')

View file

@ -1,7 +1,7 @@
import UnitTestCase from '@/__tests__/UnitTestCase'
import factory from '@/__tests__/factory'
import { expect, it } from 'vitest'
import { cache, httpService } from '@/services'
import { cache, http } from '@/services'
import { playlistStore } from '.'
const ruleGroups: SmartPlaylistRuleGroup[] = [
@ -82,7 +82,7 @@ new class extends UnitTestCase {
it('stores a playlist', async () => {
const songs = factory<Song>('song', 3)
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)
await playlistStore.store('New Playlist', songs, [])
@ -100,7 +100,7 @@ new class extends UnitTestCase {
it('deletes a playlist', async () => {
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]
await playlistStore.delete(playlist)
@ -113,7 +113,7 @@ new class extends UnitTestCase {
it('adds songs to a playlist', async () => {
const playlist = factory<Playlist>('playlist', { id: 12 })
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')
await playlistStore.addSongs(playlist, songs)
@ -125,7 +125,7 @@ new class extends UnitTestCase {
it('removes songs from a playlist', async () => {
const playlist = factory<Playlist>('playlist', { id: 12 })
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')
await playlistStore.removeSongs(playlist, songs)
@ -136,7 +136,7 @@ new class extends UnitTestCase {
it('does not modify a smart playlist content', async () => {
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))
expect(postMock).not.toHaveBeenCalled()
@ -147,7 +147,7 @@ new class extends UnitTestCase {
it('updates a standard playlist', async () => {
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' })
@ -159,7 +159,7 @@ new class extends UnitTestCase {
const playlist = factory.states('smart')<Playlist>('playlist', { id: 12 })
const rules = factory<SmartPlaylistRuleGroup>('smart-playlist-rule-group', 2)
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')
await playlistStore.update(playlist, { name: 'Foo', rules })

View file

@ -1,7 +1,7 @@
import { differenceBy, orderBy } from 'lodash'
import { reactive, UnwrapNestedRefs } from 'vue'
import { logger } from '@/utils'
import { cache, httpService } from '@/services'
import { cache, http } from '@/services'
import models from '@/config/smart-playlist/models'
import operators from '@/config/smart-playlist/operators'
@ -52,7 +52,7 @@ export const playlistStore = {
},
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,
songs: songs.map(song => song.id),
rules: this.serializeSmartPlaylistRulesForStorage(rules)
@ -67,7 +67,7 @@ export const playlistStore = {
},
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')
},
@ -76,7 +76,7 @@ export const playlistStore = {
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])
return playlist
@ -87,14 +87,14 @@ export const playlistStore = {
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])
return playlist
},
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,
rules: this.serializeSmartPlaylistRulesForStorage(data.rules || [])
})

View file

@ -2,7 +2,7 @@ import { reactive } from 'vue'
import UnitTestCase from '@/__tests__/UnitTestCase'
import { expect, it } from 'vitest'
import factory from 'factoria'
import { httpService } from '@/services'
import { http } from '@/services'
import { queueStore, songStore } from '.'
let songs
@ -89,7 +89,7 @@ new class extends UnitTestCase {
it('fetches random songs to queue', async () => {
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)
await queueStore.fetchRandom(3)
@ -101,7 +101,7 @@ new class extends UnitTestCase {
it('fetches random songs to queue with a custom order', async () => {
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)
await queueStore.fetchInOrder('title', 'desc', 3)

View file

@ -1,7 +1,7 @@
import { reactive } from 'vue'
import { differenceBy, shuffle, unionBy } from 'lodash'
import { arrayify } from '@/utils'
import { httpService } from '@/services'
import { http } from '@/services'
import { songStore } from '@/stores'
export const queueStore = {
@ -144,12 +144,12 @@ export const queueStore = {
},
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)
},
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)
}
}

View file

@ -1,14 +1,14 @@
import { expect, it } from 'vitest'
import factory from '@/__tests__/factory'
import UnitTestCase from '@/__tests__/UnitTestCase'
import { httpService } from '@/services'
import { http } from '@/services'
import { recentlyPlayedStore, songStore } from '.'
new class extends UnitTestCase {
protected test () {
it('fetches the recently played songs', async () => {
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)
await recentlyPlayedStore.fetch()

View file

@ -1,5 +1,5 @@
import { reactive } from 'vue'
import { httpService } from '@/services'
import { http } from '@/services'
import { remove } from 'lodash'
import { songStore } from '@/stores'
@ -15,7 +15,7 @@ export const recentlyPlayedStore = {
}),
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) {

View file

@ -2,7 +2,7 @@ import { expect, it } from 'vitest'
import UnitTestCase from '@/__tests__/UnitTestCase'
import { reactive } from 'vue'
import factory from '@/__tests__/factory'
import { httpService } from '@/services'
import { http } from '@/services'
import { albumStore, artistStore, ExcerptSearchResult, searchStore, songStore } from '.'
new class extends UnitTestCase {
@ -27,7 +27,7 @@ new class extends UnitTestCase {
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 syncAlbumsMock = this.mock(albumStore, 'syncWithVault', result.albums)
const syncArtistsMock = this.mock(artistStore, 'syncWithVault', result.artists)
@ -47,7 +47,7 @@ new class extends UnitTestCase {
it('performs a song search', async () => {
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)
await searchStore.songSearch('test')

View file

@ -1,5 +1,5 @@
import { reactive } from 'vue'
import { httpService } from '@/services'
import { http } from '@/services'
import { albumStore, artistStore, songStore } from '@/stores'
type ExcerptState = {
@ -21,7 +21,7 @@ export const searchStore = {
}),
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.albums = albumStore.syncWithVault(result.albums)
@ -29,7 +29,7 @@ export const searchStore = {
},
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 () {

View file

@ -1,6 +1,6 @@
import { expect, it } from 'vitest'
import UnitTestCase from '@/__tests__/UnitTestCase'
import { httpService } from '@/services'
import { http } from '@/services'
import { settingStore } from '.'
new class extends UnitTestCase {
@ -11,7 +11,7 @@ new class extends UnitTestCase {
})
it('updates the media path', async () => {
this.mock(httpService, 'put')
this.mock(http, 'put')
await settingStore.update({ media_path: '/dev/null' })
expect(settingStore.state.media_path).toEqual('/dev/null')
})

View file

@ -1,5 +1,5 @@
import { reactive } from 'vue'
import { httpService } from '@/services'
import { http } from '@/services'
import { merge } from 'lodash'
export const settingStore = {
@ -12,7 +12,7 @@ export const settingStore = {
},
async update (settings: Settings) {
await httpService.put('settings', settings)
await http.put('settings', settings)
merge(this.state, settings)
}
}

View file

@ -3,8 +3,7 @@ import isMobile from 'ismobilejs'
import UnitTestCase from '@/__tests__/UnitTestCase'
import { expect, it } from 'vitest'
import factory from '@/__tests__/factory'
import { authService, httpService } from '@/services'
import { eventBus } from '@/utils'
import { authService, http } from '@/services'
import { albumStore, artistStore, commonStore, overviewStore, preferenceStore, songStore, SongUpdateResult } from '.'
new class extends UnitTestCase {
@ -53,7 +52,7 @@ new class extends UnitTestCase {
it('resolves a song', async () => {
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(getMock).toHaveBeenCalledWith(`songs/${song.id}`)
@ -74,7 +73,7 @@ new class extends UnitTestCase {
it('registers a play', async () => {
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,
play_count: 50
}))
@ -87,7 +86,7 @@ new class extends UnitTestCase {
it('scrobbles', async () => {
const song = factory<Song>('song')
song.play_start_time = 123456789
const postMock = this.mock(httpService, 'post')
const postMock = this.mock(http, 'post')
await songStore.scrobble(song)
@ -123,9 +122,7 @@ new class extends UnitTestCase {
const syncArtistsMock = this.mock(artistStore, 'syncWithVault')
const removeAlbumsMock = this.mock(albumStore, 'removeByIds')
const removeArtistsMock = this.mock(artistStore, 'removeByIds')
const emitMock = this.mock(eventBus, 'emit')
const refreshMock = this.mock(overviewStore, 'refresh')
const putMock = this.mock(httpService, 'put').mockResolvedValueOnce(result)
const putMock = this.mock(http, 'put').mockResolvedValueOnce(result)
await songStore.update(songs, {
album_name: 'Updated Album',
@ -145,8 +142,6 @@ new class extends UnitTestCase {
expect(syncArtistsMock).toHaveBeenCalledWith(result.artists)
expect(removeAlbumsMock).toHaveBeenCalledWith([10])
expect(removeArtistsMock).toHaveBeenCalledWith([42])
expect(emitMock).toHaveBeenCalledWith('SONGS_UPDATED')
expect(refreshMock).toHaveBeenCalled()
})
it('gets source URL', () => {
@ -171,7 +166,7 @@ new class extends UnitTestCase {
playback_state: null
})
const trackPlayCountMock = this.mock(songStore, 'trackPlayCount')
const trackPlayCountMock = this.mock(songStore, 'setUpPlayCountTracking')
expect(songStore.syncWithVault(song)).toEqual([reactive(song)])
expect(songStore.vault.has(song.id)).toBe(true)
@ -183,7 +178,7 @@ new class extends UnitTestCase {
expect(trackPlayCountMock).toHaveBeenCalledOnce()
})
it('tracks play count', async () => {
it('sets up play count tracking', async () => {
const refreshMock = this.mock(overviewStore, 'refresh')
const artist = reactive(factory<Artist>('artist', { id: 42, play_count: 100 }))
const album = reactive(factory<Album>('album', { id: 10, play_count: 120 }))
@ -200,7 +195,7 @@ new class extends UnitTestCase {
play_count: 98
}))
songStore.trackPlayCount(song)
songStore.setUpPlayCountTracking(song)
song.play_count = 100
await this.tick()
@ -214,8 +209,8 @@ new class extends UnitTestCase {
it('fetches for album', async () => {
const songs = factory<Song>('song', 3)
const album = factory<Album>('album', { id: 42 })
const getMock = this.mock(httpService, 'get').mockResolvedValueOnce(songs)
const syncMock = this.mock(songStore, 'syncWithVault')
const getMock = this.mock(http, 'get').mockResolvedValueOnce(songs)
const syncMock = this.mock(songStore, 'syncWithVault', songs)
await songStore.fetchForAlbum(album)
@ -226,8 +221,8 @@ new class extends UnitTestCase {
it('fetches for artist', async () => {
const songs = factory<Song>('song', 3)
const artist = factory<Artist>('artist', { id: 42 })
const getMock = this.mock(httpService, 'get').mockResolvedValueOnce(songs)
const syncMock = this.mock(songStore, 'syncWithVault')
const getMock = this.mock(http, 'get').mockResolvedValueOnce(songs)
const syncMock = this.mock(songStore, 'syncWithVault', songs)
await songStore.fetchForArtist(artist)
@ -238,8 +233,8 @@ new class extends UnitTestCase {
it('fetches for playlist', async () => {
const songs = factory<Song>('song', 3)
const playlist = factory<Playlist>('playlist', { id: 42 })
const getMock = this.mock(httpService, 'get').mockResolvedValueOnce(songs)
const syncMock = this.mock(songStore, 'syncWithVault')
const getMock = this.mock(http, 'get').mockResolvedValueOnce(songs)
const syncMock = this.mock(songStore, 'syncWithVault', songs)
await songStore.fetchForPlaylist(playlist)
@ -250,7 +245,7 @@ new class extends UnitTestCase {
it('paginates', async () => {
const songs = factory<Song>('song', 3)
const getMock = this.mock(httpService, 'get').mockResolvedValueOnce({
const getMock = this.mock(http, 'get').mockResolvedValueOnce({
data: songs,
links: {
next: 'http://localhost/api/v1/songs?page=3'

View file

@ -2,8 +2,8 @@ import isMobile from 'ismobilejs'
import slugify from 'slugify'
import { merge, orderBy, sumBy, take, unionBy, uniqBy } from 'lodash'
import { reactive, UnwrapNestedRefs, watch } from 'vue'
import { arrayify, eventBus, logger, secondsToHis, use } from '@/utils'
import { authService, cache, httpService } from '@/services'
import { arrayify, logger, secondsToHis, use } from '@/utils'
import { authService, cache, http } from '@/services'
import { albumStore, artistStore, commonStore, overviewStore, playlistStore, preferenceStore } from '@/stores'
export type SongUpdateData = {
@ -36,7 +36,8 @@ export const songStore = {
getFormattedLength: (songs: Song | Song[]) => secondsToHis(sumBy(arrayify(songs), 'length')),
byId (id: string) {
return this.vault.get(id)
const song = this.vault.get(id)
return song?.deleted ? undefined : song
},
byIds (ids: string[]) {
@ -54,7 +55,7 @@ export const songStore = {
if (!song) {
try {
song = this.syncWithVault(await httpService.get<Song>(`songs/${id}`))[0]
song = this.syncWithVault(await http.get<Song>(`songs/${id}`))[0]
} catch (e) {
logger.error(e)
}
@ -83,18 +84,18 @@ export const songStore = {
* Increase a play count for a 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.
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
}),
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,
songs: songsToUpdate.map(song => song.id)
})
@ -106,10 +107,6 @@ export const songStore = {
albumStore.removeByIds(removed.albums.map(album => album.id))
artistStore.removeByIds(removed.artists.map(artist => artist.id))
eventBus.emit('SONGS_UPDATED')
overviewStore.refresh()
},
getSourceUrl: (song: Song) => {
@ -129,7 +126,7 @@ export const songStore = {
} else {
local = reactive(song)
local.playback_state = 'Stopped'
this.trackPlayCount(local)
this.setUpPlayCountTracking(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) => {
const album = albumStore.byId(song.album_id)
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) {
const id = typeof album === 'number' ? album : album.id
return await cache.remember<Song[]>(
[`album.songs`, id],
async () => this.syncWithVault(await httpService.get<Song[]>(`albums/${id}/songs`))
)
return await this.cacheable(['album.songs', id], http.get<Song[]>(`albums/${id}/songs`))
},
async fetchForArtist (artist: Artist | number) {
const id = typeof artist === 'number' ? artist : artist.id
return await cache.remember<Song[]>(
['artist.songs', id],
async () => this.syncWithVault(await httpService.get<Song[]>(`artists/${id}/songs`))
)
return await this.cacheable(['artist.songs', id], http.get<Song[]>(`artists/${id}/songs`))
},
async fetchForPlaylist (playlist: Playlist) {
return await cache.remember<Song[]>(
[`playlist.songs`, playlist.id],
async () => this.syncWithVault(await httpService.get<Song[]>(`playlists/${playlist.id}/songs`))
)
return await this.cacheable(['playlist.songs', playlist.id], http.get<Song[]>(`playlists/${playlist.id}/songs`))
},
async fetchForPlaylistFolder (folder: PlaylistFolder) {
@ -190,7 +181,7 @@ export const songStore = {
},
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}`
)
@ -200,10 +191,21 @@ export const songStore = {
},
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) {
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 })
}
}

View file

@ -1,7 +1,7 @@
import { expect, it } from 'vitest'
import UnitTestCase from '@/__tests__/UnitTestCase'
import factory from '@/__tests__/factory'
import { httpService } from '@/services'
import { http } from '@/services'
import { CreateUserData, UpdateCurrentProfileData, UpdateUserData, userStore } from '.'
const currentUser = factory<User>('user', {
@ -35,7 +35,7 @@ new class extends UnitTestCase {
it('fetches users', async () => {
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()
@ -51,21 +51,21 @@ new class extends UnitTestCase {
})
it('logs in', async () => {
const postMock = this.mock(httpService, 'post')
const postMock = this.mock(http, 'post')
await userStore.login('john@doe.com', 'curry-wurst')
expect(postMock).toHaveBeenCalledWith('me', { email: 'john@doe.com', password: 'curry-wurst' })
})
it('logs out', async () => {
const deleteMock = this.mock(httpService, 'delete')
const deleteMock = this.mock(http, 'delete')
await userStore.logout()
expect(deleteMock).toHaveBeenCalledWith('me')
})
it('gets profile', async () => {
const getMock = this.mock(httpService, 'get')
const getMock = this.mock(http, 'get')
await userStore.getProfile()
expect(getMock).toHaveBeenCalledWith('me')
@ -78,7 +78,7 @@ new class extends UnitTestCase {
email: 'jane@doe.com'
})
const putMock = this.mock(httpService, 'put').mockResolvedValue(updated)
const putMock = this.mock(http, 'put').mockResolvedValue(updated)
const data: UpdateCurrentProfileData = {
current_password: 'curry-wurst',
@ -102,7 +102,7 @@ new class extends UnitTestCase {
}
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(postMock).toHaveBeenCalledWith('users', data)
@ -122,7 +122,7 @@ new class extends UnitTestCase {
}
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)
@ -131,7 +131,7 @@ new class extends UnitTestCase {
})
it('deletes a user', async () => {
const deleteMock = this.mock(httpService, 'delete')
const deleteMock = this.mock(http, 'delete')
const user = factory<User>('user', { id: 2 })
userStore.state.users.push(...userStore.syncWithVault(user))

View file

@ -1,5 +1,5 @@
import { differenceBy, merge } from 'lodash'
import { httpService } from '@/services'
import { http } from '@/services'
import { reactive } from 'vue'
import { arrayify } from '@/utils'
import { UnwrapNestedRefs } from '@vue/reactivity'
@ -50,7 +50,7 @@ export const userStore = {
},
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) {
@ -61,26 +61,26 @@ export const userStore = {
return this.state.current
},
login: async (email: string, password: string) => await httpService.post<User>('me', { email, password }),
logout: async () => await httpService.delete('me'),
getProfile: async () => await httpService.get<User>('me'),
login: async (email: string, password: string) => await http.post<User>('me', { email, password }),
logout: async () => await http.delete('me'),
getProfile: async () => await http.get<User>('me'),
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) {
const user = await httpService.post<User>('users', data)
const user = await http.post<User>('users', data)
this.state.users.push(...this.syncWithVault(user))
return this.byId(user.id)
},
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) {
await httpService.delete(`users/${user.id}`)
await http.delete(`users/${user.id}`)
this.state.users = differenceBy(this.state.users, [user], 'id')
this.vault.delete(user.id)

View file

@ -161,6 +161,7 @@ interface Song {
play_start_time?: number
fmt_length?: string
created_at: string
deleted?: boolean
}
interface SmartPlaylistRuleGroup {

View file

@ -6,6 +6,7 @@ use App\Http\Controllers\V6\API\AlbumSongController;
use App\Http\Controllers\V6\API\ArtistController;
use App\Http\Controllers\V6\API\ArtistSongController;
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\FavoriteSongController;
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::get('songs/recently-played', [RecentlyPlayedSongController::class, 'index']);
Route::get('songs/favorite', [FavoriteSongController::class, 'index']);
Route::delete('songs', DeleteSongsController::class);
Route::apiResource('users', UserController::class);

View file

@ -3,6 +3,8 @@
namespace Tests\Feature\V6;
use App\Models\Song;
use App\Models\User;
use Illuminate\Support\Collection;
class SongTest extends TestCase
{
@ -60,4 +62,29 @@ class SongTest extends TestCase
$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));
}
}

View file

@ -47,7 +47,6 @@ class FileSynchronizerTest extends TestCase
self::assertEqualsWithDelta(10, $info->length, 0.1);
}
/** @test */
public function testGetFileInfoVorbisCommentsFlac(): void
{
$flacPath = __DIR__ . '/../../songs/full-vorbis-comments.flac';
@ -77,7 +76,6 @@ class FileSynchronizerTest extends TestCase
self::assertEqualsWithDelta(10, $info->length, 0.1);
}
/** @test */
public function testSongWithoutTitleHasFileNameAsTitle(): void
{
$this->fileSynchronizer->setFile(__DIR__ . '/../../songs/blank.mp3');