feat: allow making songs public/private

This commit is contained in:
Phan An 2024-01-08 23:21:21 +01:00
parent 2c3479b6f3
commit ca9b77f697
12 changed files with 169 additions and 4 deletions

View file

@ -0,0 +1,28 @@
<?php
namespace App\Http\Controllers\API;
use App\Http\Controllers\Controller;
use App\Http\Requests\API\ChangeSongsVisibilityRequest;
use App\Models\User;
use App\Repositories\SongRepository;
use App\Services\SongService;
use Illuminate\Contracts\Auth\Authenticatable;
class MakeSongsPrivateController extends Controller
{
/** @param User $user */
public function __invoke(
ChangeSongsVisibilityRequest $request,
SongRepository $repository,
SongService $songService,
Authenticatable $user
) {
$songs = $repository->getMany(ids: $request->songs, scopedUser: $user);
$songs->each(fn ($song) => $this->authorize('own', $song));
$songService->makeSongsPrivate($songs);
return response()->noContent();
}
}

View file

@ -0,0 +1,28 @@
<?php
namespace App\Http\Controllers\API;
use App\Http\Controllers\Controller;
use App\Http\Requests\API\ChangeSongsVisibilityRequest;
use App\Models\User;
use App\Repositories\SongRepository;
use App\Services\SongService;
use Illuminate\Contracts\Auth\Authenticatable;
class MakeSongsPublicController extends Controller
{
/** @param User $user */
public function __invoke(
ChangeSongsVisibilityRequest $request,
SongRepository $repository,
SongService $songService,
Authenticatable $user
) {
$songs = $repository->getMany(ids: $request->songs, scopedUser: $user);
$songs->each(fn ($song) => $this->authorize('own', $song));
$songService->makeSongsPublic($songs);
return response()->noContent();
}
}

View file

@ -0,0 +1,17 @@
<?php
namespace App\Http\Requests\API;
/**
* @property-read array<string> $songs
*/
class ChangeSongsVisibilityRequest extends Request
{
/** @return array<mixed> */
public function rules(): array
{
return [
'songs' => 'required|exists:songs,id',
];
}
}

View file

@ -35,6 +35,7 @@ class SongResource extends JsonResource
'disc' => $this->song->disc,
'genre' => $this->song->genre,
'year' => $this->song->year,
'is_public' => $this->song->is_public,
'created_at' => $this->song->created_at,
];
}

View file

