| <?php |
|
|
| namespace App\Services; |
|
|
| use Illuminate\Support\Facades\Log; |
| use Illuminate\Support\Facades\Storage; |
| use Illuminate\Support\Str; |
| use RuntimeException; |
|
|
| class HuggingFaceMediaStorageService |
| { |
| public function offloadPublicRelativePath(string $relativePath): string |
| { |
| $cleanRelativePath = ltrim(trim($relativePath), '/'); |
| if ($cleanRelativePath === '') { |
| throw new RuntimeException('Path media kosong, tidak bisa di-offload ke Hugging Face.'); |
| } |
|
|
| if (! $this->isEnabled()) { |
| return Storage::url($cleanRelativePath); |
| } |
|
|
| $disk = Storage::disk('public'); |
| if (! $disk->exists($cleanRelativePath)) { |
| return Storage::url($cleanRelativePath); |
| } |
|
|
| $token = $this->token(); |
| $driver = $this->driver(); |
| $repoId = $this->repoId(); |
| $repoType = $this->repoType(); |
| $bucketId = $this->bucketId(); |
|
|
| if ( |
| $token === '' |
| || ($driver === 'repo' && $repoId === '') |
| || ($driver === 'bucket' && $bucketId === '') |
| ) { |
| Log::warning('HF media offload dilewati karena konfigurasi belum lengkap.', [ |
| 'driver' => $driver, |
| 'has_token' => $token !== '', |
| 'repo_id' => $repoId, |
| 'bucket_id' => $bucketId, |
| ]); |
|
|
| return Storage::url($cleanRelativePath); |
| } |
|
|
| $absolutePath = $disk->path($cleanRelativePath); |
| $pathInfo = pathinfo($cleanRelativePath); |
| $extension = strtolower((string) ($pathInfo['extension'] ?? '')); |
| $typeFolder = Str::startsWith($cleanRelativePath, 'videos/') ? 'videos' : 'images'; |
| $dateFolder = now()->format('Y/m/d'); |
| $filename = ($pathInfo['filename'] ?? 'asset').($extension !== '' ? '.'.$extension : ''); |
| $remotePath = trim($this->remotePrefix().'/'.$typeFolder.'/'.$dateFolder.'/'.$filename, '/'); |
|
|
| $hfBinary = $this->hfBinary(); |
| $command = $driver === 'bucket' |
| ? $this->buildBucketUploadCommand($hfBinary, $bucketId, $absolutePath, $remotePath, $token) |
| : $this->buildRepoUploadCommand($hfBinary, $repoId, $absolutePath, $remotePath, $repoType, $token); |
|
|
| $outputLines = []; |
| $exitCode = 1; |
| @exec($command.' 2>&1', $outputLines, $exitCode); |
|
|
| if ($exitCode !== 0) { |
| Log::error('HF media offload gagal.', [ |
| 'relative_path' => $cleanRelativePath, |
| 'command' => $command, |
| 'exit_code' => $exitCode, |
| 'output' => implode("\n", $outputLines), |
| ]); |
|
|
| return Storage::url($cleanRelativePath); |
| } |
|
|
| if ($this->deleteLocalAfterOffload() && $disk->exists($cleanRelativePath)) { |
| $disk->delete($cleanRelativePath); |
| } |
|
|
| return $driver === 'bucket' |
| ? $this->resolveBucketUrl($bucketId, $remotePath) |
| : $this->resolveRepoUrl($repoType, $repoId, $remotePath); |
| } |
|
|
| private function buildRepoUploadCommand( |
| string $binary, |
| string $repoId, |
| string $absolutePath, |
| string $remotePath, |
| string $repoType, |
| string $token |
| ): string { |
| $arguments = [ |
| $binary, |
| 'upload', |
| $repoId, |
| $absolutePath, |
| $remotePath, |
| '--repo-type', |
| $repoType, |
| '--token', |
| $token, |
| '--quiet', |
| ]; |
|
|
| return implode(' ', array_map(static fn (string $value): string => escapeshellarg($value), $arguments)); |
| } |
|
|
| private function buildBucketUploadCommand( |
| string $binary, |
| string $bucketId, |
| string $absolutePath, |
| string $remotePath, |
| string $token |
| ): string { |
| $bucketHandle = 'hf://buckets/'.trim($bucketId, '/').'/'.trim($remotePath, '/'); |
|
|
| $arguments = [ |
| $binary, |
| 'buckets', |
| 'cp', |
| $absolutePath, |
| $bucketHandle, |
| '--token', |
| $token, |
| '--quiet', |
| ]; |
|
|
| return implode(' ', array_map(static fn (string $value): string => escapeshellarg($value), $arguments)); |
| } |
|
|
| private function resolveRepoUrl(string $repoType, string $repoId, string $remotePath): string |
| { |
| $encodedPath = implode('/', array_map('rawurlencode', explode('/', trim($remotePath, '/')))); |
|
|
| return match ($repoType) { |
| 'dataset' => 'https://huggingface.co/datasets/'.$repoId.'/resolve/main/'.$encodedPath, |
| 'space' => 'https://huggingface.co/spaces/'.$repoId.'/resolve/main/'.$encodedPath, |
| default => 'https://huggingface.co/'.$repoId.'/resolve/main/'.$encodedPath, |
| }; |
| } |
|
|
| private function resolveBucketUrl(string $bucketId, string $remotePath): string |
| { |
| $encodedPath = rawurlencode(trim($remotePath, '/')); |
|
|
| return 'https://huggingface.co/buckets/'.trim($bucketId, '/').'/resolve/'.$encodedPath; |
| } |
|
|
| private function driver(): string |
| { |
| $value = strtolower(trim((string) ( |
| config('services.huggingface.media_driver') |
| ?? $this->readEnv('HF_MEDIA_DRIVER') |
| ?? 'repo' |
| ))); |
|
|
| return in_array($value, ['repo', 'bucket'], true) ? $value : 'repo'; |
| } |
|
|
| private function isEnabled(): bool |
| { |
| $value = strtolower(trim((string) ( |
| config('services.huggingface.media_enabled') |
| ?? $this->readEnv('HF_MEDIA_ENABLED') |
| ?? 'false' |
| ))); |
|
|
| return in_array($value, ['1', 'true', 'yes', 'on'], true); |
| } |
|
|
| private function deleteLocalAfterOffload(): bool |
| { |
| $value = strtolower(trim((string) ( |
| config('services.huggingface.media_delete_local') |
| ?? $this->readEnv('HF_MEDIA_DELETE_LOCAL') |
| ?? 'true' |
| ))); |
|
|
| return in_array($value, ['1', 'true', 'yes', 'on'], true); |
| } |
|
|
| private function token(): string |
| { |
| return trim((string) ( |
| config('services.huggingface.api_token') |
| ?? $this->readEnv('HUGGINGFACE_API_TOKEN') |
| ?? $this->readEnv('HF_API_TOKEN') |
| ?? $this->readEnv('HF_TOKEN') |
| ?? $this->readEnv('HUGGINGFACEHUB_API_TOKEN') |
| ?? '' |
| )); |
| } |
|
|
| private function repoId(): string |
| { |
| return trim((string) ( |
| config('services.huggingface.media_repo_id') |
| ?? $this->readEnv('HF_MEDIA_REPO_ID') |
| ?? $this->readEnv('HUGGINGFACE_MEDIA_REPO_ID') |
| ?? '' |
| )); |
| } |
|
|
| private function bucketId(): string |
| { |
| return trim((string) ( |
| config('services.huggingface.media_bucket_id') |
| ?? $this->readEnv('HF_MEDIA_BUCKET_ID') |
| ?? $this->readEnv('HUGGINGFACE_MEDIA_BUCKET_ID') |
| ?? '' |
| )); |
| } |
|
|
| private function repoType(): string |
| { |
| $value = strtolower(trim((string) ( |
| config('services.huggingface.media_repo_type') |
| ?? $this->readEnv('HF_MEDIA_REPO_TYPE') |
| ?? 'dataset' |
| ))); |
|
|
| return in_array($value, ['dataset', 'model', 'space'], true) ? $value : 'dataset'; |
| } |
|
|
| private function remotePrefix(): string |
| { |
| return trim((string) ( |
| config('services.huggingface.media_path_prefix') |
| ?? $this->readEnv('HF_MEDIA_PATH_PREFIX') |
| ?? 'laravel-media' |
| )); |
| } |
|
|
| private function hfBinary(): string |
| { |
| return trim((string) ( |
| config('services.huggingface.cli_binary') |
| ?? $this->readEnv('HF_CLI_BINARY') |
| ?? 'hf' |
| )); |
| } |
|
|
| private function readEnv(string $key): ?string |
| { |
| $value = $_ENV[$key] ?? $_SERVER[$key] ?? getenv($key); |
| if (! is_string($value)) { |
| return null; |
| } |
|
|
| $trimmed = trim($value); |
|
|
| return $trimmed === '' ? null : $trimmed; |
| } |
| } |
|
|