mirror of
https://github.com/koel/koel
synced 2024-11-10 06:34:14 +00:00
Add "watch" functionality (fix #213)
This commit is contained in:
parent
3e6922f596
commit
46f6141fa8
8 changed files with 382 additions and 16 deletions
|
@ -13,7 +13,8 @@ class SyncMedia extends Command
|
|||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'koel:sync';
|
||||
protected $signature = 'koel:sync
|
||||
{record? : A single fswatch record. Consult Wiki for more info.}';
|
||||
|
||||
protected $ignored = 0;
|
||||
protected $invalid = 0;
|
||||
|
@ -47,6 +48,20 @@ class SyncMedia extends Command
|
|||
return;
|
||||
}
|
||||
|
||||
if (!$record = $this->argument('record')) {
|
||||
$this->syncAll();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->syngle($record);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync all files in the configured media path.
|
||||
*/
|
||||
protected function syncAll()
|
||||
{
|
||||
$this->info('Koel syncing started. All we need now is just a little patience…');
|
||||
|
||||
Media::sync(null, $this);
|
||||
|
@ -56,6 +71,24 @@ class SyncMedia extends Command
|
|||
."and <comment>{$this->invalid} invalid file(s)</comment>.");
|
||||
}
|
||||
|
||||
/**
|
||||
* SYNc a sinGLE file or directory. See my awesome pun?
|
||||
*
|
||||
* @param string|FSWatchRecord $record An fswatch record, in this format:
|
||||
* "<changed_path> <event_flag_1>::<event_flag_2>::<event_flag_n>"
|
||||
* The fswatch command should look like this:
|
||||
* ``` bash
|
||||
* $ fswatch -0x --event-flag-separator="::" $MEDIA_PATH \
|
||||
* | xargs -0 -n1 -I record php artisan koel:sync record
|
||||
* ```
|
||||
*
|
||||
* @link https://github.com/emcrisostomo/fswatch/wiki/How-to-Use-fswatch
|
||||
*/
|
||||
public function syngle($record)
|
||||
{
|
||||
Media::syncFSWatchRecord($record, $this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a song's sync status to console.
|
||||
*/
|
||||
|
|
23
app/Events/LibraryChanged.php
Normal file
23
app/Events/LibraryChanged.php
Normal file
|
@ -0,0 +1,23 @@
|
|||
<?php
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
class LibraryChanged extends Event
|
||||
{
|
||||
/**
|
||||
* Create a new event instance.
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the channels the event should be broadcast on.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function broadcastOn()
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
101
app/Helpers/FSWatchRecord.php
Normal file
101
app/Helpers/FSWatchRecord.php
Normal file
|
@ -0,0 +1,101 @@
|
|||
<?php
|
||||
|
||||
namespace App\Helpers;
|
||||
|
||||
class FSWatchRecord
|
||||
{
|
||||
/**
|
||||
* The event separator used in our fswatch command.
|
||||
*/
|
||||
const FSWATCH_FLAG_SEPARATOR = '::';
|
||||
|
||||
/**
|
||||
* Path of the file/directory that triggers the fswatch event.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $path;
|
||||
|
||||
/**
|
||||
* The flags of the fswatch event.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $eventFlags;
|
||||
|
||||
/**
|
||||
* Construct an FSWatchRecord object for a record string.
|
||||
*
|
||||
* @param string $string The record string, e.g.
|
||||
* "/full/path/to/changed/file Renamed::IsFile"
|
||||
*/
|
||||
public function __construct($string)
|
||||
{
|
||||
$parts = explode(' ', $string);
|
||||
$this->eventFlags = explode(self::FSWATCH_FLAG_SEPARATOR, array_pop($parts));
|
||||
$this->path = implode(' ', $parts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if the file/directory is deleted from the system.
|
||||
* We can't rely on fswatch, since the event is OS-dependent.
|
||||
* For example, deleting on OSX will be reported as "Renamed", as
|
||||
* the file/directory is "renamed" into the Trash folder.
|
||||
*
|
||||
* @return boolean
|
||||
*/
|
||||
public function isDeleted()
|
||||
{
|
||||
return !file_exists($this->path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if the object is renamed.
|
||||
*
|
||||
* @return boolean
|
||||
*/
|
||||
public function isRenamed()
|
||||
{
|
||||
return in_array('Renamed', $this->eventFlags);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if the changed object is a file.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function isFile()
|
||||
{
|
||||
return in_array('IsFile', $this->eventFlags);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if the changed object is a directory.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function isDir()
|
||||
{
|
||||
return in_array('IsDir', $this->eventFlags);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the full path of the changed file/directory.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function getPath()
|
||||
{
|
||||
return $this->path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the event flags of the fswatch record.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function getEventFlags()
|
||||
{
|
||||
return $this->eventFlags;
|
||||
}
|
||||
}
|
32
app/Listeners/TidyLibrary.php
Normal file
32
app/Listeners/TidyLibrary.php
Normal file
|
@ -0,0 +1,32 @@
|
|||
<?php
|
||||
|
||||
namespace App\Listeners;
|
||||
|
||||
use App\Models\Album;
|
||||
use App\Models\Artist;
|
||||
use App\Models\Song;
|
||||
|
||||
class TidyLibrary
|
||||
{
|
||||
/**
|
||||
* Create the event listener.
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Fired every time a LibraryChanged event is triggered.
|
||||
* Remove empty albums and artists from our system.
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
$inUseAlbums = Song::select('album_id')->groupBy('album_id')->get()->lists('album_id');
|
||||
$inUseAlbums[] = Album::UNKNOWN_ID;
|
||||
Album::whereNotIn('id', $inUseAlbums)->delete();
|
||||
|
||||
$inUseArtists = Album::select('artist_id')->groupBy('artist_id')->get()->lists('artist_id');
|
||||
$inUseArtists[] = Artist::UNKNOWN_ID;
|
||||
Artist::whereNotIn('id', $inUseArtists)->delete();
|
||||
}
|
||||
}
|
|
@ -4,6 +4,7 @@ namespace App\Models;
|
|||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Lastfm;
|
||||
use Media;
|
||||
|
||||
/**
|
||||
* @property string path
|
||||
|
@ -49,7 +50,7 @@ class Song extends Model
|
|||
* Scrobble the song using Last.fm service.
|
||||
*
|
||||
* @param string $timestamp The UNIX timestamp in which the song started playing.
|
||||
*
|
||||
*
|
||||
* @return mixed
|
||||
*/
|
||||
public function scrobble($timestamp)
|
||||
|
@ -73,6 +74,34 @@ class Song extends Model
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a Song record using its path.
|
||||
*
|
||||
* @param string $path
|
||||
*
|
||||
* @return Song|null
|
||||
*/
|
||||
public static function byPath($path)
|
||||
{
|
||||
return self::find(Media::getHash($path));
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope a query to only include songs in a given directory.
|
||||
*
|
||||
* @param \Illuminate\Database\Eloquent\Builder $query
|
||||
* @param string $path Full path of the directory
|
||||
*
|
||||
* @return \Illuminate\Database\Eloquent\Builder
|
||||
*/
|
||||
public function scopeInDirectory($query, $path)
|
||||
{
|
||||
// Make sure the path ends with a directory separator.
|
||||
$path = rtrim(trim($path), DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR;
|
||||
|
||||
return $query->where('path', 'LIKE', "$path%");
|
||||
}
|
||||
|
||||
/**
|
||||
* Sometimes the tags extracted from getID3 are HTML entity encoded.
|
||||
* This makes sure they are always sane.
|
||||
|
@ -87,7 +116,7 @@ class Song extends Model
|
|||
/**
|
||||
* Some songs don't have a title.
|
||||
* Fall back to the file name (without extension) for such.
|
||||
*
|
||||
*
|
||||
* @param $value
|
||||
*
|
||||
* @return string
|
||||
|
|
|
@ -19,9 +19,14 @@ class EventServiceProvider extends ServiceProvider
|
|||
'App\Events\SongLikeToggled' => [
|
||||
'App\Listeners\LoveTrackOnLastfm',
|
||||
],
|
||||
|
||||
'App\Events\SongStartedPlaying' => [
|
||||
'App\Listeners\UpdateLastfmNowPlaying',
|
||||
],
|
||||
|
||||
'App\Events\LibraryChanged' => [
|
||||
'App\Listeners\TidyLibrary',
|
||||
],
|
||||
];
|
||||
|
||||
/**
|
||||
|
|
|
@ -7,12 +7,14 @@ use App\Models\Album;
|
|||
use App\Models\Artist;
|
||||
use App\Models\Setting;
|
||||
use App\Models\Song;
|
||||
use App\Events\LibraryChanged;
|
||||
use App\Helpers\FSWatchRecord;
|
||||
use Exception;
|
||||
use getID3;
|
||||
use getid3_lib;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Symfony\Component\Finder\Finder;
|
||||
use Symfony\Component\Finder\SplFileInfo;
|
||||
use SplFileInfo;
|
||||
|
||||
class Media
|
||||
{
|
||||
|
@ -46,9 +48,7 @@ class Media
|
|||
'ugly' => [], // Unmodified files
|
||||
];
|
||||
|
||||
$files = Finder::create()->files()->name('/\.(mp3|ogg|m4a|flac)$/i')->in($path);
|
||||
|
||||
foreach ($files as $file) {
|
||||
foreach ($this->gatherFiles($path) as $file) {
|
||||
$song = $this->syncFile($file);
|
||||
|
||||
if ($song === true) {
|
||||
|
@ -71,27 +71,37 @@ class Media
|
|||
|
||||
Song::whereNotIn('id', $hashes)->delete();
|
||||
|
||||
// Empty albums and artists should be gone as well.
|
||||
$inUseAlbums = Song::select('album_id')->groupBy('album_id')->get()->lists('album_id');
|
||||
$inUseAlbums[] = Album::UNKNOWN_ID;
|
||||
Album::whereNotIn('id', $inUseAlbums)->delete();
|
||||
// Trigger LibraryChanged, so that TidyLibrary handler is fired to, erm, tidy our library.
|
||||
event(new LibraryChanged());
|
||||
}
|
||||
|
||||
$inUseArtists = Album::select('artist_id')->groupBy('artist_id')->get()->lists('artist_id');
|
||||
$inUseArtists[] = Artist::UNKNOWN_ID;
|
||||
Artist::whereNotIn('id', $inUseArtists)->delete();
|
||||
/**
|
||||
* Gather all applicable files in a given directory.
|
||||
*
|
||||
* @param string $path The directory's full path
|
||||
*
|
||||
* @return array An array of SplFileInfo objects
|
||||
*/
|
||||
public function gatherFiles($path)
|
||||
{
|
||||
return Finder::create()->files()->name('/\.(mp3|ogg|m4a|flac)$/i')->in($path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync a song with all available media info against the database.
|
||||
*
|
||||
* @param SplFileInfo $file The SplFileInfo instance of the file.
|
||||
* @param SplFileInfo|string $file The SplFileInfo instance of the file, or the file path.
|
||||
*
|
||||
* @return bool|Song A Song object on success,
|
||||
* true if file exists but is unmodified,
|
||||
* or false on an error.
|
||||
*/
|
||||
public function syncFile(SplFileInfo $file)
|
||||
public function syncFile($file)
|
||||
{
|
||||
if (!($file instanceof SplFileInfo)) {
|
||||
$file = new SplFileInfo($file);
|
||||
}
|
||||
|
||||
if (!$info = $this->getInfo($file)) {
|
||||
return false;
|
||||
}
|
||||
|
@ -123,6 +133,74 @@ class Media
|
|||
return $song;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync media using an fswatch record.
|
||||
*
|
||||
* @param string|FSWatchRecord $record The fswatch record, in this format:
|
||||
* "<changed_path> <event_flag_1>::<event_flag_2>::<event_flag_n>"
|
||||
* The fswatch command should look like this:
|
||||
* ``` bash
|
||||
* $ fswatch -0x --event-flag-separator="::" $MEDIA_PATH \
|
||||
* | xargs -0 -n1 -I record php artisan koel:sync record
|
||||
* ```
|
||||
* @param SyncMedia|null $syncCommand The SyncMedia command object, to log to console if executed by artisan.
|
||||
*/
|
||||
public function syncFSWatchRecord($record, SyncMedia $syncCommand = null)
|
||||
{
|
||||
if (!($record instanceof FSWatchRecord)) {
|
||||
$record = new FSWatchRecord($record);
|
||||
}
|
||||
|
||||
$path = $record->getPath();
|
||||
|
||||
if ($record->isFile()) {
|
||||
// If the file has been deleted...
|
||||
if ($record->isDeleted()) {
|
||||
// ...and it has a record in our database, remove it.
|
||||
if ($song = Song::byPath($path)) {
|
||||
$song->delete();
|
||||
|
||||
Log::info("Deleted $path");
|
||||
|
||||
event(new LibraryChanged());
|
||||
}
|
||||
}
|
||||
// Otherwise, it's a new or changed file. Try to sync it in.
|
||||
// File format etc. will be handled by the syncFile method.
|
||||
else {
|
||||
Log::info("Syncing file $path");
|
||||
Log::info($this->syncFile($path) instanceof Song ? "Synchronized $path" : "Invalid file $path");
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($record->isDir()) {
|
||||
if ($record->isDeleted()) {
|
||||
// A whole directory is removed.
|
||||
// We remove all songs in it.
|
||||
Song::inDirectory($path)->delete();
|
||||
|
||||
Log::info("Deleted all song(s) under $path");
|
||||
|
||||
event(new LibraryChanged());
|
||||
} elseif ($record->isRenamed()) {
|
||||
foreach ($this->gatherFiles($path) as $file) {
|
||||
$this->syncFile($file);
|
||||
}
|
||||
|
||||
Log::info("Synced all song(s) under $path");
|
||||
} else {
|
||||
// "New directory" fswatch event actually comes with individual "new file" events,
|
||||
// which should already be handled by our logic above.
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// The changed item is a symlink maybe. But we're not doing anything with it.
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a media file is new or changed.
|
||||
* A file is considered existing and unchanged only when:
|
||||
|
|
|
@ -1,17 +1,27 @@
|
|||
<?php
|
||||
|
||||
use App\Events\LibraryChanged;
|
||||
use App\Helpers\FSWatchRecord;
|
||||
use App\Models\Album;
|
||||
use App\Models\Song;
|
||||
use App\Services\Media;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Foundation\Testing\WithoutMiddleware;
|
||||
use Mockery as m;
|
||||
|
||||
class MediaTest extends TestCase
|
||||
{
|
||||
use DatabaseTransactions, WithoutMiddleware;
|
||||
|
||||
public function tearDown()
|
||||
{
|
||||
m::close();
|
||||
}
|
||||
|
||||
public function testSync()
|
||||
{
|
||||
$this->expectsEvents(LibraryChanged::class);
|
||||
|
||||
$media = new Media();
|
||||
$media->sync($this->mediaPath);
|
||||
|
||||
|
@ -54,4 +64,59 @@ class MediaTest extends TestCase
|
|||
// Albums with a non-default cover should have their covers overwritten
|
||||
$this->assertEquals($currentCover, Album::find($album->id)->cover);
|
||||
}
|
||||
|
||||
public function testWatchSingleFileAdded()
|
||||
{
|
||||
$path = $this->mediaPath.'/blank.mp3';
|
||||
|
||||
$record = m::mock(FSWatchRecord::class, [
|
||||
'isDeleted' => false,
|
||||
'getPath' => $path,
|
||||
'isFile' => true,
|
||||
], ["$path IsFile"]);
|
||||
|
||||
(new Media())->syncFSWatchRecord($record);
|
||||
|
||||
$this->seeInDatabase('songs', ['path' => $path]);
|
||||
}
|
||||
|
||||
public function testWatchSingleFileDeleted()
|
||||
{
|
||||
$this->expectsEvents(LibraryChanged::class);
|
||||
|
||||
$this->createSampleMediaSet();
|
||||
$song = Song::orderBy('id', 'desc')->first();
|
||||
|
||||
$record = m::mock(FSWatchRecord::class, [
|
||||
'isDeleted' => true,
|
||||
'getPath' => $song->path,
|
||||
'isFile' => true,
|
||||
], ["{$song->path} IsFile"]);
|
||||
|
||||
(new Media())->syncFSWatchRecord($record);
|
||||
|
||||
$this->notSeeInDatabase('songs', ['id' => $song->id]);
|
||||
}
|
||||
|
||||
public function testWatchDirectoryDeleted()
|
||||
{
|
||||
$this->expectsEvents(LibraryChanged::class);
|
||||
|
||||
$media = new Media();
|
||||
$media->sync($this->mediaPath);
|
||||
$path = $this->mediaPath.'/subdir';
|
||||
|
||||
$record = m::mock(FSWatchRecord::class, [
|
||||
'isDeleted' => true,
|
||||
'getPath' => $path,
|
||||
'isFile' => false,
|
||||
'isDir' => true,
|
||||
], ["$path IsDir"]);
|
||||
|
||||
$media->syncFSWatchRecord($record);
|
||||
|
||||
$this->notSeeInDatabase('songs', ['path' => $this->mediaPath.'/subdir/sic.mp3']);
|
||||
$this->notSeeInDatabase('songs', ['path' => $this->mediaPath.'/subdir/no-name.MP3']);
|
||||
$this->notSeeInDatabase('songs', ['path' => $this->mediaPath.'/subdir/back-in-black.mp3']);
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue