Skip to content

Commit 4be793a

Browse files
committed
feat: subtitle extraction logic
1 parent 5800493 commit 4be793a

6 files changed

Lines changed: 207 additions & 0 deletions

File tree

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<?php
2+
3+
namespace App\Http\Controllers\Api\V1\Metadata;
4+
5+
use App\Http\Controllers\Controller;
6+
use App\Models\Metadata;
7+
use App\Services\Subtitles\SubtitleResolver;
8+
use Illuminate\Http\Request;
9+
10+
class SubtitleController extends Controller {
11+
public function __construct(protected SubtitleResolver $subtitleResolver) {}
12+
13+
/**
14+
* Look for a specified subtitle resource.
15+
*
16+
* Subtitle Extraction Process ->
17+
* Initially scan file, set subtitles_scanned_at time and make partially identifying subtitle rows in the subtitle table
18+
* then when requested by stream or file type, if the exact requested file matches an existing one, return that but if not probe again,
19+
* and for each found stream, extract it, write to a file and update the row in the subtitle file, and then convert it to the requested format and return that file
20+
* on subsequent requests, a matching file (usually vtt) will already exist and get returned directly
21+
*/
22+
public function show(
23+
Request $request,
24+
Metadata $metadata,
25+
int $track = 2, // track number to specify file like 2.vtt or 3.vtt
26+
string $format = 'vtt'
27+
) {
28+
// TODO: Log activity from request (requires future activity feature)
29+
return $this->subtitleResolver->resolveSubtitles($metadata, $track, $format);
30+
}
31+
32+
// TODO: Add an API route that gets subtitles for a specific metadata or video row and cache the results. These are only used to determine the filepath used in the <track/>
33+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
<?php
2+
3+
namespace App\Services\Subtitles\Formats;
4+
5+
interface SubtitleFormatStrategy {
6+
public function convert(string $inputPath, string $outputPath): void;
7+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
<?php
2+
3+
namespace App\Services\Subtitles\Formats;
4+
5+
use Illuminate\Support\Facades\Storage;
6+
use Symfony\Component\Process\Process;
7+
8+
class VttStrategy implements SubtitleFormatStrategy {
9+
public function convert(string $inputPath, string $outputPath): void {
10+
$process = new Process([
11+
'ffmpeg',
12+
'-y',
13+
'-i',
14+
Storage::disk('local')->path($inputPath),
15+
'-c:s',
16+
'webvtt',
17+
Storage::disk('local')->path($outputPath),
18+
]);
19+
20+
$process->mustRun();
21+
}
22+
}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
<?php
2+
3+
namespace App\Services\Subtitles;
4+
5+
use App\Models\Metadata;
6+
use App\Models\Subtitle;
7+
use Illuminate\Support\Facades\Log;
8+
use Illuminate\Support\Facades\Storage;
9+
use Symfony\Component\Process\Process;
10+
11+
class SubtitleExtractor {
12+
public function extractStream(Metadata $metadata, Subtitle $subtitle) {
13+
try {
14+
$mediaPath = $metadata->video->path;
15+
$ext = $this->getExtentionFromCodec($subtitle['codec']);
16+
17+
$outputPath = $this->getOutputDir($subtitle, $ext);
18+
19+
$command = [
20+
'ffmpeg',
21+
'-y',
22+
'-i',
23+
Storage::disk('public')->path($mediaPath), // Media is on public disk for now
24+
'-map',
25+
"0:$subtitle->track_id",
26+
Storage::disk('local')->path($outputPath),
27+
];
28+
29+
$process = new Process($command);
30+
$process->run();
31+
32+
if (! $process->isSuccessful()) {
33+
throw new \Exception('Subtitles Failed: "' . implode(' ', $command) . '"');
34+
}
35+
36+
$subtitle->update([
37+
'path' => $outputPath,
38+
'format' => $ext,
39+
]);
40+
41+
return $outputPath;
42+
} catch (\Throwable $th) {
43+
Log::error('Unable to get file subtitles', ['error' => $th->getMessage()]);
44+
throw $th;
45+
}
46+
}
47+
48+
/**
49+
* Get the relative output path given the subtitle and file extention
50+
*/
51+
private function getOutputDir(Subtitle $subtitle, string $ext) {
52+
$outDir = "data/media/{$subtitle->metadata_uuid}/subtitles"; // Relative output directory
53+
Storage::disk('local')->makeDirectory($outDir); // Ensure directory exists
54+
55+
return "{$outDir}/{$subtitle->track_id}.{$ext}"; // Add track.ext to get final output address
56+
}
57+
58+
private function getExtentionFromCodec(string $codec): string {
59+
return match ($codec) {
60+
'subrip' => 'srt',
61+
'ass' => 'ass',
62+
'ssa' => 'ssa',
63+
'webvtt' => 'vtt',
64+
'hdmv_pgs_subtitle' => 'sup',
65+
'dvd_subtitle' => 'sub',
66+
default => 'bin',
67+
};
68+
}
69+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
<?php
2+
3+
namespace App\Services\Subtitles;
4+
5+
use App\Services\Subtitles\Formats\VttStrategy;
6+
use Illuminate\Support\Facades\Storage;
7+
8+
class SubtitleFormatter {
9+
public function convert(string $input, string $output, string $format): string {
10+
if (! Storage::disk('local')->exists($input)) {
11+
throw new \RuntimeException("Input file not found: {$input}");
12+
}
13+
14+
$strategy = match ($format) {
15+
'vtt' => new VttStrategy,
16+
default => throw new \InvalidArgumentException('Unsupported format'),
17+
};
18+
19+
$strategy->convert($input, $output);
20+
21+
if (! Storage::disk('local')->exists($output)) {
22+
throw new \RuntimeException("Conversion failed: {$output} not created");
23+
}
24+
25+
return $output;
26+
}
27+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
<?php
2+
3+
namespace App\Services\Subtitles;
4+
5+
use App\Models\Metadata;
6+
use Illuminate\Support\Facades\Response;
7+
use Illuminate\Support\Facades\Storage;
8+
use Symfony\Component\HttpFoundation\BinaryFileResponse;
9+
10+
class SubtitleResolver {
11+
public function __construct(protected SubtitleExtractor $extractor, protected SubtitleFormatter $formatter) {}
12+
13+
public function resolveSubtitles(Metadata $metadata, int $track, string $format): BinaryFileResponse {
14+
$subtitle = $metadata->subtitles()->where('track_id', $track)->firstOrFail(); // 404 on no result
15+
16+
$format = ltrim(strtolower($format), '.');
17+
$requestedPath = "data/media/{$subtitle->metadata_uuid}/subtitles/{$track}.{$format}";
18+
19+
if ($this->fileExists($requestedPath)) {
20+
return Response::file(
21+
Storage::disk('local')->path($requestedPath)
22+
);
23+
}
24+
25+
if (! $subtitle->path || ! $this->fileExists($subtitle->path)) {
26+
$this->extractor->extractStream($metadata, $subtitle); // Native format subtitle file (usually srt)
27+
$subtitle->refresh();
28+
}
29+
30+
$nativePath = $subtitle->path;
31+
$convertedPath = $this->formatter->convert(
32+
$nativePath,
33+
$requestedPath,
34+
$format
35+
);
36+
37+
if (! $this->fileExists($convertedPath)) {
38+
abort(500);
39+
}
40+
41+
return Response::file(
42+
Storage::disk('local')->path($convertedPath)
43+
);
44+
}
45+
46+
private function fileExists(string $path): bool {
47+
return Storage::disk('local')->exists($path);
48+
}
49+
}

0 commit comments

Comments
 (0)