diff --git a/app/Console/Commands/NasFreeLocalStorage.php b/app/Console/Commands/NasFreeLocalStorage.php
new file mode 100644
index 0000000..ab1e8bf
--- /dev/null
+++ b/app/Console/Commands/NasFreeLocalStorage.php
@@ -0,0 +1,146 @@
+isEnabled()) {
+ $this->error('NAS sync is not enabled. Enable it in Admin → Settings first.');
+ return 1;
+ }
+
+ $dryRun = $this->option('dry-run');
+ $force = $this->option('force');
+
+ if (! $dryRun && ! $force) {
+ $this->warn('Pass --dry-run to preview, or --force to delete.');
+ return 1;
+ }
+
+ $mode = $dryRun ? 'DRY RUN — nothing will be deleted' : 'FORCE — deleting confirmed NAS files';
+ $this->info($mode);
+ $this->newLine();
+
+ // Only videos that still have a local file
+ $videos = Video::all()->filter(function (Video $v) {
+ return file_exists(storage_path('app/' . $v->path));
+ });
+
+ if ($videos->isEmpty()) {
+ $this->info('No local video files found — nothing to do.');
+ return 0;
+ }
+
+ $this->info("Checking {$videos->count()} local file(s) against NAS…");
+ $this->newLine();
+
+ $toDelete = [];
+ $totalBytes = 0;
+ $bar = $this->output->createProgressBar($videos->count());
+ $bar->start();
+
+ foreach ($videos as $video) {
+ $localPath = storage_path('app/' . $video->path);
+
+ // meta.json is written last in syncVideo(), so its presence means
+ // the video file was fully pushed to NAS.
+ $dir = $nas->resolveVideoDir($video);
+ $meta = null;
+ try {
+ $raw = $nas->getContent("{$dir}/meta.json");
+ $meta = $raw ? json_decode($raw, true) : null;
+ } catch (\Throwable) {
+ // SMB error — skip this file
+ }
+
+ if (is_array($meta) && ($meta['id'] ?? null) === $video->id) {
+ $bytes = filesize($localPath);
+ $totalBytes += $bytes;
+ $toDelete[] = [
+ 'video' => $video,
+ 'path' => $localPath,
+ 'bytes' => $bytes,
+ ];
+ }
+
+ $bar->advance();
+ }
+
+ $bar->finish();
+ $this->newLine(2);
+
+ if (empty($toDelete)) {
+ $this->info('No local files confirmed on NAS — nothing to delete.');
+ return 0;
+ }
+
+ $this->table(
+ ['ID', 'Title', 'Local file', 'Size'],
+ array_map(fn ($row) => [
+ $row['video']->id,
+ \Illuminate\Support\Str::limit($row['video']->title, 40),
+ basename($row['path']),
+ $this->humanBytes($row['bytes']),
+ ], $toDelete)
+ );
+
+ $this->newLine();
+ $this->line(sprintf(
+ 'Found %d file(s) totalling %s that can be freed.',
+ count($toDelete),
+ $this->humanBytes($totalBytes)
+ ));
+
+ if ($dryRun) {
+ $this->newLine();
+ $this->warn('Run with --force to delete these files.');
+ return 0;
+ }
+
+ // ── Actually delete ───────────────────────────────────────────────────
+ $deleted = 0;
+ $failed = 0;
+
+ foreach ($toDelete as $row) {
+ if (@unlink($row['path'])) {
+ $deleted++;
+ Log::info('nas:free-local: deleted ' . $row['path'], ['video_id' => $row['video']->id]);
+ } else {
+ $failed++;
+ $this->warn("Could not delete: {$row['path']}");
+ }
+ }
+
+ $this->newLine();
+ $this->info("Deleted {$deleted} file(s), freed {$this->humanBytes($totalBytes)}.");
+
+ if ($failed > 0) {
+ $this->warn("{$failed} file(s) could not be deleted — check permissions.");
+ }
+
+ return 0;
+ }
+
+ // ── Helpers ───────────────────────────────────────────────────────────────
+
+ private function humanBytes(int $bytes): string
+ {
+ if ($bytes >= 1_073_741_824) return round($bytes / 1_073_741_824, 2) . ' GB';
+ if ($bytes >= 1_048_576) return round($bytes / 1_048_576, 2) . ' MB';
+ if ($bytes >= 1_024) return round($bytes / 1_024, 2) . ' KB';
+ return $bytes . ' B';
+ }
+}