Add "watch" functionality (fix #213)

This commit is contained in:
An Phan 2016-02-02 15:47:00 +08:00
parent 3e6922f596
commit 46f6141fa8
8 changed files with 382 additions and 16 deletions

View file

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

View 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 [];
}
}

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

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

View file

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

View file

@ -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',
],
];
/**

View file

@ -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:

View file

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