Offline-tolerant music-video merge

The merge no longer refuses when NAS is unreachable. DB changes always
run; per-side file operations are best-effort. When the NAS rename can't
happen it's recorded in a new pending_nas_moves table, and nas:auto-sync
drains the queue (rename + best-effort parent prune) once NAS is back,
retaining attempts/last_error on failure. Also plugs missing isEnabled()
guards on renameNasPath and deleteFolder so the merge cleanup path can't
hang on smbclient timeouts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
ghassan 2026-07-31 03:27:52 +03:00
parent fcb752dd76
commit 847020fd02
4 changed files with 101 additions and 12 deletions

View File

@ -7,6 +7,7 @@ use App\Models\User;
use App\Models\Video;
use App\Services\NasSyncService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
class NasAutoSync extends Command
{
@ -19,6 +20,39 @@ class NasAutoSync extends Command
return 0; // NAS down or disabled — nothing to do
}
// Drain queued NAS file/folder moves recorded while NAS was down
// (e.g. music-video merges). Best-effort per row; failures stay
// in the table with attempts/last_error for later retries.
$drained = 0; $failed = 0;
DB::table('pending_nas_moves')->orderBy('id')->get()->each(
function ($row) use ($nas, &$drained, &$failed) {
try {
$nas->renameNasPath($row->from_path, $row->to_path);
// Best-effort prune of the now-empty source parent
// (e.g. the source video folder after all its track
// subfolders were adopted by the target).
$parent = dirname($row->from_path);
if ($parent && $parent !== '.') {
$nas->deleteFile("{$parent}/meta.json");
$nas->deleteFolder($parent);
}
DB::table('pending_nas_moves')->where('id', $row->id)->delete();
$drained++;
} catch (\Throwable $e) {
DB::table('pending_nas_moves')->where('id', $row->id)->update([
'attempts' => (int) $row->attempts + 1,
'last_attempt_at' => now(),
'last_error' => mb_substr($e->getMessage(), 0, 500),
'updated_at' => now(),
]);
$failed++;
}
}
);
if ($drained || $failed) {
$this->info("Drained {$drained} pending NAS move(s)" . ($failed ? " (${failed} failed)" : ''));
}
// Videos whose file OR thumbnail/slides are still on local disk
$synced = 0;
Video::with(['user', 'slides'])

View File

@ -1235,6 +1235,7 @@ class NasSyncService
public function deleteFolder(string $nasRelPath): void
{
if (! $this->isEnabled()) return; // fail-fast when NAS unreachable
$cfg = $this->cfg();
$target = escapeshellarg($this->smbTarget($cfg));
$cred = escapeshellarg($this->smbCredential($cfg));
@ -1335,6 +1336,7 @@ class NasSyncService
*/
public function renameNasPath(string $oldNasRelPath, string $newNasRelPath): void
{
if (! $this->isEnabled()) return; // fail-fast when NAS unreachable
$cfg = $this->cfg();
$target = escapeshellarg($this->smbTarget($cfg));
$cred = escapeshellarg($this->smbCredential($cfg));

View File

@ -59,12 +59,6 @@ class VideoMergeService
if ($source->type !== 'music' || $target->type !== 'music') {
throw new \InvalidArgumentException('Only music-type videos can be merged.');
}
if (!$this->nas->isEnabled()) {
throw new \RuntimeException(
'NAS is currently unreachable — merge is disabled until it comes back online.'
);
}
$source->loadMissing(['audioTracks']);
$sourceDir = $this->nas->resolveVideoDir($source);
@ -74,14 +68,22 @@ class VideoMergeService
throw new \RuntimeException('Source and target resolve to the same folder — refusing to merge.');
}
// Merge is DB-first: the schema changes always run, and file moves
// are best-effort on both sides. When NAS is unreachable, per-track
// NAS renames get queued in `pending_nas_moves` and drained by
// `nas:auto-sync` once NAS returns.
$nasReachable = $this->nas->isEnabled();
Log::info('Merge: begin', [
'source_id' => $source->id,
'target_id' => $target->id,
'source_dir' => $sourceDir,
'target_dir' => $targetDir,
'source_id' => $source->id,
'target_id' => $target->id,
'source_dir' => $sourceDir,
'target_dir' => $targetDir,
'nas_reachable' => $nasReachable,
]);
// Ensure target has a tracks/ subfolder on both NAS and local
// Ensure target has a tracks/ subfolder — always locally; on NAS
// only when it's reachable (mkdirp itself no-ops otherwise).
$this->nas->mkdirp("{$targetDir}/tracks");
@mkdir(storage_path("app/{$targetDir}/tracks"), 0755, true);
@ -202,7 +204,8 @@ class VideoMergeService
}
/**
* Move a folder on both NAS and local disk. Logs the outcome.
* Move a folder on both NAS and local disk. NAS ops are queued in
* pending_nas_moves when NAS is unreachable; nas:auto-sync drains them.
*/
private function moveFolder(string $oldRel, string $newRel): void
{
@ -212,6 +215,16 @@ class VideoMergeService
if ($this->nas->isEnabled()) {
$this->nas->renameNasPath($oldRel, $newRel);
$nasMoved = true;
} else {
// Queue for later — nas:auto-sync will do the rename when NAS is back.
DB::table('pending_nas_moves')->insert([
'from_path' => $oldRel,
'to_path' => $newRel,
'kind' => 'folder',
'origin' => 'merge',
'created_at' => now(),
'updated_at' => now(),
]);
}
$oldLocal = storage_path('app/' . $oldRel);
@ -224,6 +237,7 @@ class VideoMergeService
Log::info('Merge: moveFolder', [
'old' => $oldRel, 'new' => $newRel,
'nas' => $nasMoved, 'local' => $localMoved,
'queued' => !$nasMoved,
]);
}
@ -233,6 +247,12 @@ class VideoMergeService
$this->nas->deleteFile("{$rel}/meta.json");
$this->nas->deleteFolder($rel);
}
// If NAS was unreachable we DON'T queue a delete: after auto-sync
// drains the pending moves, the leftover source folder will be
// empty and can be reaped by a subsequent housekeeping pass (or by
// the videos:repair-merged command). Deleting a folder we haven't
// yet emptied would blow away data.
$local = storage_path('app/' . $rel);
if (is_dir($local)) {
@unlink($local . '/meta.json');

View File

@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
// Records file/folder moves that were queued while NAS was
// unreachable. `nas:auto-sync` drains rows when NAS is back:
// for each row it renames from → to on NAS and deletes the row.
Schema::create('pending_nas_moves', function (Blueprint $table) {
$table->id();
$table->string('from_path', 500);
$table->string('to_path', 500);
$table->string('kind', 24)->default('folder'); // 'folder' or 'file'
$table->string('origin', 40)->default('merge'); // where it came from
$table->unsignedInteger('attempts')->default(0);
$table->timestamp('last_attempt_at')->nullable();
$table->text('last_error')->nullable();
$table->timestamps();
$table->index(['origin', 'created_at']);
});
}
public function down(): void
{
Schema::dropIfExists('pending_nas_moves');
}
};