mirror of
https://github.com/koel/koel
synced 2024-11-10 06:34:14 +00:00
Ditch fswatch for inotify
This commit is contained in:
parent
f10fc5fa7d
commit
5d690f272d
7 changed files with 198 additions and 169 deletions
|
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
61
app/Libraries/WatchRecord/InotifyWatchRecord.php
Normal file
61
app/Libraries/WatchRecord/InotifyWatchRecord.php
Normal 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');
|
||||
}
|
||||
}
|
84
app/Libraries/WatchRecord/WatchRecord.php
Normal file
84
app/Libraries/WatchRecord/WatchRecord.php
Normal 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;
|
||||
}
|
||||
}
|
18
app/Libraries/WatchRecord/WatchRecordInterface.php
Normal file
18
app/Libraries/WatchRecord/WatchRecordInterface.php
Normal 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();
|
||||
}
|
|
@ -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.
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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']);
|
||||
|
|
Loading…
Reference in a new issue