@ -8,6 +8,11 @@ use App\Models\User;
class SongPolicy
{
public function own(User $user, Song $song): bool
{
return $song->owner_id === $user->id;
}
public function play(User $user, Song $song): bool
{
return License::isCommunity() || $song->is_public || $song->owner_id === $user->id;

View file

@ -82,6 +82,16 @@ class SongService
return $this->songRepository->getOne($song->id);
}
public function makeSongsPublic(Collection $songs): void
{
Song::query()->whereIn('id', $songs->pluck('id'))->update(['is_public' => true]);
}
public function makeSongsPrivate(Collection $songs): void
{
Song::query()->whereIn('id', $songs->pluck('id'))->update(['is_public' => false]);
}
/**
* @param array<string>|string $ids
*/

View file

@ -25,6 +25,7 @@ const generate = (partOfCompilation = false): Song => {
lyrics: faker.lorem.paragraph(),
play_count: faker.datatype.number(),
liked: faker.datatype.boolean(),
is_public: faker.datatype.boolean(),
created_at: faker.date.past().toISOString(),
playback_state: 'Stopped'
}

View file

@ -22,9 +22,11 @@
<li @click="addSongsToFavorite">Favorites</li>
</template>
<li v-if="normalPlaylists.length" class="separator" />
<ul v-if="normalPlaylists.length" v-koel-overflow-fade class="playlists">
<li v-if="normalPlaylists.length">
<ul class="playlists" v-koel-overflow-fade>
<li v-for="p in normalPlaylists" :key="p.id" @click="addSongsToExistingPlaylist(p)">{{ p.name }}</li>
</ul>
</li>
<li class="separator" />
<li @click="addSongsToNewPlaylist">New Playlist</li>
</ul>
@ -42,6 +44,12 @@
<li class="separator" />
</template>
<template v-if="visibilityActions.length">
<li class="separator" />
<li v-for="action in visibilityActions" :key="action.label" @click="action.handler">{{ action.label }}</li>
<li class="separator" />
</template>
<li v-if="canModify" @click="openEditForm">Edit</li>
<li v-if="allowsDownload" @click="download">Download</li>
<li v-if="onlyOneSongSelected" @click="copyUrl">Copy Shareable URL</li>
@ -107,6 +115,42 @@ const onlyOneSongSelected = computed(() => songs.value.length === 1)
const firstSongPlaying = computed(() => songs.value.length ? songs.value[0].playback_state === 'Playing' : false)
const normalPlaylists = computed(() => playlists.value.filter(playlist => !playlist.is_smart))
const makePublic = () => trigger(async () => {
await songStore.makePublic(songs.value)
toastSuccess(`Made ${pluralize(songs.value, 'song')} public to everyone.`)
})
const makePrivate = () => trigger(async () => {
await songStore.makePrivate(songs.value)
toastSuccess(`Removed public access to ${pluralize(songs.value, 'song')}.`)
})
const visibilityActions = computed(() => {
if (!isPlus) return []
// If some songs don't belong to the current user, no actions are available.
if (songs.value.some(song => song.owner_id !== currentUser.value?.id)) return []
const visibilities = Array.from(new Set(songs.value.map(song => song.is_public)))
if (visibilities.length === 2) {
return [
{
label: 'Make Public',
handler: makePublic
},
{
label: 'Make Private',
handler: makePrivate
}
]
}
return visibilities[0]
? [{ label: 'Make Private', handler: makePrivate }]
: [{ label: 'Make Public', handler: makePublic }]
})
const canBeRemovedFromPlaylist = computed(() => {
if (!isCurrentScreen('Playlist')) return false
const playlist = playlistStore.byId(parseInt(getRouteParam('id')!))
@ -174,5 +218,9 @@ ul.playlists {
position: relative;
max-height: 192px;
overflow-y: auto;
li {
padding: 0;
}
}
</style>

View file

@ -16,6 +16,7 @@ export type SongUpdateData = {
lyrics?: string
year?: number | null
genre?: string
visibility?: 'public' | 'private' | 'unchanged'
}
export interface SongUpdateResult {
@ -233,5 +234,21 @@ export const songStore = {
})
await http.delete('songs', { songs: ids })
},
async makePublic (songs: Song[]) {
await http.put('songs/make-public', {
songs: songs.map(song => song.id)
})
songs.forEach(song => song.is_public = true)
},
async makePrivate (songs: Song[]) {
await http.put('songs/make-private', {
songs: songs.map(song => song.id)
})
songs.forEach(song => song.is_public = false)
}
}

View file

@ -150,6 +150,7 @@ interface Song {
play_start_time?: number
fmt_length?: string
created_at: string
is_public: boolean
deleted?: boolean
}
@ -384,7 +385,7 @@ interface PaginatorResource {
}
}
type EditSongFormTabName = 'details' | 'lyrics'
type EditSongFormTabName = 'details' | 'lyrics' | 'visibility'
type ToastMessage = {
id: string

View file

@ -1,5 +1,6 @@
<?php
use App\Facades\License;
use App\Facades\YouTube;
use App\Http\Controllers\API\AlbumController;
use App\Http\Controllers\API\AlbumSongController;
@ -24,6 +25,8 @@ use App\Http\Controllers\API\GenreSongController;
use App\Http\Controllers\API\Interaction\BatchLikeController;
use App\Http\Controllers\API\Interaction\HandlePlaybackStartedController;
use App\Http\Controllers\API\Interaction\ToggleLikeSongController;
use App\Http\Controllers\API\MakeSongsPrivateController;
use App\Http\Controllers\API\MakeSongsPublicController;
use App\Http\Controllers\API\ObjectStorage\S3\SongController as S3SongController;
use App\Http\Controllers\API\PlaylistController;
use App\Http\Controllers\API\PlaylistFolderController;
@ -155,6 +158,11 @@ Route::prefix('api')->middleware('api')->group(static function (): void {
Route::post('invitations', [UserInvitationController::class, 'invite']);
Route::delete('invitations', [UserInvitationController::class, 'revoke']);
if (License::isPlus()) {
Route::put('songs/make-public', MakeSongsPublicController::class);
Route::put('songs/make-private', MakeSongsPrivateController::class);
}
});
// Object-storage (S3) routes

View file

@ -29,6 +29,7 @@ class SongTest extends TestCase
'genre',
'year',
'disc',
'is_public',
'created_at',
];