diff --git a/app/Console/Commands/NasAutoSync.php b/app/Console/Commands/NasAutoSync.php index 8e7c2a8..ca41aa2 100644 --- a/app/Console/Commands/NasAutoSync.php +++ b/app/Console/Commands/NasAutoSync.php @@ -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']) diff --git a/app/Services/NasSyncService.php b/app/Services/NasSyncService.php index 18d8fa5..ec0e9ce 100644 --- a/app/Services/NasSyncService.php +++ b/app/Services/NasSyncService.php @@ -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)); diff --git a/app/Services/VideoMergeService.php b/app/Services/VideoMergeService.php index de9f887..a80f1c8 100644 --- a/app/Services/VideoMergeService.php +++ b/app/Services/VideoMergeService.php @@ -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'); diff --git a/database/migrations/2026_07_31_000003_create_pending_nas_moves_table.php b/database/migrations/2026_07_31_000003_create_pending_nas_moves_table.php new file mode 100644 index 0000000..89a822b --- /dev/null +++ b/database/migrations/2026_07_31_000003_create_pending_nas_moves_table.php @@ -0,0 +1,33 @@ +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'); + } +};