feat: use UUIDs for song IDs

This commit is contained in:
Phan An 2022-08-01 12:42:33 +02:00
parent f5f6aa0d7f
commit c4cffcc2e7
No known key found for this signature in database
GPG key ID: A81E4477F0BB6FDC
16 changed files with 128 additions and 114 deletions

View file

@ -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');
} }
} }

View file

@ -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';

View file

@ -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';

View file

@ -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);

View file

@ -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();
}
});
}
}

View 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();
}
});
}
}

View file

@ -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);
}
}

View file

@ -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);
} }
} }

View file

@ -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> */

View file

@ -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);
} }

View file

@ -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();
});
}
};

View file

@ -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 })
}) })

View file

@ -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(),

View file

@ -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')

View file

@ -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']);

View file

@ -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'])
); );
} }