mirror of
https://github.com/koel/koel
synced 2024-11-10 06:34:14 +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\Models\Song;
|
||||
use App\Repositories\SongRepository;
|
||||
use App\Services\Helper;
|
||||
use App\Values\SyncResult;
|
||||
|
||||
class DeleteNonExistingRecordsPostSync
|
||||
|
@ -16,12 +15,12 @@ class DeleteNonExistingRecordsPostSync
|
|||
|
||||
public function handle(MediaSyncCompleted $event): void
|
||||
{
|
||||
$hashes = $event->results
|
||||
$paths = $event->results
|
||||
->valid()
|
||||
->map(static fn (SyncResult $result) => Helper::getFileHash($result->path))
|
||||
->merge($this->songRepository->getAllHostedOnS3()->pluck('id'))
|
||||
->map(static fn (SyncResult $result) => $result->path)
|
||||
->merge($this->songRepository->getAllHostedOnS3()->pluck('path'))
|
||||
->toArray();
|
||||
|
||||
Song::deleteWhereIDsNotIn($hashes);
|
||||
Song::deleteWhereValueNotIn($paths, 'path');
|
||||
}
|
||||
}
|
||||
|
|
|
@ -47,7 +47,7 @@ class Album extends Model
|
|||
{
|
||||
use HasFactory;
|
||||
use Searchable;
|
||||
use SupportsDeleteWhereIDsNotIn;
|
||||
use SupportsDeleteWhereValueNotIn;
|
||||
|
||||
public const UNKNOWN_ID = 1;
|
||||
public const UNKNOWN_NAME = 'Unknown Album';
|
||||
|
|
|
@ -43,7 +43,7 @@ class Artist extends Model
|
|||
{
|
||||
use HasFactory;
|
||||
use Searchable;
|
||||
use SupportsDeleteWhereIDsNotIn;
|
||||
use SupportsDeleteWhereValueNotIn;
|
||||
|
||||
public const UNKNOWN_ID = 1;
|
||||
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\Query\JoinClause;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Str;
|
||||
use Laravel\Scout\Searchable;
|
||||
|
||||
/**
|
||||
|
@ -49,9 +50,11 @@ class Song extends Model
|
|||
{
|
||||
use HasFactory;
|
||||
use Searchable;
|
||||
use SupportsDeleteWhereIDsNotIn;
|
||||
use SupportsDeleteWhereValueNotIn;
|
||||
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;
|
||||
protected $guarded = [];
|
||||
|
||||
|
@ -66,6 +69,11 @@ class Song extends Model
|
|||
|
||||
protected $keyType = 'string';
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::creating(static fn (self $song) => $song->id = Str::uuid()->toString());
|
||||
}
|
||||
|
||||
public function artist(): BelongsTo
|
||||
{
|
||||
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\UpdateLastfmNowPlaying;
|
||||
use App\Models\Album;
|
||||
use App\Models\Song;
|
||||
use App\Observers\AlbumObserver;
|
||||
use App\Observers\SongObserver;
|
||||
use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;
|
||||
use Illuminate\Foundation\Support\Providers\EventServiceProvider as BaseServiceProvider;
|
||||
|
||||
class EventServiceProvider extends ServiceProvider
|
||||
class EventServiceProvider extends BaseServiceProvider
|
||||
{
|
||||
protected $listen = [
|
||||
SongLikeToggled::class => [
|
||||
|
@ -54,7 +52,6 @@ class EventServiceProvider extends ServiceProvider
|
|||
{
|
||||
parent::boot();
|
||||
|
||||
Song::observe(SongObserver::class);
|
||||
Album::observe(AlbumObserver::class);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -8,7 +8,6 @@ use App\Models\Playlist;
|
|||
use App\Models\Song;
|
||||
use App\Models\User;
|
||||
use App\Repositories\Traits\Searchable;
|
||||
use App\Services\Helper;
|
||||
use Illuminate\Contracts\Database\Query\Builder;
|
||||
use Illuminate\Contracts\Pagination\Paginator;
|
||||
use Illuminate\Support\Collection;
|
||||
|
@ -32,7 +31,7 @@ class SongRepository extends Repository
|
|||
|
||||
public function getOneByPath(string $path): ?Song
|
||||
{
|
||||
return $this->getOneById(Helper::getFileHash($path));
|
||||
return Song::where('path', $path)->first();
|
||||
}
|
||||
|
||||
/** @return Collection|array<Song> */
|
||||
|
|
|
@ -20,12 +20,6 @@ class FileSynchronizer
|
|||
private ?int $fileModifiedTime = 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.
|
||||
*/
|
||||
|
@ -46,9 +40,8 @@ class FileSynchronizer
|
|||
{
|
||||
$file = $path instanceof SplFileInfo ? $path : new SplFileInfo($path);
|
||||
|
||||
$this->filePath = $file->getPathname();
|
||||
$this->fileHash = Helper::getFileHash($this->filePath);
|
||||
$this->song = $this->songRepository->getOneById($this->fileHash); // @phpstan-ignore-line
|
||||
$this->filePath = $file->getRealPath();
|
||||
$this->song = $this->songRepository->getOneByPath($this->filePath);
|
||||
$this->fileModifiedTime = Helper::getModifiedTime($file);
|
||||
|
||||
return $this;
|
||||
|
@ -96,7 +89,7 @@ class FileSynchronizer
|
|||
$data['album_id'] = $album->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);
|
||||
}
|
||||
|
|
|
@ -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'
|
||||
|
||||
export default (faker: Faker): Interaction => ({
|
||||
type: 'interactions',
|
||||
id: faker.datatype.number({ min: 1 }),
|
||||
song_id: crypto(32),
|
||||
song_id: faker.datatype.uuid(),
|
||||
liked: faker.datatype.boolean(),
|
||||
play_count: faker.datatype.number({ min: 1 })
|
||||
})
|
||||
|
|
|
@ -1,4 +1,3 @@
|
|||
import crypto from 'crypto-random-string'
|
||||
import { Faker, faker } from '@faker-js/faker'
|
||||
|
||||
const generate = (partOfCompilation = false): Song => {
|
||||
|
@ -14,7 +13,7 @@ const generate = (partOfCompilation = false): Song => {
|
|||
album_artist_id: partOfCompilation ? artistId + 1 : artistId,
|
||||
album_artist_name: partOfCompilation ? artistName : faker.name.findName(),
|
||||
album_cover: faker.image.imageUrl(),
|
||||
id: crypto(32),
|
||||
id: faker.datatype.uuid(),
|
||||
title: faker.lorem.sentence(),
|
||||
length: faker.datatype.number(),
|
||||
track: faker.datatype.number(),
|
||||
|
|
|
@ -26,7 +26,7 @@ class Router {
|
|||
'/album/(\\d+)': async (id: string) => loadMainView('Album', parseInt(id)),
|
||||
'/artist/(\\d+)': async (id: string) => loadMainView('Artist', parseInt(id)),
|
||||
'/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)
|
||||
if (!song) {
|
||||
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\SongController;
|
||||
use App\Http\Controllers\V6\API\SongSearchController;
|
||||
use App\Models\Song;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
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::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/favorite', [FavoriteSongController::class, 'index']);
|
||||
|
||||
|
|
|
@ -168,8 +168,8 @@ class MediaSyncServiceTest extends TestCase
|
|||
|
||||
// Song should be added back with all info
|
||||
self::assertEquals(
|
||||
Arr::except(Song::findOrFail($song->id)->toArray(), 'created_at'),
|
||||
Arr::except($song->toArray(), 'created_at')
|
||||
Arr::except(Song::where('path', $song->path)->first()->toArray(), ['id', 'created_at']),
|
||||
Arr::except($song->toArray(), ['id', 'created_at'])
|
||||
);
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in a new issue