feat: allows users to upload for Plus

This commit is contained in:
Phan An 2024-01-04 12:35:36 +01:00
parent f4a0e8d006
commit 53d08371b9
48 changed files with 216 additions and 346 deletions

View file

@ -6,7 +6,6 @@ use App\Console\Commands\Traits\AskForPassword;
use App\Exceptions\InstallationFailedException;
use App\Models\Setting;
use App\Models\User;
use App\Services\MediaCacheService;
use Illuminate\Console\Command;
use Illuminate\Contracts\Console\Kernel as Artisan;
use Illuminate\Contracts\Hashing\Hasher as Hash;
@ -32,7 +31,6 @@ class InitCommand extends Command
private bool $adminSeeded = false;
public function __construct(
private MediaCacheService $mediaCacheService,
private Artisan $artisan,
private Hash $hash,
private DotenvEditor $dotenvEditor,
@ -277,9 +275,6 @@ class InitCommand extends Command
$this->components->task('Migrating database', function (): void {
$this->artisan->call('migrate', ['--force' => true]);
});
// Clear the media cache, just in case we did any media-related migration
$this->mediaCacheService->clear();
}
private function maybeSetMediaPath(): void

View file

@ -0,0 +1,19 @@
<?php
namespace App\Exceptions;
use Exception;
use Throwable;
class OwnerNotSetPriorToScanException extends Exception
{
private function __construct($message = '', $code = 0, ?Throwable $previous = null)
{
parent::__construct($message, $code, $previous);
}
public static function create(): self
{
return new static('An owner must be set prior to scanning, as a song must be owned by a user.');
}
}

View file

@ -2,6 +2,7 @@
namespace App\Http\Controllers\API;
use App\Facades\License;
use App\Http\Controllers\Controller;
use App\Http\Resources\PlaylistFolderResource;
use App\Http\Resources\PlaylistResource;
@ -34,11 +35,13 @@ class FetchInitialDataController extends Controller
'playlists' => PlaylistResource::collection($user->playlists),
'playlist_folders' => PlaylistFolderResource::collection($user->playlist_folders),
'current_user' => UserResource::make($user, true),
'use_last_fm' => LastfmService::used(),
'use_spotify' => SpotifyService::enabled(),
'use_you_tube' => YouTubeService::enabled(),
'use_i_tunes' => $iTunesService->used(),
'allow_download' => config('koel.download.allow'),
'uses_last_fm' => LastfmService::used(),
'uses_spotify' => SpotifyService::enabled(),
'uses_you_tube' => YouTubeService::enabled(),
'uses_i_tunes' => $iTunesService->used(),
'allows_download' => config('koel.download.allow'),
'media_path_set' => (bool) $settingRepository->getByKey('media_path'),
'allows_upload' => License::isPlus() || $user->is_admin,
'supports_transcoding' => config('koel.streaming.ffmpeg_path')
&& is_executable(config('koel.streaming.ffmpeg_path')),
'cdn_url' => static_url(),

View file

