$extensions Case-insensitive extensions without dots. * @throws ImportException SCAN_EMPTY, DISK_FULL, SPACE_CHECK_FAILED, DB_ERROR. */ public function import(string $sourcePath, array $extensions = FileEnumeratorInterface::DEFAULT_EXTENSIONS): int { $scan = $this->enumerator->enumerate($sourcePath, $extensions); if ($scan->files === []) { $detail = $scan->errors === [] ? 'No importable files were found.' : $scan->errors[0]->message; throw new ImportException('SCAN_EMPTY', $detail); } // Preflight before writing any row so a full disk never leaves a job behind. $this->space->check($this->libraryRoot, $scan->totalBytes); try { return $this->jobs->atomic( fn (): int => $this->jobs->create($sourcePath, $this->manifest($sourcePath, $scan)) ); } catch (ImportException $error) { throw $error; } catch (Throwable $error) { throw new ImportException('DB_ERROR', 'Could not persist the import job.', $error); } } /** @return list> */ private function manifest(string $sourcePath, ScanResult $scan): array { $items = []; foreach ($scan->files as $file) { $items[] = [ 'source_path' => $file->sourcePath, 'size' => $file->size, 'model_id' => $this->groupingAdapter->modelFor($sourcePath, $file), ]; } // Scan failures become terminal items so the job reports them durably. foreach ($scan->errors as $error) { $items[] = [ 'source_path' => $error->sourcePath, 'size' => 0, 'model_id' => null, 'status' => 'Failed', 'failure_reason' => $error->code . ': ' . $error->message, ]; } return $items; } }