mirror of
https://github.com/koel/koel
synced 2024-11-27 22:40:26 +00:00
feat: allows users to upload for Plus
This commit is contained in:
parent
f4a0e8d006
commit
53d08371b9
48 changed files with 216 additions and 346 deletions
|
@ -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
|
||||
|
|
19
app/Exceptions/OwnerNotSetPriorToScanException.php
Normal file
19
app/Exceptions/OwnerNotSetPriorToScanException.php
Normal 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.');
|
||||
}
|
||||
}
|
|
@ -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(),
|
||||
|
|
|
@ -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());
|
||||
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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 => [
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -107,7 +107,7 @@ return [
|
|||
*/
|
||||
|
||||
'download' => [
|
||||
'allow' => env('ALLOW_DOWNLOAD', true),
|
||||
'allow' => env('allows_download', true),
|
||||
],
|
||||
|
||||
/*
|
||||
|
|
|
@ -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()
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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!))
|
||||
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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))
|
||||
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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!)
|
||||
|
|
|
@ -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' })
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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' })
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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!)
|
||||
|
|
|
@ -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')
|
||||
|
||||
|
|
|
@ -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' })
|
||||
|
|
|
@ -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')
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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))
|
||||
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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!)
|
||||
}
|
||||
|
||||
|
|
|
@ -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 || {}
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
59
tests/Integration/Services/UploadServiceTest.php
Normal file
59
tests/Integration/Services/UploadServiceTest.php
Normal 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);
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue