Ditch fswatch for inotify

This commit is contained in:
An Phan 2016-02-04 23:04:53 +08:00
parent f10fc5fa7d
commit 5d690f272d
7 changed files with 198 additions and 169 deletions

View file

@ -74,19 +74,18 @@ class SyncMedia extends Command
/**
* 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
* ```
* @param string $record The watch record.
* As of current we only support inotifywait.
* Some examples:
* - "DELETE /var/www/media/gone.mp3"
* - "CLOSE_WRITE,CLOSE /var/www/media/new.mp3"
* - "MOVED_TO /var/www/media/new_dir"
*
* @link https://github.com/emcrisostomo/fswatch/wiki/How-to-Use-fswatch
* @see http://man7.org/linux/man-pages/man1/inotifywait.1.html
*/
public function syngle($record)
{
Media::syncFSWatchRecord($record, $this);
Media::syncByWatchRecord($record, $this);
}
/**

View file

@ -1,101 +0,0 @@
<?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,61 @@
<?php
namespace App\Libraries\WatchRecord;
class InotifyWatchRecord extends WatchRecord implements WatchRecordInterface
{
/**
* InotifyWatchRecord constructor.
* {@inheritdoc}
*
* @param $string
*/
public function __construct($string)
{
parent::__construct($string);
$this->parse($string);
}
/**
* Parse the inotifywait's output. The inotifywait command should be something like:
* $ inotifywait -rme move,close_write,delete --format "%e %w%f" $MEDIA_PATH
*
* @param $string string The output string.
*/
public function parse($string)
{
list($events, $this->path) = explode(' ', $string, 2);
$this->events = explode(',', $events);
}
/**
* Determine if the object has just been deleted or moved from our watched directory.
*
* @return bool
*/
public function isDeleted()
{
return $this->eventExists('DELETE') || $this->eventExists('MOVED_FROM');
}
/**
* Determine if the object has just been created or modified.
* For our purpose, we DON'T watch the CREATE event, because CLOSE_WRITE should be enough.
* Additionally, a MOVED_TO (occurred after the object has been moved/renamed to another location
* **under our watched directory**) should be considered as "modified" also.
*
* @return bool
*/
public function isNewOrModified()
{
return $this->eventExists('CLOSE_WRITE') || $this->eventExists('MOVED_TO');
}
/**
* {@inheritdoc}
*/
public function isDirectory()
{
return $this->eventExists('ISDIR');
}
}

View file

@ -0,0 +1,84 @@
<?php
namespace App\Libraries\WatchRecord;
class WatchRecord
{
/**
* Array of the occurred events.
*
* @var array
*/
protected $events;
/**
* Full path of the file/directory on which the event occurred.
*
* @var string
*/
protected $path;
/**
* The input of the watch record.
* For example, a Inotifywatch record should have an input similar to
* "DELETE /var/www/media/song.mp3".
*
* @var string
*/
protected $input;
/**
* WatchRecord constructor.
*
* @param $input string The output from a watcher command (which is an input for our script).
*/
public function __construct($input)
{
$this->input = $input;
}
/**
* Determine if the object is a directory.
*
* @return bool
*/
public function isDirectory()
{
return true;
}
/**
* @return string
*/
public function getPath()
{
return $this->path;
}
/**
* Determine if the object is a file.
*
* @return bool
*/
public function isFile()
{
return !$this->isDirectory();
}
/**
* Check if a given event name exists in the event array.
*
* @param $event string
*
* @return bool
*/
protected function eventExists($event)
{
return in_array($event, $this->events);
}
public function __toString()
{
return $this->input;
}
}

View file

@ -0,0 +1,18 @@
<?php
namespace App\Libraries\WatchRecord;
interface WatchRecordInterface
{
public function parse($string);
public function getPath();
public function isDeleted();
public function isNewOrModified();
public function isDirectory();
public function isFile();
}

View file

@ -3,12 +3,12 @@
namespace App\Services;
use App\Console\Commands\SyncMedia;
use App\Libraries\WatchRecord\WatchRecordInterface;
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;
@ -134,71 +134,59 @@ class Media
}
/**
* Sync media using an fswatch record.
* Sync media using a watch 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 WatchRecordInterface $record The watch record.
* @param SyncMedia|null $syncCommand The SyncMedia command object, to log to console if executed by artisan.
*/
public function syncFSWatchRecord($record, SyncMedia $syncCommand = null)
public function syncByWatchRecord(WatchRecordInterface $record, SyncMedia $syncCommand = null)
{
if (!($record instanceof FSWatchRecord)) {
$record = new FSWatchRecord($record);
}
Log::info("New watch record received: $record");
$path = $record->getPath();
if ($record->isFile()) {
Log::info("$record is a file.");
// 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");
Log::info("$path deleted.");
event(new LibraryChanged());
} else {
Log::info("$path doesn't exist in our database--skipping.");
}
}
// 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");
elseif ($record->isNewOrModified()) {
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");
// Record is a directory.
Log::info("$record is a directory.");
if ($record->isDeleted()) {
// The directory is removed. We remove all songs in it.
if ($count = Song::inDirectory($path)->delete()) {
Log::info("Deleted $$count 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.
Log::info("$path is empty--no action needed.");
}
} elseif ($record->isNewOrModified()) {
foreach ($this->gatherFiles($path) as $file) {
$this->syncFile($file);
}
return;
Log::info("Synced all song(s) under $path");
}
// The changed item is a symlink maybe. But we're not doing anything with it.
}
/**

View file

@ -1,7 +1,7 @@
<?php
use App\Events\LibraryChanged;
use App\Helpers\FSWatchRecord;
use App\Libraries\WatchRecord\InotifyWatchRecord;
use App\Models\Album;
use App\Models\Song;
use App\Services\Media;
@ -69,13 +69,7 @@ class MediaTest extends TestCase
{
$path = $this->mediaPath.'/blank.mp3';
$record = m::mock(FSWatchRecord::class, [
'isDeleted' => false,
'getPath' => $path,
'isFile' => true,
], ["$path IsFile"]);
(new Media())->syncFSWatchRecord($record);
(new Media())->syncByWatchRecord(new InotifyWatchRecord("CLOSE_WRITE,CLOSE $path"));
$this->seeInDatabase('songs', ['path' => $path]);
}
@ -87,13 +81,7 @@ class MediaTest extends TestCase
$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);
(new Media())->syncByWatchRecord(new InotifyWatchRecord("DELETE {$song->path}"));
$this->notSeeInDatabase('songs', ['id' => $song->id]);
}
@ -104,16 +92,8 @@ class MediaTest extends TestCase
$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);
$media->syncByWatchRecord(new InotifyWatchRecord("MOVED_FROM,ISDIR {$this->mediaPath}/subdir"));
$this->notSeeInDatabase('songs', ['path' => $this->mediaPath.'/subdir/sic.mp3']);
$this->notSeeInDatabase('songs', ['path' => $this->mediaPath.'/subdir/no-name.MP3']);