@ -29,7 +29,7 @@ class UploadController extends Controller
$this->authorize('upload', User::class);
try {
$song = $songRepository->getOne($uploadService->handleUploadedFile($request->file)->id);
$song = $songRepository->getOne($uploadService->handleUploadedFile($request->file, $user)->id, $user);
event(new LibraryChanged());

View file

@ -1,20 +0,0 @@
<?php
namespace App\Listeners;
use App\Services\MediaCacheService;
class ClearMediaCache
{
private MediaCacheService $mediaCacheService;
public function __construct(MediaCacheService $mediaCacheService)
{
$this->mediaCacheService = $mediaCacheService;
}
public function handle(): void
{
$this->mediaCacheService->clear();
}
}

View file

@ -8,7 +8,6 @@ use App\Events\PlaybackStarted;
use App\Events\SongLikeToggled;
use App\Events\SongsBatchLiked;
use App\Events\SongsBatchUnliked;
use App\Listeners\ClearMediaCache;
use App\Listeners\DeleteNonExistingRecordsPostSync;
use App\Listeners\LoveMultipleTracksOnLastfm;
use App\Listeners\LoveTrackOnLastfm;
@ -41,7 +40,6 @@ class EventServiceProvider extends BaseServiceProvider
LibraryChanged::class => [
PruneLibrary::class,
ClearMediaCache::class,
],
MediaSyncCompleted::class => [

View file

@ -1,14 +0,0 @@
<?php
namespace App\Providers;
use App\Services\MediaCacheService;
use Illuminate\Support\ServiceProvider;
class MediaCacheServiceProvider extends ServiceProvider
{
public function register(): void
{
app()->singleton('MediaCache', static fn (): MediaCacheService => app(MediaCacheService::class));
}
}

View file

@ -2,9 +2,11 @@
namespace App\Services;
use App\Exceptions\OwnerNotSetPriorToScanException;
use App\Models\Album;
use App\Models\Artist;
use App\Models\Song;
use App\Models\User;
use App\Repositories\SongRepository;
use App\Values\SongScanInformation;
use App\Values\SyncResult;
@ -24,6 +26,8 @@ class FileSynchronizer
*/
private ?Song $song;
private ?User $owner = null;
private ?string $syncError = null;
public function __construct(
@ -36,7 +40,7 @@ class FileSynchronizer
) {
}
public function setFile(string|SplFileInfo $path): self
public function setFile(string|SplFileInfo $path): static
{
$file = $path instanceof SplFileInfo ? $path : new SplFileInfo($path);
@ -47,6 +51,13 @@ class FileSynchronizer
return $this;
}
public function setOwner(User $owner): static
{
$this->owner = $owner;
return $this;
}
public function getFileScanInformation(): ?SongScanInformation
{
$raw = $this->getID3->analyze($this->filePath);
@ -72,6 +83,10 @@ class FileSynchronizer
*/
public function sync(array $ignores = [], bool $force = false): SyncResult
{
if (!$this->owner) {
throw OwnerNotSetPriorToScanException::create();
}
if (!$this->isFileNewOrChanged() && !$force) {
return SyncResult::skipped($this->filePath);
}
@ -97,6 +112,7 @@ class FileSynchronizer
$data = Arr::except($info, ['album', 'artist', 'albumartist', 'cover']);
$data['album_id'] = $album->id;
$data['artist_id'] = $artist->id;
$data['owner_id'] = $this->owner->id;
$this->song = Song::query()->updateOrCreate(['path' => $this->filePath], $data); // @phpstan-ignore-line

View file

@ -1,51 +0,0 @@
<?php
namespace App\Services;
use App\Models\Album;
use App\Models\Artist;
use App\Models\Song;
use Illuminate\Cache\Repository as Cache;
class MediaCacheService
{
private const CACHE_KEY = 'media_cache';
public function __construct(private Cache $cache)
{
}
/**
* Get media data.
* If caching is enabled, the data will be retrieved from the cache.
*
* @return array<mixed>
*/
public function get(): array
{
if (!config('koel.cache_media')) {
return $this->query();
}
return $this->cache->rememberForever(self::CACHE_KEY, fn (): array => $this->query());
}
/**
* Query fresh data from the database.
*
* @return array<mixed>
*/
private function query(): array
{
return [
'albums' => Album::query()->orderBy('name')->get(),
'artists' => Artist::query()->orderBy('name')->get(),
'songs' => Song::all(),
];
}
public function clear(): void
{
$this->cache->forget(self::CACHE_KEY);
}
}

View file

@ -6,25 +6,35 @@ use App\Exceptions\MediaPathNotSetException;
use App\Exceptions\SongUploadFailedException;
use App\Models\Setting;
use App\Models\Song;
use App\Models\User;
use Illuminate\Http\UploadedFile;
use Throwable;
use function Functional\memoize;
class UploadService
{
private const UPLOAD_DIRECTORY = '__KOEL_UPLOADS__';
public function __construct(private FileSynchronizer $fileSynchronizer)
{
}
public function handleUploadedFile(UploadedFile $file): Song
public function handleUploadedFile(UploadedFile $file, User $uploader): Song
{
$targetFileName = $this->getTargetFileName($file);
$file->move($this->getUploadDirectory(), $targetFileName);
$uploadDirectory = $this->getUploadDirectory($uploader);
$targetFileName = $this->getTargetFileName($file, $uploader);
$targetPathName = $this->getUploadDirectory() . $targetFileName;
$result = $this->fileSynchronizer->setFile($targetPathName)->sync();
$file->move($uploadDirectory, $targetFileName);
$targetPathName = $uploadDirectory . $targetFileName;
try {
$result = $this->fileSynchronizer
->setOwner($uploader)
->setFile($targetPathName)
->sync();
} catch (Throwable) {
@unlink($targetPathName);
throw new SongUploadFailedException('Unknown error');
}
if ($result->isError()) {
@unlink($targetPathName);
@ -34,25 +44,35 @@ class UploadService
return $this->fileSynchronizer->getSong();
}
private function getUploadDirectory(): string
private function getUploadDirectory(User $uploader): string
{
return memoize(static function (): string {
return memoize(static function () use ($uploader): string {
$mediaPath = Setting::get('media_path');
if (!$mediaPath) {
throw new MediaPathNotSetException();
throw_unless((bool) $mediaPath, MediaPathNotSetException::class);
$dir = sprintf(
'%s%s__KOEL_UPLOADS_$%s__%s',
$mediaPath,
DIRECTORY_SEPARATOR,
$uploader->id,
DIRECTORY_SEPARATOR
);
if (!file_exists($dir)) {
mkdir($dir, 0755, true);
}
return $mediaPath . DIRECTORY_SEPARATOR . self::UPLOAD_DIRECTORY . DIRECTORY_SEPARATOR;
return $dir;
});
}
private function getTargetFileName(UploadedFile $file): string
private function getTargetFileName(UploadedFile $file, User $uploader): string
{
// If there's no existing file with the same name in the upload directory, use the original name.
// Otherwise, prefix the original name with a hash.
// The whole point is to keep a readable file name when we can.
if (!file_exists($this->getUploadDirectory() . $file->getClientOriginalName())) {
if (!file_exists($this->getUploadDirectory($uploader) . $file->getClientOriginalName())) {
return $file->getClientOriginalName();
}

View file

@ -137,7 +137,6 @@ return [
App\Providers\AuthServiceProvider::class,
App\Providers\EventServiceProvider::class,
App\Providers\RouteServiceProvider::class,
App\Providers\MediaCacheServiceProvider::class,
App\Providers\UtilServiceProvider::class,
App\Providers\YouTubeServiceProvider::class,
App\Providers\DownloadServiceProvider::class,

View file

@ -107,7 +107,7 @@ return [
*/
'download' => [
'allow' => env('ALLOW_DOWNLOAD', true),
'allow' => env('allows_download', true),
],
/*

View file

@ -44,8 +44,8 @@ export default abstract class UnitTestCase {
protected beforeEach (cb?: Closure) {
beforeEach(() => {
commonStore.state.song_length = 10
commonStore.state.allow_download = true
commonStore.state.use_i_tunes = true
commonStore.state.allows_download = true
commonStore.state.uses_i_tunes = true
cb && cb()
})
}

View file

@ -42,7 +42,7 @@ new class extends UnitTestCase {
})
it('does not have an option to download if downloading is disabled', async () => {
commonStore.state.allow_download = false
commonStore.state.allows_download = false
this.renderComponent()
expect(screen.queryByText('Download')).toBeNull()

View file

@ -51,7 +51,7 @@ const { startDragging } = useDraggable('album')
const props = withDefaults(defineProps<{ album: Album, layout?: ArtistAlbumCardLayout }>(), { layout: 'full' })
const { album, layout } = toRefs(props)
const allowDownload = toRef(commonStore.state, 'allow_download')
const allowDownload = toRef(commonStore.state, 'allows_download')
const isStandardArtist = computed(() => artistStore.isStandard(album.value.artist_id))
const showing = computed(() => !albumStore.isUnknown(album.value))

View file

@ -61,7 +61,7 @@ new class extends UnitTestCase {
})
it('does not have an option to download if downloading is disabled', async () => {
commonStore.state.allow_download = false
commonStore.state.allows_download = false
await this.renderComponent()
expect(screen.queryByText('Download')).toBeNull()

View file

@ -25,7 +25,7 @@ const { go } = useRouter()
const { base, ContextMenuBase, open, trigger } = useContextMenu()
const album = ref<Album>()
const allowDownload = toRef(commonStore.state, 'allow_download')
const allowDownload = toRef(commonStore.state, 'allows_download')
const isStandardAlbum = computed(() => !albumStore.isUnknown(album.value!))

View file

@ -10,7 +10,7 @@ let album: Album
new class extends UnitTestCase {
private async renderComponent (mode: MediaInfoDisplayMode = 'aside', info?: AlbumInfo) {
commonStore.state.use_last_fm = true
commonStore.state.uses_last_fm = true
if (info === undefined) {
info = factory<AlbumInfo>('album-info')

View file

@ -43,7 +43,7 @@ new class extends UnitTestCase {
})
it('does not have an option to download if downloading is disabled', async () => {
commonStore.state.allow_download = false
commonStore.state.allows_download = false
this.renderComponent()
expect(screen.queryByText('Download')).toBeNull()

View file

@ -48,7 +48,7 @@ const { startDragging } = useDraggable('artist')
const props = withDefaults(defineProps<{ artist: Artist, layout?: ArtistAlbumCardLayout }>(), { layout: 'full' })
const { artist, layout } = toRefs(props)
const allowDownload = toRef(commonStore.state, 'allow_download')
const allowDownload = toRef(commonStore.state, 'allows_download')
const showing = computed(() => artistStore.isStandard(artist.value))

View file

@ -61,7 +61,7 @@ new class extends UnitTestCase {
})
it('does not have an option to download if downloading is disabled', async () => {
commonStore.state.allow_download = false
commonStore.state.allows_download = false
await this.renderComponent()
expect(screen.queryByText('Download')).toBeNull()

View file

@ -26,7 +26,7 @@ const { go } = useRouter()
const { base, ContextMenuBase, open, trigger } = useContextMenu()
const artist = ref<Artist>()
const allowDownload = toRef(commonStore.state, 'allow_download')
const allowDownload = toRef(commonStore.state, 'allows_download')
const isStandardArtist = computed(() =>
!artistStore.isUnknown(artist.value!)

View file

@ -10,7 +10,7 @@ let artist: Artist
new class extends UnitTestCase {
private async renderComponent (mode: MediaInfoDisplayMode = 'aside', info?: ArtistInfo) {
commonStore.state.use_last_fm = true
commonStore.state.uses_last_fm = true
info = info ?? factory<ArtistInfo>('artist-info')
artist = factory<Artist>('artist', { name: 'Led Zeppelin' })

View file

@ -49,7 +49,7 @@ new class extends UnitTestCase {
})
it('fetches info for the current song', async () => {
commonStore.state.use_you_tube = true
commonStore.state.uses_you_tube = true
const song = factory<Song>('song')
const songRef = ref<Song | null>(null)

View file

@ -31,7 +31,7 @@ new class extends UnitTestCase {
})
it('shows the YouTube sidebar item on demand', async () => {
commonStore.state.use_you_tube = true
commonStore.state.uses_you_tube = true
this.render(Sidebar)
eventBus.emit('PLAY_YOUTUBE_VIDEO', { id: '123', title: 'A Random Video' })

View file

@ -17,13 +17,13 @@
<PlaylistList />
<section v-if="isAdmin" class="manage">
<section v-if="showManageSection" class="manage">
<h1>Manage</h1>
<ul class="menu">
<SidebarItem screen="Settings" href="#/settings" :icon="faTools">Settings</SidebarItem>
<SidebarItem screen="Settings" href="#/settings" :icon="faTools" v-if="isAdmin">Settings</SidebarItem>
<SidebarItem screen="Upload" href="#/upload" :icon="faUpload">Upload</SidebarItem>
<SidebarItem screen="Users" href="#/users" :icon="faUsers">Users</SidebarItem>
<SidebarItem screen="Users" href="#/users" :icon="faUsers" v-if="isAdmin">Users</SidebarItem>
</ul>
</section>
</nav>
@ -43,7 +43,7 @@ import {
import { computed, ref } from 'vue'
import { eventBus } from '@/utils'
import { useAuthorization, useRouter, useThirdPartyServices } from '@/composables'
import { useAuthorization, useRouter, useThirdPartyServices, useUpload } from '@/composables'
import SidebarItem from './SidebarItem.vue'
import QueueSidebarItem from './QueueSidebarItem.vue'
@ -54,11 +54,13 @@ import SearchForm from '@/components/ui/SearchForm.vue'
const { onRouteChanged } = useRouter()
const { useYouTube } = useThirdPartyServices()
const { isAdmin } = useAuthorization()
const { allowsUpload } = useUpload()
const mobileShowing = ref(false)
const youTubePlaying = ref(false)
const showYouTube = computed(() => useYouTube.value && youTubePlaying.value)
const showManageSection = computed(() => isAdmin.value || allowsUpload.value)
const closeIfMobile = () => (mobileShowing.value = false)

View file

@ -8,7 +8,7 @@ new class extends UnitTestCase {
it.each<[boolean, boolean]>([[false, false], [false, true], [true, false], [true, true]])
('renders proper content with Spotify integration status %s, current user admin status %s',
(useSpotify, isAdmin) => {
commonStore.state.use_spotify = useSpotify
commonStore.state.uses_spotify = useSpotify
if (isAdmin) {
this.actingAsAdmin()

View file

@ -11,7 +11,7 @@ let album: Album
new class extends UnitTestCase {
private async renderComponent () {
commonStore.state.use_last_fm = true
commonStore.state.uses_last_fm = true
album = factory<Album>('album', {
id: 42,

View file

@ -134,8 +134,8 @@ const {
onScrollBreakpoint
} = useSongList(songs)
const useLastfm = toRef(commonStore.state, 'use_last_fm')
const allowDownload = toRef(commonStore.state, 'allow_download')
const useLastfm = toRef(commonStore.state, 'uses_last_fm')
const allowDownload = toRef(commonStore.state, 'allows_download')
const isNormalArtist = computed(() => {
if (!album.value) return true

View file

@ -11,7 +11,7 @@ let artist: Artist
new class extends UnitTestCase {
protected async renderComponent () {
commonStore.state.use_last_fm = true
commonStore.state.uses_last_fm = true
artist = factory<Artist>('artist', {
id: 42,

View file

@ -131,7 +131,7 @@ const {
} = useSongList(songs)
const { useLastfm } = useThirdPartyServices()
const allowDownload = toRef(commonStore.state, 'allow_download')
const allowDownload = toRef(commonStore.state, 'allows_download')
const albumCount = computed(() => {
const albums = new Set()

View file

@ -91,7 +91,7 @@ const {
sort
} = useSongList(toRef(favoriteStore.state, 'songs'))
const allowDownload = toRef(commonStore.state, 'allow_download')
const allowDownload = toRef(commonStore.state, 'allows_download')
const download = () => downloadService.fromFavorites()
const removeSelected = () => selectedSongs.value.length && favoriteStore.unlike(selectedSongs.value)

View file

@ -110,7 +110,7 @@ const {
const { removeSongsFromPlaylist } = usePlaylistManagement()
const allowDownload = toRef(commonStore.state, 'allow_download')
const allowDownload = toRef(commonStore.state, 'allows_download')
const destroy = () => eventBus.emit('PLAYLIST_DELETE', playlist.value!)
const download = () => downloadService.fromPlaylist(playlist.value!)

View file

@ -43,7 +43,7 @@
</template>
<li v-if="isAdmin" @click="openEditForm">Edit</li>
<li v-if="allowDownload" @click="download">Download</li>
<li v-if="allowsDownload" @click="download">Download</li>
<li v-if="onlyOneSongSelected" @click="copyUrl">Copy Shareable URL</li>
<template v-if="canBeRemovedFromPlaylist">
@ -92,7 +92,7 @@ const {
} = useSongMenuMethods(songs, close)
const playlists = toRef(playlistStore.state, 'playlists')
const allowDownload = toRef(commonStore.state, 'allow_download')
const allowsDownload = toRef(commonStore.state, 'allows_download')
const queue = toRef(queueStore.state, 'songs')
const currentSong = toRef(queueStore, 'current')

View file

@ -7,7 +7,7 @@ import ExtraDrawerTabHeader from './ExtraDrawerTabHeader.vue'
new class extends UnitTestCase {
protected test () {
it('renders tab headers', () => {
commonStore.state.use_you_tube = false
commonStore.state.uses_you_tube = false
this.render(ExtraDrawerTabHeader)
;['Lyrics', 'Artist information', 'Album information'].forEach(name => screen.getByRole('button', { name }))
@ -15,7 +15,7 @@ new class extends UnitTestCase {
})
it('has a YouTube tab header if using YouTube', () => {
commonStore.state.use_you_tube = true
commonStore.state.uses_you_tube = true
this.render(ExtraDrawerTabHeader)
screen.getByRole('button', { name: 'Related YouTube videos' })

View file

@ -3,9 +3,9 @@ import { commonStore } from '@/stores'
export const useThirdPartyServices = () => {
return {
useLastfm: toRef(commonStore.state, 'use_last_fm'),
useYouTube: toRef(commonStore.state, 'use_you_tube'),
useAppleMusic: toRef(commonStore.state, 'use_i_tunes'),
useSpotify: toRef(commonStore.state, 'use_spotify')
useLastfm: toRef(commonStore.state, 'uses_last_fm'),
useYouTube: toRef(commonStore.state, 'uses_you_tube'),
useAppleMusic: toRef(commonStore.state, 'uses_i_tunes'),
useSpotify: toRef(commonStore.state, 'uses_spotify')
}
}

View file

@ -1,20 +1,16 @@
import isMobile from 'ismobilejs'
import { computed, toRef } from 'vue'
import { settingStore } from '@/stores'
import { computed } from 'vue'
import { commonStore } from '@/stores'
import { acceptedMediaTypes } from '@/config'
import { UploadFile, uploadService } from '@/services'
import { getAllFileEntries, pluralize } from '@/utils'
import { useAuthorization, useMessageToaster, useRouter } from '@/composables'
import { useMessageToaster, useRouter } from '@/composables'
export const useUpload = () => {
const { isAdmin } = useAuthorization()
const { toastSuccess, toastWarning } = useMessageToaster()
const { go, isCurrentScreen } = useRouter()
const mediaPath = toRef(settingStore.state, 'media_path')
const mediaPathSetUp = computed(() => Boolean(mediaPath.value))
const allowsUpload = computed(() => isAdmin.value && !isMobile.any)
const mediaPathSetUp = computed(() => commonStore.state.media_path_set)
const allowsUpload = computed(() => commonStore.state.allows_upload)
const fileEntryToFile = async (entry: FileSystemEntry) => new Promise<File>(resolve => entry.file(resolve))

View file

@ -2,6 +2,7 @@ import { eventBus } from '@/utils'
import { Route } from '@/router'
import { userStore } from '@/stores'
import { localStorageService } from '@/services'
import { useUpload } from '@/composables'
export const routes: Route[] = [
{
@ -47,7 +48,7 @@ export const routes: Route[] = [
{
path: '/upload',
screen: 'Upload',
onResolve: () => userStore.current?.is_admin
onResolve: () => useUpload().allowsUpload.value
},
{
path: '/settings',

View file

@ -98,7 +98,7 @@ new class extends UnitTestCase {
})
it('scrobbles if current song ends', () => {
commonStore.state.use_last_fm = true
commonStore.state.uses_last_fm = true
userStore.state.current = reactive(factory<User>('user', {
preferences: {
lastfm_session_key: 'foo'
@ -114,7 +114,7 @@ new class extends UnitTestCase {
it.each<[RepeatMode, number, number]>([['REPEAT_ONE', 1, 0], ['NO_REPEAT', 0, 1], ['REPEAT_ALL', 0, 1]])(
'when song ends, if repeat mode is %s then restart() is called %d times and playNext() is called %d times',
(repeatMode, restartCalls, playNextCalls) => {
commonStore.state.use_last_fm = false // so that no scrobbling is made unnecessarily
commonStore.state.uses_last_fm = false // so that no scrobbling is made unnecessarily
preferences.repeatMode = repeatMode
playbackService.init(document.querySelector('.plyr')!)
const restartMock = this.mock(playbackService, 'restart')

View file

@ -369,7 +369,7 @@ class PlaybackService {
media.addEventListener('error', () => this.playNext(), true)
media.addEventListener('ended', () => {
if (commonStore.state.use_last_fm && userStore.current.preferences!.lastfm_session_key) {
if (commonStore.state.uses_last_fm && userStore.current.preferences!.lastfm_session_key) {
songStore.scrobble(queueStore.current!)
}

View file

@ -4,39 +4,43 @@ import { http } from '@/services'
import { playlistFolderStore, playlistStore, preferenceStore, queueStore, settingStore, themeStore, userStore } from '.'
interface CommonStoreState {
allow_download: boolean
allows_download: boolean
allows_upload: false,
cdn_url: string
current_user: User
current_version: string
latest_version: string
media_path_set: boolean
playlists: Playlist[]
playlist_folders: PlaylistFolder[]
queue_state: QueueState,
settings: Settings
use_i_tunes: boolean
use_last_fm: boolean
use_spotify: boolean
users: User[]
use_you_tube: boolean,
song_count: number,
song_length: number,
queue_state: QueueState
uses_i_tunes: boolean
uses_last_fm: boolean
uses_spotify: boolean
uses_you_tube: boolean,
users: User[]
}
export const commonStore = {
state: reactive<CommonStoreState>({
allow_download: false,
allows_download: false,
allows_upload: false,
cdn_url: '',
current_user: undefined as unknown as User,
current_version: '',
latest_version: '',
media_path_set: false,
playlists: [],
playlist_folders: [],
settings: {} as Settings,
use_i_tunes: false,
use_last_fm: false,
use_spotify: false,
uses_i_tunes: false,
uses_last_fm: false,
uses_spotify: false,
users: [],
use_you_tube: false,
uses_you_tube: false,
song_count: 0,
song_length: 0,
queue_state: {
@ -51,7 +55,10 @@ export const commonStore = {
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
this.state.uses_you_tube = this.state.uses_you_tube && !isMobile.phone
// Always disable uploading on mobile
this.state.allows_upload = this.state.allows_upload && !isMobile.phone
// If this is a new user, initialize his preferences to be an empty object.
this.state.current_user.preferences = this.state.current_user.preferences || {}

View file

@ -11,10 +11,10 @@ class DataTest extends TestCase
'playlists',
'playlist_folders',
'current_user',
'use_last_fm',
'use_you_tube',
'use_i_tunes',
'allow_download',
'uses_last_fm',
'uses_you_tube',
'uses_i_tunes',
'allows_download',
'supports_transcoding',
'cdn_url',
'current_version',

View file

@ -24,8 +24,7 @@ class UploadTest extends TestCase
parent::setUp();
$this->uploadService = self::mock(UploadService::class);
$this->file = UploadedFile::fake()
->createWithContent('song.mp3', file_get_contents(__DIR__ . '/../songs/full.mp3'));
$this->file = UploadedFile::fromFile(__DIR__ . '/../songs/full.mp3', 'song.mp3');
}
public function testUnauthorizedPost(): void

View file

@ -1,62 +0,0 @@
<?php
namespace Tests\Integration\Services;
use App\Models\Album;
use App\Models\Artist;
use App\Models\Song;
use App\Services\MediaCacheService;
use Illuminate\Cache\Repository as Cache;
use Mockery;
use Tests\TestCase;
class MediaCacheServiceTest extends TestCase
{
private MediaCacheService $mediaCacheService;
private $cache;
public function setUp(): void
{
parent::setUp();
$this->cache = Mockery::mock(Cache::class);
$this->mediaCacheService = new MediaCacheService($this->cache);
}
public function testGetIfCacheIsNotAvailable(): void
{
Song::factory(5)->create();
$this->cache->shouldReceive('rememberForever')->andReturn([
'albums' => Album::query()->orderBy('name')->get(),
'artists' => Artist::query()->orderBy('name')->get(),
'songs' => Song::all(),
]);
$data = $this->mediaCacheService->get();
self::assertCount(6, $data['albums']); // 5 new albums and the default Unknown Album
self::assertCount(7, $data['artists']); // 5 new artists and the default Various and Unknown Artist
self::assertCount(5, $data['songs']);
}
public function testGetIfCacheIsAvailable(): void
{
$this->cache->shouldReceive('rememberForever')->andReturn(['dummy']);
config(['koel.cache_media' => true]);
$data = $this->mediaCacheService->get();
self::assertEquals(['dummy'], $data);
}
public function testCacheDisabled(): void
{
$this->cache->shouldReceive('rememberForever')->never();
config(['koel.cache_media' => false]);
$this->mediaCacheService->get();
}
}

View file

@ -0,0 +1,59 @@
<?php
namespace Tests\Integration\Services;
use App\Exceptions\MediaPathNotSetException;
use App\Exceptions\SongUploadFailedException;
use App\Models\Setting;
use App\Models\User;
use App\Services\UploadService;
use Illuminate\Http\UploadedFile;
use Mockery;
use Tests\TestCase;
class UploadServiceTest extends TestCase
{
private UploadService $service;
public function setUp(): void
{
parent::setUp();
$this->service = app(UploadService::class);
}
public function testHandleUploadedFileWithMediaPathNotSet(): void
{
Setting::set('media_path');
/** @var User $user */
$user = User::factory()->create();
self::expectException(MediaPathNotSetException::class);
$this->service->handleUploadedFile(Mockery::mock(UploadedFile::class), $user);
}
public function testHandleUploadedFileFails(): void
{
Setting::set('media_path', public_path('sandbox/media'));
/** @var User $user */
$user = User::factory()->create();
self::expectException(SongUploadFailedException::class);
$this->service->handleUploadedFile(UploadedFile::fake()->create('fake.mp3'), $user);
}
public function testHandleUploadedFile(): void
{
Setting::set('media_path', public_path('sandbox/media'));
/** @var User $user */
$user = User::factory()->create();
$song = $this->service->handleUploadedFile(UploadedFile::fromFile(__DIR__ . '/../../songs/full.mp3'), $user);
self::assertSame($song->owner_id, $user->id);
self::assertSame(public_path("sandbox/media/__KOEL_UPLOADS_\${$user->id}__/full.mp3"), $song->path);
}
}

View file

@ -10,6 +10,7 @@ use App\Services\CommunityLicenseService;
use DMS\PHPUnitExtensions\ArraySubset\ArraySubsetAsserts;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
use Illuminate\Http\UploadedFile;
use Illuminate\Testing\TestResponse;
use ReflectionClass;
use Tests\Traits\CreatesApplication;
@ -35,6 +36,10 @@ abstract class TestCase extends BaseTestCase
return $this;
});
UploadedFile::macro('fromFile', static function (string $path, ?string $name = null): UploadedFile {
return UploadedFile::fake()->createWithContent($name ?? basename($path), file_get_contents($path));
});
self::createSandbox();
}

View file

@ -13,6 +13,7 @@ trait SandboxesTests
@mkdir(public_path(config('koel.album_cover_dir')), 0755, true);
@mkdir(public_path(config('koel.artist_image_dir')), 0755, true);
@mkdir(public_path('sandbox/media/'), 0755, true);
}
private static function destroySandbox(): void

View file

@ -1,103 +0,0 @@
<?php
namespace Tests\Unit\Services;
use App\Exceptions\MediaPathNotSetException;
use App\Exceptions\SongUploadFailedException;
use App\Models\Setting;
use App\Models\Song;
use App\Services\FileSynchronizer;
use App\Services\UploadService;
use App\Values\SyncResult;
use Illuminate\Http\UploadedFile;
use Mockery;
use Mockery\LegacyMockInterface;
use Mockery\MockInterface;
use Tests\TestCase;
class UploadServiceTest extends TestCase
{
private FileSynchronizer|MockInterface|LegacyMockInterface $fileSynchronizer;
private UploadService $uploadService;
public function setUp(): void
{
parent::setUp();
$this->fileSynchronizer = Mockery::mock(FileSynchronizer::class);
$this->uploadService = new UploadService($this->fileSynchronizer);
}
public function testHandleUploadedFileWithMediaPathNotSet(): void
{
Setting::set('media_path');
self::expectException(MediaPathNotSetException::class);
$this->uploadService->handleUploadedFile(Mockery::mock(UploadedFile::class));
}
public function testHandleUploadedFileFails(): void
{
Setting::set('media_path', '/media/koel');
/** @var UploadedFile|MockInterface $file */
$file = Mockery::mock(UploadedFile::class);
$file->shouldReceive('getClientOriginalName')
->andReturn('foo.mp3');
$file->shouldReceive('move')
->once()
->with('/media/koel/__KOEL_UPLOADS__/', 'foo.mp3');
$this->fileSynchronizer
->shouldReceive('setFile')
->once()
->with('/media/koel/__KOEL_UPLOADS__/foo.mp3')
->andReturnSelf();
$this->fileSynchronizer
->shouldReceive('sync')
->once()
->with()
->andReturn(SyncResult::error('/media/koel/__KOEL_UPLOADS__/foo.mp3', 'A monkey ate your file oh no'));
self::expectException(SongUploadFailedException::class);
self::expectExceptionMessage('A monkey ate your file oh no');
$this->uploadService->handleUploadedFile($file);
}
public function testHandleUploadedFile(): void
{
Setting::set('media_path', '/media/koel');
/** @var UploadedFile|MockInterface $file */
$file = Mockery::mock(UploadedFile::class);
$file->shouldReceive('getClientOriginalName')
->andReturn('foo.mp3');
$file->shouldReceive('move')
->once()
->with('/media/koel/__KOEL_UPLOADS__/', 'foo.mp3');
$this->fileSynchronizer
->shouldReceive('setFile')
->once()
->with('/media/koel/__KOEL_UPLOADS__/foo.mp3')
->andReturnSelf();
$this->fileSynchronizer
->shouldReceive('sync')
->once()
->andReturn(SyncResult::success('/media/koel/__KOEL_UPLOADS__/foo.mp3'));
$song = new Song();
$this->fileSynchronizer
->shouldReceive('getSong')
->once()
->andReturn($song);
self::assertSame($song, $this->uploadService->handleUploadedFile($file));
}
}