mirror of
https://github.com/koel/koel
synced 2024-11-27 22:40:26 +00:00
feat: use UUIDs for song IDs
This commit is contained in:
parent
f5f6aa0d7f
commit
c4cffcc2e7
16 changed files with 128 additions and 114 deletions
|
@ -5,7 +5,6 @@ namespace App\Listeners;
|
||||||
use App\Events\MediaSyncCompleted;
|
use App\Events\MediaSyncCompleted;
|
||||||
use App\Models\Song;
|
use App\Models\Song;
|
||||||
use App\Repositories\SongRepository;
|
use App\Repositories\SongRepository;
|
||||||
use App\Services\Helper;
|
|
||||||
use App\Values\SyncResult;
|
use App\Values\SyncResult;
|
||||||
|
|
||||||
class DeleteNonExistingRecordsPostSync
|
class DeleteNonExistingRecordsPostSync
|
||||||
|
@ -16,12 +15,12 @@ class DeleteNonExistingRecordsPostSync
|
||||||
|
|
||||||
public function handle(MediaSyncCompleted $event): void
|
public function handle(MediaSyncCompleted $event): void
|
||||||
{
|
{
|
||||||
$hashes = $event->results
|
$paths = $event->results
|
||||||
->valid()
|
->valid()
|
||||||
->map(static fn (SyncResult $result) => Helper::getFileHash($result->path))
|
->map(static fn (SyncResult $result) => $result->path)
|
||||||
->merge($this->songRepository->getAllHostedOnS3()->pluck('id'))
|
->merge($this->songRepository->getAllHostedOnS3()->pluck('path'))
|
||||||
->toArray();
|
->toArray();
|
||||||
|
|
||||||
Song::deleteWhereIDsNotIn($hashes);
|
Song::deleteWhereValueNotIn($paths, 'path');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -47,7 +47,7 @@ class Album extends Model
|
||||||
{
|
{
|
||||||
use HasFactory;
|
use HasFactory;
|
||||||
use Searchable;
|
use Searchable;
|
||||||
use SupportsDeleteWhereIDsNotIn;
|
use SupportsDeleteWhereValueNotIn;
|
||||||
|
|
||||||
public const UNKNOWN_ID = 1;
|
public const UNKNOWN_ID = 1;
|
||||||
public const UNKNOWN_NAME = 'Unknown Album';
|
public const UNKNOWN_NAME = 'Unknown Album';
|
||||||
|
|
|
@ -43,7 +43,7 @@ class Artist extends Model
|
||||||
{
|
{
|
||||||
use HasFactory;
|
use HasFactory;
|
||||||
use Searchable;
|
use Searchable;
|
||||||
use SupportsDeleteWhereIDsNotIn;
|
use SupportsDeleteWhereValueNotIn;
|
||||||
|
|
||||||
public const UNKNOWN_ID = 1;
|
public const UNKNOWN_ID = 1;
|
||||||
public const UNKNOWN_NAME = 'Unknown Artist';
|
public const UNKNOWN_NAME = 'Unknown Artist';
|
||||||
|
|
|
@ -12,6 +12,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
use Illuminate\Database\Query\JoinClause;
|
use Illuminate\Database\Query\JoinClause;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
use Laravel\Scout\Searchable;
|
use Laravel\Scout\Searchable;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -49,9 +50,11 @@ class Song extends Model
|
||||||
{
|
{
|
||||||
use HasFactory;
|
use HasFactory;
|
||||||
use Searchable;
|
use Searchable;
|
||||||
use SupportsDeleteWhereIDsNotIn;
|
use SupportsDeleteWhereValueNotIn;
|
||||||
use SupportsS3;
|
use SupportsS3;
|
||||||
|
|
||||||
|
public const ID_REGEX = '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}';
|
||||||
|
|
||||||
public $incrementing = false;
|
public $incrementing = false;
|
||||||
protected $guarded = [];
|
protected $guarded = [];
|
||||||
|
|
||||||
|
@ -66,6 +69,11 @@ class Song extends Model
|
||||||
|
|
||||||
protected $keyType = 'string';
|
protected $keyType = 'string';
|
||||||
|
|
||||||
|
protected static function booted(): void
|
||||||
|
{
|
||||||
|
static::creating(static fn (self $song) => $song->id = Str::uuid()->toString());
|
||||||
|
}
|
||||||
|
|
||||||
public function artist(): BelongsTo
|
public function artist(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(Artist::class);
|
return $this->belongsTo(Artist::class);
|
||||||
|
|
|
@ -1,67 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Models;
|
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
use Illuminate\Support\Facades\DB;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* With reference to GitHub issue #463.
|
|
||||||
* MySQL and PostgresSQL seem to have a limit of 2^16-1 (65535) elements in an IN statement.
|
|
||||||
* This trait provides a method as a workaround to this limitation.
|
|
||||||
*
|
|
||||||
* @method static Builder whereIn($keys, array $values)
|
|
||||||
* @method static Builder whereNotIn($keys, array $values)
|
|
||||||
* @method static Builder select(string $string)
|
|
||||||
*/
|
|
||||||
trait SupportsDeleteWhereIDsNotIn
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Deletes all records whose IDs are not in an array.
|
|
||||||
*
|
|
||||||
* @param array<string>|array<int> $ids the array of IDs
|
|
||||||
* @param string $key name of the primary key
|
|
||||||
*/
|
|
||||||
public static function deleteWhereIDsNotIn(array $ids, string $key = 'id'): void
|
|
||||||
{
|
|
||||||
$maxChunkSize = config('database.default') === 'sqlite-persistent' ? 999 : 65535;
|
|
||||||
|
|
||||||
// If the number of entries is lower than, or equals to maxChunkSize, just go ahead.
|
|
||||||
if (count($ids) <= $maxChunkSize) {
|
|
||||||
static::whereNotIn($key, $ids)->delete();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Otherwise, we get the actual IDs that should be deleted…
|
|
||||||
$allIDs = static::select($key)->get()->pluck($key)->all();
|
|
||||||
$whereInIDs = array_diff($allIDs, $ids);
|
|
||||||
|
|
||||||
// …and see if we can delete them instead.
|
|
||||||
if (count($whereInIDs) < $maxChunkSize) {
|
|
||||||
static::whereIn($key, $whereInIDs)->delete();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If that's not possible (i.e. this array has more than maxChunkSize elements, too)
|
|
||||||
// then we'll delete chunk by chunk.
|
|
||||||
static::deleteByChunk($ids, $key, $maxChunkSize);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete records chunk by chunk.
|
|
||||||
*
|
|
||||||
* @param array<string>|array<int> $ids The array of record IDs to delete
|
|
||||||
* @param string $key Name of the primary key
|
|
||||||
* @param int $chunkSize Size of each chunk. Defaults to 2^16-1 (65535)
|
|
||||||
*/
|
|
||||||
public static function deleteByChunk(array $ids, string $key = 'id', int $chunkSize = 65535): void
|
|
||||||
{
|
|
||||||
DB::transaction(static function () use ($ids, $key, $chunkSize): void {
|
|
||||||
foreach (array_chunk($ids, $chunkSize) as $chunk) {
|
|
||||||
static::whereIn($key, $chunk)->delete();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
57
app/Models/SupportsDeleteWhereValueNotIn.php
Normal file
57
app/Models/SupportsDeleteWhereValueNotIn.php
Normal file
|
@ -0,0 +1,57 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* With reference to GitHub issue #463.
|
||||||
|
* MySQL and PostgresSQL seem to have a limit of 2^16-1 (65535) elements in an IN statement.
|
||||||
|
* This trait provides a method as a workaround to this limitation.
|
||||||
|
*
|
||||||
|
* @method static Builder whereIn($keys, array $values)
|
||||||
|
* @method static Builder whereNotIn($keys, array $values)
|
||||||
|
* @method static Builder select(string $string)
|
||||||
|
*/
|
||||||
|
trait SupportsDeleteWhereValueNotIn
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Deletes all records whose certain value is not in an array.
|
||||||
|
*/
|
||||||
|
public static function deleteWhereValueNotIn(array $values, string $field = 'id'): void
|
||||||
|
{
|
||||||
|
$maxChunkSize = config('database.default') === 'sqlite-persistent' ? 999 : 65535;
|
||||||
|
|
||||||
|
// If the number of entries is lower than, or equals to maxChunkSize, just go ahead.
|
||||||
|
if (count($values) <= $maxChunkSize) {
|
||||||
|
static::whereNotIn($field, $values)->delete();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, we get the actual IDs that should be deleted…
|
||||||
|
$allIDs = static::select($field)->get()->pluck($field)->all();
|
||||||
|
$whereInIDs = array_diff($allIDs, $values);
|
||||||
|
|
||||||
|
// …and see if we can delete them instead.
|
||||||
|
if (count($whereInIDs) < $maxChunkSize) {
|
||||||
|
static::whereIn($field, $whereInIDs)->delete();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If that's not possible (i.e. this array has more than maxChunkSize elements, too)
|
||||||
|
// then we'll delete chunk by chunk.
|
||||||
|
static::deleteByChunk($values, $field, $maxChunkSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function deleteByChunk(array $values, string $field = 'id', int $chunkSize = 65535): void
|
||||||
|
{
|
||||||
|
DB::transaction(static function () use ($values, $field, $chunkSize): void {
|
||||||
|
foreach (array_chunk($values, $chunkSize) as $chunk) {
|
||||||
|
static::whereIn($field, $chunk)->delete();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,14 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Observers;
|
|
||||||
|
|
||||||
use App\Models\Song;
|
|
||||||
use App\Services\Helper;
|
|
||||||
|
|
||||||
class SongObserver
|
|
||||||
{
|
|
||||||
public function creating(Song $song): void
|
|
||||||
{
|
|
||||||
$song->id = Helper::getFileHash($song->path);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -16,12 +16,10 @@ use App\Listeners\PruneLibrary;
|
||||||
use App\Listeners\UnloveMultipleTracksOnLastfm;
|
use App\Listeners\UnloveMultipleTracksOnLastfm;
|
||||||
use App\Listeners\UpdateLastfmNowPlaying;
|
use App\Listeners\UpdateLastfmNowPlaying;
|
||||||
use App\Models\Album;
|
use App\Models\Album;
|
||||||
use App\Models\Song;
|
|
||||||
use App\Observers\AlbumObserver;
|
use App\Observers\AlbumObserver;
|
||||||
use App\Observers\SongObserver;
|
use Illuminate\Foundation\Support\Providers\EventServiceProvider as BaseServiceProvider;
|
||||||
use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;
|
|
||||||
|
|
||||||
class EventServiceProvider extends ServiceProvider
|
class EventServiceProvider extends BaseServiceProvider
|
||||||
{
|
{
|
||||||
protected $listen = [
|
protected $listen = [
|
||||||
SongLikeToggled::class => [
|
SongLikeToggled::class => [
|
||||||
|
@ -54,7 +52,6 @@ class EventServiceProvider extends ServiceProvider
|
||||||
{
|
{
|
||||||
parent::boot();
|
parent::boot();
|
||||||
|
|
||||||
Song::observe(SongObserver::class);
|
|
||||||
Album::observe(AlbumObserver::class);
|
Album::observe(AlbumObserver::class);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,7 +8,6 @@ use App\Models\Playlist;
|
||||||
use App\Models\Song;
|
use App\Models\Song;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Repositories\Traits\Searchable;
|
use App\Repositories\Traits\Searchable;
|
||||||
use App\Services\Helper;
|
|
||||||
use Illuminate\Contracts\Database\Query\Builder;
|
use Illuminate\Contracts\Database\Query\Builder;
|
||||||
use Illuminate\Contracts\Pagination\Paginator;
|
use Illuminate\Contracts\Pagination\Paginator;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
|
@ -32,7 +31,7 @@ class SongRepository extends Repository
|
||||||
|
|
||||||
public function getOneByPath(string $path): ?Song
|
public function getOneByPath(string $path): ?Song
|
||||||
{
|
{
|
||||||
return $this->getOneById(Helper::getFileHash($path));
|
return Song::where('path', $path)->first();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @return Collection|array<Song> */
|
/** @return Collection|array<Song> */
|
||||||
|
|
|
@ -20,12 +20,6 @@ class FileSynchronizer
|
||||||
private ?int $fileModifiedTime = null;
|
private ?int $fileModifiedTime = null;
|
||||||
private ?string $filePath = null;
|
private ?string $filePath = null;
|
||||||
|
|
||||||
/**
|
|
||||||
* A (MD5) hash of the file's path.
|
|
||||||
* This value is unique, and can be used to query a Song record.
|
|
||||||
*/
|
|
||||||
private ?string $fileHash = null;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The song model that's associated with the current file.
|
* The song model that's associated with the current file.
|
||||||
*/
|
*/
|
||||||
|
@ -46,9 +40,8 @@ class FileSynchronizer
|
||||||
{
|
{
|
||||||
$file = $path instanceof SplFileInfo ? $path : new SplFileInfo($path);
|
$file = $path instanceof SplFileInfo ? $path : new SplFileInfo($path);
|
||||||
|
|
||||||
$this->filePath = $file->getPathname();
|
$this->filePath = $file->getRealPath();
|
||||||
$this->fileHash = Helper::getFileHash($this->filePath);
|
$this->song = $this->songRepository->getOneByPath($this->filePath);
|
||||||
$this->song = $this->songRepository->getOneById($this->fileHash); // @phpstan-ignore-line
|
|
||||||
$this->fileModifiedTime = Helper::getModifiedTime($file);
|
$this->fileModifiedTime = Helper::getModifiedTime($file);
|
||||||
|
|
||||||
return $this;
|
return $this;
|
||||||
|
@ -96,7 +89,7 @@ class FileSynchronizer
|
||||||
$data['album_id'] = $album->id;
|
$data['album_id'] = $album->id;
|
||||||
$data['artist_id'] = $artist->id;
|
$data['artist_id'] = $artist->id;
|
||||||
|
|
||||||
$this->song = Song::updateOrCreate(['id' => $this->fileHash], $data);
|
$this->song = Song::updateOrCreate(['path' => $this->filePath], $data);
|
||||||
|
|
||||||
return SyncResult::success($this->filePath);
|
return SyncResult::success($this->filePath);
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,43 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\Song;
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('songs', static function (Blueprint $table): void {
|
||||||
|
$table->string('id', 36)->change();
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::table('playlist_song', static function (Blueprint $table): void {
|
||||||
|
$table->string('song_id', 36)->change();
|
||||||
|
|
||||||
|
if (DB::getDriverName() !== 'sqlite') {
|
||||||
|
$table->dropForeign('playlist_song_song_id_foreign');
|
||||||
|
}
|
||||||
|
|
||||||
|
$table->foreign('song_id')->references('id')->on('songs')->cascadeOnDelete()->cascadeOnUpdate();
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::table('interactions', static function (Blueprint $table): void {
|
||||||
|
$table->string('song_id', 36)->change();
|
||||||
|
|
||||||
|
if (DB::getDriverName() !== 'sqlite') {
|
||||||
|
$table->dropForeign('playlist_song_song_id_foreign');
|
||||||
|
}
|
||||||
|
|
||||||
|
$table->foreign('song_id')->references('id')->on('songs')->cascadeOnDelete()->cascadeOnUpdate();
|
||||||
|
});
|
||||||
|
|
||||||
|
Song::all()->each(static function (Song $song): void {
|
||||||
|
$song->id = Str::uuid();
|
||||||
|
$song->save();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
|
@ -1,10 +1,9 @@
|
||||||
import crypto from 'crypto-random-string'
|
|
||||||
import { Faker } from '@faker-js/faker'
|
import { Faker } from '@faker-js/faker'
|
||||||
|
|
||||||
export default (faker: Faker): Interaction => ({
|
export default (faker: Faker): Interaction => ({
|
||||||
type: 'interactions',
|
type: 'interactions',
|
||||||
id: faker.datatype.number({ min: 1 }),
|
id: faker.datatype.number({ min: 1 }),
|
||||||
song_id: crypto(32),
|
song_id: faker.datatype.uuid(),
|
||||||
liked: faker.datatype.boolean(),
|
liked: faker.datatype.boolean(),
|
||||||
play_count: faker.datatype.number({ min: 1 })
|
play_count: faker.datatype.number({ min: 1 })
|
||||||
})
|
})
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
import crypto from 'crypto-random-string'
|
|
||||||
import { Faker, faker } from '@faker-js/faker'
|
import { Faker, faker } from '@faker-js/faker'
|
||||||
|
|
||||||
const generate = (partOfCompilation = false): Song => {
|
const generate = (partOfCompilation = false): Song => {
|
||||||
|
@ -14,7 +13,7 @@ const generate = (partOfCompilation = false): Song => {
|
||||||
album_artist_id: partOfCompilation ? artistId + 1 : artistId,
|
album_artist_id: partOfCompilation ? artistId + 1 : artistId,
|
||||||
album_artist_name: partOfCompilation ? artistName : faker.name.findName(),
|
album_artist_name: partOfCompilation ? artistName : faker.name.findName(),
|
||||||
album_cover: faker.image.imageUrl(),
|
album_cover: faker.image.imageUrl(),
|
||||||
id: crypto(32),
|
id: faker.datatype.uuid(),
|
||||||
title: faker.lorem.sentence(),
|
title: faker.lorem.sentence(),
|
||||||
length: faker.datatype.number(),
|
length: faker.datatype.number(),
|
||||||
track: faker.datatype.number(),
|
track: faker.datatype.number(),
|
||||||
|
|
|
@ -26,7 +26,7 @@ class Router {
|
||||||
'/album/(\\d+)': async (id: string) => loadMainView('Album', parseInt(id)),
|
'/album/(\\d+)': async (id: string) => loadMainView('Album', parseInt(id)),
|
||||||
'/artist/(\\d+)': async (id: string) => loadMainView('Artist', parseInt(id)),
|
'/artist/(\\d+)': async (id: string) => loadMainView('Artist', parseInt(id)),
|
||||||
'/playlist/(\\d+)': (id: number) => use(playlistStore.byId(~~id), playlist => loadMainView('Playlist', playlist)),
|
'/playlist/(\\d+)': (id: number) => use(playlistStore.byId(~~id), playlist => loadMainView('Playlist', playlist)),
|
||||||
'/song/([a-z0-9]{32})': async (id: string) => {
|
'/song/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})': async (id: string) => {
|
||||||
const song = await songStore.resolve(id)
|
const song = await songStore.resolve(id)
|
||||||
if (!song) {
|
if (!song) {
|
||||||
this.go('home')
|
this.go('home')
|
||||||
|
|
|
@ -18,6 +18,7 @@ use App\Http\Controllers\V6\API\QueueController;
|
||||||
use App\Http\Controllers\V6\API\RecentlyPlayedSongController;
|
use App\Http\Controllers\V6\API\RecentlyPlayedSongController;
|
||||||
use App\Http\Controllers\V6\API\SongController;
|
use App\Http\Controllers\V6\API\SongController;
|
||||||
use App\Http\Controllers\V6\API\SongSearchController;
|
use App\Http\Controllers\V6\API\SongSearchController;
|
||||||
|
use App\Models\Song;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
Route::prefix('api')->middleware('api')->group(static function (): void {
|
Route::prefix('api')->middleware('api')->group(static function (): void {
|
||||||
|
@ -38,7 +39,7 @@ Route::prefix('api')->middleware('api')->group(static function (): void {
|
||||||
Route::post('playlists/{playlist}/songs', [PlaylistSongController::class, 'add']);
|
Route::post('playlists/{playlist}/songs', [PlaylistSongController::class, 'add']);
|
||||||
Route::delete('playlists/{playlist}/songs', [PlaylistSongController::class, 'remove']);
|
Route::delete('playlists/{playlist}/songs', [PlaylistSongController::class, 'remove']);
|
||||||
|
|
||||||
Route::apiResource('songs', SongController::class)->where(['song' => '[a-f0-9]{32}']);
|
Route::apiResource('songs', SongController::class)->where(['song' => Song::ID_REGEX]);
|
||||||
Route::get('songs/recently-played', [RecentlyPlayedSongController::class, 'index']);
|
Route::get('songs/recently-played', [RecentlyPlayedSongController::class, 'index']);
|
||||||
Route::get('songs/favorite', [FavoriteSongController::class, 'index']);
|
Route::get('songs/favorite', [FavoriteSongController::class, 'index']);
|
||||||
|
|
||||||
|
|
|
@ -168,8 +168,8 @@ class MediaSyncServiceTest extends TestCase
|
||||||
|
|
||||||
// Song should be added back with all info
|
// Song should be added back with all info
|
||||||
self::assertEquals(
|
self::assertEquals(
|
||||||
Arr::except(Song::findOrFail($song->id)->toArray(), 'created_at'),
|
Arr::except(Song::where('path', $song->path)->first()->toArray(), ['id', 'created_at']),
|
||||||
Arr::except($song->toArray(), 'created_at')
|
Arr::except($song->toArray(), ['id', 'created_at'])
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue