From 130f25e15abb2fe4c845178c736565dc5b78c96c Mon Sep 17 00:00:00 2001 From: Anatoliy Poloz Date: Thu, 16 Jul 2026 21:56:58 +0300 Subject: [PATCH] first commit --- .gitignore | 7 + README.md | 197 ++++++++++ composer.json | 45 +++ config/video-extractor.php | 36 ++ phpunit.xml | 21 ++ src/Dto/FormatDto.php | 23 ++ src/Dto/ThumbnailDto.php | 26 ++ src/Dto/VideoInfoDto.php | 65 ++++ src/Dto/VideoRef.php | 31 ++ src/Exceptions/GeoBlockedException.php | 9 + src/Exceptions/LoginRequiredException.php | 9 + src/Exceptions/ParseFailedException.php | 9 + .../PlatformNotSupportedException.php | 9 + src/Exceptions/VideoExtractionException.php | 11 + src/Exceptions/VideoNotFoundException.php | 9 + src/Facades/Video.php | 21 ++ src/Http/HttpClient.php | 137 +++++++ src/Parsing/EmbeddedJsonExtractor.php | 150 ++++++++ src/Parsing/MetaTagParser.php | 100 +++++ src/Parsing/OkRuUrlParser.php | 53 +++ src/Parsing/RutubeUrlParser.php | 76 ++++ src/Parsing/VkUrlParser.php | 110 ++++++ src/Platform/Platform.php | 15 + src/Platform/PlatformDetector.php | 82 ++++ src/Strategies/OkRu/OkRuStrategy.php | 357 ++++++++++++++++++ src/Strategies/Rutube/RutubeApiStrategy.php | 272 +++++++++++++ src/Strategies/StrategyInterface.php | 22 ++ src/Strategies/Vk/AlVideoStrategy.php | 182 +++++++++ src/Strategies/Vk/InteractsWithVkPayload.php | 107 ++++++ src/Strategies/Vk/VideoExtStrategy.php | 215 +++++++++++ src/Strategies/Vk/VkStrategyChain.php | 45 +++ src/VideoExtractor.php | 168 +++++++++ src/VideoExtractorServiceProvider.php | 61 +++ tests/Feature/OkRuExtractionTest.php | 132 +++++++ tests/Feature/RutubeExtractionTest.php | 124 ++++++ tests/Feature/VideoExtractorTest.php | 96 +++++ tests/Feature/VkExtractionTest.php | 157 ++++++++ tests/TestCase.php | 55 +++ tests/Unit/EmbeddedJsonExtractorTest.php | 80 ++++ tests/Unit/HttpClientTest.php | 90 +++++ tests/Unit/OkRuUrlParserTest.php | 55 +++ tests/Unit/PlatformDetectorTest.php | 85 +++++ tests/Unit/RutubeUrlParserTest.php | 65 ++++ tests/Unit/VkUrlParserTest.php | 79 ++++ tests/fixtures/ok/video_meta_only.html | 23 ++ tests/fixtures/ok/video_not_found.html | 10 + tests/fixtures/ok/video_success.html | 1 + tests/fixtures/rutube/play_options.json | 28 ++ .../rutube/play_options_geo_blocked.json | 11 + tests/fixtures/rutube/video_meta.json | 31 ++ tests/fixtures/rutube/video_meta_404.json | 4 + tests/fixtures/vk/al_video_deleted.json | 1 + tests/fixtures/vk/al_video_success.json | 1 + tests/fixtures/vk/video_ext_not_found.html | 14 + tests/fixtures/vk/video_ext_success.html | 13 + 55 files changed, 3835 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 composer.json create mode 100644 config/video-extractor.php create mode 100644 phpunit.xml create mode 100644 src/Dto/FormatDto.php create mode 100644 src/Dto/ThumbnailDto.php create mode 100644 src/Dto/VideoInfoDto.php create mode 100644 src/Dto/VideoRef.php create mode 100644 src/Exceptions/GeoBlockedException.php create mode 100644 src/Exceptions/LoginRequiredException.php create mode 100644 src/Exceptions/ParseFailedException.php create mode 100644 src/Exceptions/PlatformNotSupportedException.php create mode 100644 src/Exceptions/VideoExtractionException.php create mode 100644 src/Exceptions/VideoNotFoundException.php create mode 100644 src/Facades/Video.php create mode 100644 src/Http/HttpClient.php create mode 100644 src/Parsing/EmbeddedJsonExtractor.php create mode 100644 src/Parsing/MetaTagParser.php create mode 100644 src/Parsing/OkRuUrlParser.php create mode 100644 src/Parsing/RutubeUrlParser.php create mode 100644 src/Parsing/VkUrlParser.php create mode 100644 src/Platform/Platform.php create mode 100644 src/Platform/PlatformDetector.php create mode 100644 src/Strategies/OkRu/OkRuStrategy.php create mode 100644 src/Strategies/Rutube/RutubeApiStrategy.php create mode 100644 src/Strategies/StrategyInterface.php create mode 100644 src/Strategies/Vk/AlVideoStrategy.php create mode 100644 src/Strategies/Vk/InteractsWithVkPayload.php create mode 100644 src/Strategies/Vk/VideoExtStrategy.php create mode 100644 src/Strategies/Vk/VkStrategyChain.php create mode 100644 src/VideoExtractor.php create mode 100644 src/VideoExtractorServiceProvider.php create mode 100644 tests/Feature/OkRuExtractionTest.php create mode 100644 tests/Feature/RutubeExtractionTest.php create mode 100644 tests/Feature/VideoExtractorTest.php create mode 100644 tests/Feature/VkExtractionTest.php create mode 100644 tests/TestCase.php create mode 100644 tests/Unit/EmbeddedJsonExtractorTest.php create mode 100644 tests/Unit/HttpClientTest.php create mode 100644 tests/Unit/OkRuUrlParserTest.php create mode 100644 tests/Unit/PlatformDetectorTest.php create mode 100644 tests/Unit/RutubeUrlParserTest.php create mode 100644 tests/Unit/VkUrlParserTest.php create mode 100644 tests/fixtures/ok/video_meta_only.html create mode 100644 tests/fixtures/ok/video_not_found.html create mode 100644 tests/fixtures/ok/video_success.html create mode 100644 tests/fixtures/rutube/play_options.json create mode 100644 tests/fixtures/rutube/play_options_geo_blocked.json create mode 100644 tests/fixtures/rutube/video_meta.json create mode 100644 tests/fixtures/rutube/video_meta_404.json create mode 100644 tests/fixtures/vk/al_video_deleted.json create mode 100644 tests/fixtures/vk/al_video_success.json create mode 100644 tests/fixtures/vk/video_ext_not_found.html create mode 100644 tests/fixtures/vk/video_ext_success.html diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..36bfc08 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +/vendor/ +/.phpunit.cache/ +/.phpunit.result.cache +/composer.lock +/node_modules +/.idea +/.vscode diff --git a/README.md b/README.md new file mode 100644 index 0000000..e39563a --- /dev/null +++ b/README.md @@ -0,0 +1,197 @@ +# laravel-video-extractor + +Extract **thumbnails**, **metadata** and **stream formats** from **VK** (`vkvideo.ru` / `vk.com`), **Rutube** and **OK.ru** (Odnoklassniki) videos. The platform is auto-detected from the URL — one facade, all services. + +```php +use Apoloz\VideoExtractor\Facades\Video; + +// Full extraction (metadata + thumbnails + formats) +$info = Video::extract('https://rutube.ru/video/373ba417757981facf06c55ea5c46cb4/'); +echo $info->title; // "Первые слова малыша..." +echo $info->bestThumbnail()->url; // https://pic.rtbcdn.ru/video/...jpg +echo $info->duration; // 3756 + +// Lightweight thumbnail-only path (single request) +Video::thumbnail('https://vkvideo.ru/video-18403220_456239696'); + +// OK.ru — auto-detected, full metadata + mp4/HLS formats +Video::extract('https://ok.ru/video/15019292494547')->bestThumbnail()->url; +``` + +## Requirements + +- PHP **8.1+** +- Laravel **10 / 11 / 12** + +## Installation + +The package is hosted in a private Gitea repository +(`gitea.office.local:3000/eatvio/laravel-video-extractor`). It is **not** on +Packagist, so the consuming project must register the repository with Composer +and have read access to it. + +### Option A — via SSH (recommended) + +Requires an SSH key with read access to the repo on every machine running +`composer install` (the key configured during development works on this host). + +```bash +# In your Laravel project: +composer config repositories.laravel-video-extractor git \ + "ssh://git@gitea.office.local:222/eatvio/laravel-video-extractor.git" +composer require apoloz/laravel-video-extractor:^1.0 +``` + +For other servers / CI, add a **Gitea deploy key** (repo → Settings → Deploy +Keys) with the machine's public key and pre-seed `known_hosts`: + +```bash +ssh-keyscan -p 222 gitea.office.local >> ~/.ssh/known_hosts +``` + +### Option B — via HTTP with an access token + +Useful when SSH key distribution is impractical (CI, restricted networks). + +1. In Gitea: **Settings → Applications → Generate New Token** (scope `read:repository`). +2. Configure Composer auth (on each machine): + ```bash + composer config --global http-basic.gitea.office.local + ``` +3. Register the repository and install: + ```bash + composer config repositories.laravel-video-extractor git \ + "http://gitea.office.local:3000/eatvio/laravel-video-extractor.git" + composer require apoloz/laravel-video-extractor:^1.0 + ``` + +For CI, the auth can be supplied via the `COMPOSER_AUTH` environment variable +instead of `composer config`. + +### After installation + +The service provider and `Video` facade are auto-discovered by Laravel — no +manual registration needed. To publish the config: + +```bash +php artisan vendor:publish --tag=video-extractor-config +``` + +### Versioning + +Stable releases are git tags (`v1.0.0`, ...). Use a caret constraint +(`^1.0`) to pull tagged releases. The `@dev` / `dev-main` constraint pulls the +latest `main` branch tip (no tag required) — convenient during development. + + +## Supported URLs + +| Platform | Examples | +| --- | --- | +| VK | `vkvideo.ru/video-{oid}_{id}`, `vk.com/video-{oid}_{id}`, `vkvideo.ru/clip-...`, `vk.com/video_ext.php?oid=...&id=...`, `m.vk.com/...`, `vksport.vkvideo.ru/...` | +| Rutube | `rutube.ru/video/{id}`, `rutube.ru/live/video/{id}`, `rutube.ru/video/private/{id}/?p=token`, `rutube.ru/play/embed/{id}` | +| OK.ru | `ok.ru/video/{id}`, `ok.ru/videoembed/{id}`, `ok.ru/live/{id}`, `m.ok.ru/video/{id}`, `odnoklassniki.ru/video/{id}`, `ok.ru/dk?st.mvId=...` | + +## How it works + +The package mirrors the extraction logic of [`yt-dlp`](https://github.com/yt-dlp/yt-dlp), adapted for idiomatic Laravel: + +- **VK** uses two endpoints with automatic fallback: + 1. `GET https://vk.com/video_ext.php?oid=&id=` (primary) — returns a multi-resolution `image[]` array of thumbnails plus full metadata, embedded as JSON in the HTML. + 2. `POST https://vk.com/al_video.php` (fallback, yt-dlp's proven method) — yields a single thumbnail URL but surfaces explicit error codes (`3` = login required, `8` = unavailable). + - VK serves responses in **Windows-1251**; the package transparently re-encodes them to UTF-8. + +- **Rutube** uses its clean public REST API (UTF-8, no auth, no challenges): + 1. `GET https://rutube.ru/api/video/{id}/?format=json` — metadata + stable `thumbnail_url`. + 2. `GET https://rutube.ru/api/play/options/{id}/?format=json` — master HLS playlist + access verdict. + +- **OK.ru** scrapes the public video page (UTF-8, anonymous access) with a two-tier strategy: + 1. (primary) Parses the `
` player payload (HTML-entity-decoded then double `json_decode`) for `movie.poster`, `videos[]` (mp4 qualities), `hlsManifestUrl` and `likeCount`. + 2. (fallback) Open Graph / `ya:ovs` `` tags — used when the player payload is absent (external/restricted/paid videos) to still recover the thumbnail and basic metadata. + +## Usage + +### Extract everything + +```php +$info = Video::extract($url); + +$info->title; +$info->description; +$info->duration; // seconds +$info->uploader; // author / channel +$info->timestamp; // unix +$info->viewCount; +$info->isLive; +$info->ageLimit; // 0 or 18 +$info->categories; // ["Education"] +$info->thumbnails; // ThumbnailDto[] (multiple resolutions where available) +$info->bestThumbnail(); // highest-resolution ThumbnailDto +$info->thumbnailUrl(); // convenience: just the URL +$info->formats; // FormatDto[] (mp4/hls/dash for VK, HLS for Rutube) +``` + +### Just the thumbnail (fast path) + +```php +$url = Video::thumbnail($url); // string|null — skips the formats request +``` + +### Dependency injection + +```php +use Apoloz\VideoExtractor\VideoExtractor; + +public function handle(VideoExtractor $extractor): void +{ + $info = $extractor->extract($url); +} +``` + +## Configuration + +Published to `config/video-extractor.php`: + +```php +return [ + 'http' => [ + 'timeout' => env('VIDEO_EXTRACTOR_TIMEOUT', 30), + 'user_agent' => env('VIDEO_EXTRACTOR_USER_AGENT'), + ], + + 'cache' => [ + 'enabled' => env('VIDEO_EXTRACTOR_CACHE_ENABLED', true), + 'store' => env('VIDEO_EXTRACTOR_CACHE_STORE'), // null = default + 'thumbnail_ttl' => env('VIDEO_EXTRACTOR_THUMBNAIL_TTL', 604800), // 7 days + 'info_ttl' => env('VIDEO_EXTRACTOR_INFO_TTL', 600), // 10 min + ], +]; +``` + +**Caching strategy:** thumbnail URLs are stable CDN resources and are cached for a long time (default 7 days). Full extraction results include format URLs that carry an `expires=`/`expire=` parameter, so they are cached briefly and the TTL is automatically capped by the soonest format expiry. + +## Error handling + +| Exception | Cause | +| --- | --- | +| `PlatformNotSupportedException` | The URL host is neither VK nor Rutube | +| `VideoNotFoundException` | Video deleted / removed / not found | +| `GeoBlockedException` | Rutube `acl_access` denied the request | +| `LoginRequiredException` | VK returned code `3` (private / age-restricted) | +| `VideoExtractionException` | Base class for all package errors | + +All exceptions extend `Apoloz\VideoExtractor\Exceptions\VideoExtractionException`. + +## Known limitations + +- **VK JS challenge (429 → `/challenge.html`):** VK occasionally requires solving a JavaScript challenge. yt-dlp solves this with a built-in JS interpreter; that is out of scope for this package, which raises a descriptive `RuntimeException` instead. Public videos normally work without it; retry later or use a VK session cookie for heavy use. +- **Auth-required content** (private / age-restricted VK videos) is not supported without upstream session handling. +- **Stream format URLs expire** within hours by design (VK `expires=`, Rutube `expire=`); always re-extract before playback. + +## Credits + +Extraction logic inspired by and adapted from [yt-dlp](https://github.com/yt-dlp/yt-dlp)'s `VK` and `Rutube` extractors. + +## License + +MIT diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..4b7c847 --- /dev/null +++ b/composer.json @@ -0,0 +1,45 @@ +{ + "name": "apoloz/laravel-video-extractor", + "description": "Extract thumbnails, metadata and stream formats from VK and Rutube videos. Auto-detects platform by URL.", + "type": "library", + "license": "MIT", + "keywords": ["laravel", "vk", "rutube", "vkvideo", "thumbnail", "video", "extractor"], + "authors": [ + { + "name": "Apoloz", + "homepage": "https://github.com/apoloz" + } + ], + "require": { + "php": ">=8.1", + "ext-mbstring": "*", + "illuminate/support": "^10.0|^11.0|^12.0", + "illuminate/http": "^10.0|^11.0|^12.0" + }, + "require-dev": { + "orchestra/testbench": "^8.0|^9.0|^10.0", + "phpunit/phpunit": "^10.0|^11.0|^12.0" + }, + "autoload": { + "psr-4": { + "Apoloz\\VideoExtractor\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Apoloz\\VideoExtractor\\Tests\\": "tests/" + } + }, + "extra": { + "laravel": { + "providers": [ + "Apoloz\\VideoExtractor\\VideoExtractorServiceProvider" + ], + "aliases": { + "Video": "Apoloz\\VideoExtractor\\Facades\\Video" + } + } + }, + "minimum-stability": "stable", + "prefer-stable": true +} diff --git a/config/video-extractor.php b/config/video-extractor.php new file mode 100644 index 0000000..2e7cb12 --- /dev/null +++ b/config/video-extractor.php @@ -0,0 +1,36 @@ + [ + 'timeout' => env('VIDEO_EXTRACTOR_TIMEOUT', 30), + 'user_agent' => env('VIDEO_EXTRACTOR_USER_AGENT'), + ], + + /* + |-------------------------------------------------------------------------- + | Caching + |-------------------------------------------------------------------------- + | + | Thumbnail URLs are stable CDN resources and may be cached for a long + | time. Full extraction results (which include format URLs) are cached + | briefly, since stream links carry an `expires=`/`expire=` parameter. + | + */ + + 'cache' => [ + 'enabled' => env('VIDEO_EXTRACTOR_CACHE_ENABLED', true), + 'store' => env('VIDEO_EXTRACTOR_CACHE_STORE'), // null = default store + 'thumbnail_ttl' => env('VIDEO_EXTRACTOR_THUMBNAIL_TTL', 7 * 24 * 60 * 60), // 7 days + 'info_ttl' => env('VIDEO_EXTRACTOR_INFO_TTL', 10 * 60), // 10 minutes + ], + +]; diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..5ef83c0 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,21 @@ + + + + + tests/Unit + + + tests/Feature + + + + + src + + + diff --git a/src/Dto/FormatDto.php b/src/Dto/FormatDto.php new file mode 100644 index 0000000..96a7e95 --- /dev/null +++ b/src/Dto/FormatDto.php @@ -0,0 +1,23 @@ +width ?? 0) * ($this->height ?? 0); + } +} diff --git a/src/Dto/VideoInfoDto.php b/src/Dto/VideoInfoDto.php new file mode 100644 index 0000000..bfe2ee8 --- /dev/null +++ b/src/Dto/VideoInfoDto.php @@ -0,0 +1,65 @@ + $categories + */ + public function __construct( + public readonly Platform $platform, + public readonly string $id, + public readonly ?string $title = null, + public readonly ?string $description = null, + public readonly ?int $duration = null, + public readonly ?string $uploader = null, + public readonly ?string $uploaderId = null, + public readonly ?int $timestamp = null, + public readonly ?int $viewCount = null, + public readonly ?int $likeCount = null, + public readonly ?int $commentCount = null, + public readonly ?int $ageLimit = null, + public readonly ?bool $isLive = null, + public readonly array $thumbnails = [], + public readonly array $formats = [], + public readonly array $categories = [], + public readonly ?int $formatExpiresAt = null, + ) {} + + /** + * The thumbnail with the largest resolution, or the first available. + */ + public function bestThumbnail(): ?ThumbnailDto + { + if ($this->thumbnails === []) { + return null; + } + + $best = $this->thumbnails[0]; + foreach ($this->thumbnails as $thumbnail) { + if ($thumbnail->area() > $best->area()) { + $best = $thumbnail; + } + } + + return $best; + } + + /** + * Convenience accessor returning just the best thumbnail URL (or null). + */ + public function thumbnailUrl(): ?string + { + return $this->bestThumbnail()?->url; + } +} diff --git a/src/Dto/VideoRef.php b/src/Dto/VideoRef.php new file mode 100644 index 0000000..3af1c94 --- /dev/null +++ b/src/Dto/VideoRef.php @@ -0,0 +1,31 @@ + */ + public readonly array $query = [], + ) {} + + /** + * Stable cache key for the video reference. + */ + public function cacheKey(string $suffix = ''): string + { + return "video:{$this->platform->value}:{$this->id}" . ($suffix !== '' ? ":{$suffix}" : ''); + } +} diff --git a/src/Exceptions/GeoBlockedException.php b/src/Exceptions/GeoBlockedException.php new file mode 100644 index 0000000..7508c49 --- /dev/null +++ b/src/Exceptions/GeoBlockedException.php @@ -0,0 +1,9 @@ + $query + * @param array $headers + * + * @throws ConnectionException|RuntimeException + */ + public function get(string $url, array $query = [], array $headers = []): Response + { + $response = $this->baseRequest($headers)->get($url, $query); + + $this->guardRateLimited($response->status(), (string) $response->effectiveUri()); + + return $response; + } + + /** + * Perform a POST request with URL-encoded form params. + * + * @param array $data + * @param array $headers + * + * @throws ConnectionException|RuntimeException + */ + public function post(string $url, array $data = [], array $headers = []): Response + { + $response = $this->baseRequest($headers) + ->asForm() + ->post($url, $data); + + $this->guardRateLimited($response->status(), (string) $response->effectiveUri()); + + return $response; + } + + /** + * Return the response body converted from its source charset to UTF-8. + * + * VK responses are served as Windows-1251; Rutube responses are UTF-8. + * The source charset is detected from the Content-Type header when + * available, otherwise it can be forced via $forceCharset. + */ + public function bodyUtf8(Response $response, ?string $forceCharset = null): string + { + $body = $response->body(); + $charset = $forceCharset ?? $this->detectCharset($response); + + if ($charset === null || strtoupper($charset) === 'UTF-8') { + return $body; + } + + $converted = @mb_convert_encoding($body, 'UTF-8', strtoupper($charset)); + + return $converted === false || $converted === '' ? $body : $converted; + } + + /** + * Build a {@see PendingRequest} with the shared defaults applied. + * + * @param array $headers + */ + private function baseRequest(array $headers): PendingRequest + { + return Http::withHeaders(array_merge( + ['User-Agent' => $this->userAgent], + $headers, + ))->timeout($this->timeout); + } + + /** + * Extract a charset token from the Content-Type header. + */ + private function detectCharset(Response $response): ?string + { + $header = $response->header('Content-Type'); + + if ($header !== '' && preg_match('/charset\s*=\s*"?([\w\-]+)/i', $header, $matches)) { + return $matches[1]; + } + + return null; + } + + /** + * VK answers 429 and redirects to /challenge.html when it decides a JS + * challenge must be solved. Solving it requires a JS interpreter (like + * yt-dlp's), which is out of scope for this package. + * + * @throws RuntimeException when a challenge redirect is detected + */ + private function guardRateLimited(int $status, string $effectiveUrl): void + { + if ($status !== 429) { + return; + } + + $path = parse_url($effectiveUrl, PHP_URL_PATH); + if ($path === '/challenge.html') { + throw new RuntimeException( + 'VK returned a JS challenge (429 /challenge.html). ' + . 'This is a known limitation; retry later or provide a VK session cookie.', + ); + } + } +} diff --git a/src/Parsing/EmbeddedJsonExtractor.php b/src/Parsing/EmbeddedJsonExtractor.php new file mode 100644 index 0000000..27bd6ed --- /dev/null +++ b/src/Parsing/EmbeddedJsonExtractor.php @@ -0,0 +1,150 @@ + + * + * @throws ParseFailedException when no balanced JSON value can be located + */ + public function extractAfterMarker(string $haystack, string $marker): array + { + $offset = mb_strpos($haystack, $marker); + + if ($offset === false) { + throw new ParseFailedException("Marker not found: {$marker}"); + } + + $start = $this->findFirstToken($haystack, $offset + mb_strlen($marker)); + + if ($start === null) { + throw new ParseFailedException("No JSON token found after marker: {$marker}"); + } + + $json = $this->readBalancedValue($haystack, $start); + + if ($json === null) { + throw new ParseFailedException("Could not balance a JSON value after marker: {$marker}"); + } + + try { + $decoded = json_decode($json, true, flags: JSON_THROW_ON_ERROR); + } catch (\JsonException $e) { + throw new ParseFailedException("Decoding embedded JSON failed: {$e->getMessage()}", previous: $e); + } + + if (!is_array($decoded)) { + throw new ParseFailedException('Embedded JSON did not decode to an array.'); + } + + return $decoded; + } + + /** + * Locate the first non-empty JSON object/array token at or after $from. + * + * The scan walks forward through any preceding characters (whitespace, + * parentheses, identifiers such as `window.cur || `) and skips over empty + * literals (`{}`, `[]`) so that, for an assignment like + * `Object.assign(window.cur || {}, {actual})`, the token of the *second* + * (data) argument is selected rather than the empty `{}`. + * + * @return int|null the byte offset, or null when no token is present + */ + private function findFirstToken(string $haystack, int $from): ?int + { + $length = strlen($haystack); + $i = $from; + + while ($i < $length) { + $char = $haystack[$i]; + + if ($char === '{' || $char === '[') { + $close = $char === '{' ? '}' : ']'; + + // Skip empty object/array literals (`{}`, `[]`). + if ($i + 1 < $length && $haystack[$i + 1] === $close) { + $i += 2; + continue; + } + + return $i; + } + + $i++; + } + + return null; + } + + /** + * Starting at $start (a `{` or `[`), walk the string counting matching + * braces/brackets while skipping string literals. Returns the raw JSON + * substring when balanced, or null on failure. + */ + private function readBalancedValue(string $haystack, int $start): ?string + { + $open = $haystack[$start]; + $close = $open === '{' ? '}' : ']'; + + $length = strlen($haystack); + $depth = 0; + $inString = false; + $escaped = false; + + for ($i = $start; $i < $length; $i++) { + $char = $haystack[$i]; + + if ($inString) { + if ($escaped) { + $escaped = false; + } elseif ($char === '\\') { + $escaped = true; + } elseif ($char === '"') { + $inString = false; + } + continue; + } + + if ($char === '"') { + $inString = true; + continue; + } + + if ($char === $open) { + $depth++; + } elseif ($char === $close) { + $depth--; + if ($depth === 0) { + return substr($haystack, $start, $i - $start + 1); + } + } + } + + return null; + } +} diff --git a/src/Parsing/MetaTagParser.php b/src/Parsing/MetaTagParser.php new file mode 100644 index 0000000..3130ef1 --- /dev/null +++ b/src/Parsing/MetaTagParser.php @@ -0,0 +1,100 @@ +` tags by their `property` (Open Graph / + * `ya:ovs`) or `name` attribute. + * + * Designed for the OK.ru head section, where Open Graph and Yandex OVS + * vocabularies expose thumbnail + metadata even when the inline `data-options` + * player payload is unavailable (external embeds, restricted or paid videos). + */ +final class MetaTagParser +{ + /** + * Read a single meta property/name, returning its decoded `content`. + * + * HTML entities (e.g. `&`) are unescaped so URLs come out clean. + */ + public function get(string $html, string $property): ?string + { + // property="og:image" ... content="...", order-insensitive within the tag. + $pattern = sprintf( + '/]+(?:property|name)\s*=\s*["\']%s["\'][^>]*>/i', + preg_quote($property, '/'), + ); + + if (preg_match($pattern, $html, $tagMatch) !== 1) { + return null; + } + + if (preg_match('/content\s*=\s*["\']([^"\']*)["\']/i', $tagMatch[0], $contentMatch) !== 1) { + return null; + } + + $value = html_entity_decode($contentMatch[1], ENT_QUOTES | ENT_HTML5, 'UTF-8'); + + return $value === '' ? null : $value; + } + + /** + * Read and cast a meta value to int. + */ + public function getInt(string $html, string $property): ?int + { + $value = $this->get($html, $property); + + return $value !== null && preg_match('/^\d+/', $value) + ? (int) $value + : null; + } + + /** + * Parse a meta date string (`2026-07-01T19:28:15+03:00`) to a Unix + * timestamp, preserving the declared timezone. + */ + public function getTimestamp(string $html, string $property): ?int + { + $value = $this->get($html, $property); + if ($value === null) { + return null; + } + + $ts = strtotime($value); + + return $ts === false ? null : $ts; + } + + /** + * Parse a comma-separated meta value (`video:tag`) into a list. + * + * @return string[] + */ + public function getList(string $html, string $property): array + { + $value = $this->get($html, $property); + if ($value === null) { + return []; + } + + $parts = array_map('trim', explode(',', $value)); + + return array_values(array_filter($parts, static fn ($p) => $p !== '')); + } + + /** + * Interpret a boolean-ish meta value (`"true"`/`"false"`). + */ + public function getBool(string $html, string $property): ?bool + { + $value = $this->get($html, $property); + if ($value === null) { + return null; + } + + return strtolower($value) === 'true'; + } +} diff --git a/src/Parsing/OkRuUrlParser.php b/src/Parsing/OkRuUrlParser.php new file mode 100644 index 0000000..ef85e9d --- /dev/null +++ b/src/Parsing/OkRuUrlParser.php @@ -0,0 +1,53 @@ +` segment. + */ +final class OkRuUrlParser +{ + /** + * @throws ParseFailedException when the URL does not match any known shape + */ + public function parse(string $url): VideoRef + { + // Canonical and embed forms expose the id in the path. + if (preg_match('#(?:video(?:embed)?|live|moviePlayer)/(\d+(?:-\d+)?)#i', $url, $m)) { + return $this->makeRef($m[1]); + } + + // Legacy deep-link form: st.mvId= in the query string. + if (preg_match('/st\.mvId=(\d+(?:-\d+)?)/i', $url, $m)) { + return $this->makeRef($m[1]); + } + + throw new ParseFailedException("URL does not look like an OK.ru video: {$url}"); + } + + private function makeRef(string $id): VideoRef + { + return new VideoRef( + platform: Platform::OK, + id: $id, + ); + } +} diff --git a/src/Parsing/RutubeUrlParser.php b/src/Parsing/RutubeUrlParser.php new file mode 100644 index 0000000..5a90913 --- /dev/null +++ b/src/Parsing/RutubeUrlParser.php @@ -0,0 +1,76 @@ +extractPrivateToken($url); + + return new VideoRef( + platform: Platform::RUTUBE, + id: $id, + query: $query, + ); + } + + // Numeric embed id: rutube.ru/video/embed/6722881 — requires a + // play/options lookup to resolve the 32-hex `effective_video`. + if (preg_match('#/embed/(\d+)#i', $url, $m)) { + return new VideoRef( + platform: Platform::RUTUBE, + id: $m[1], + type: 'embed_numeric', + ); + } + + throw new ParseFailedException("URL does not look like a Rutube video: {$url}"); + } + + /** + * @return array + */ + private function extractPrivateToken(string $url): array + { + $queryStr = (string) parse_url($url, PHP_URL_QUERY); + if ($queryStr === '') { + return []; + } + parse_str($queryStr, $query); + + if (isset($query['p']) && is_string($query['p'])) { + return ['p' => $query['p']]; + } + + return []; + } +} diff --git a/src/Parsing/VkUrlParser.php b/src/Parsing/VkUrlParser.php new file mode 100644 index 0000000..b531117 --- /dev/null +++ b/src/Parsing/VkUrlParser.php @@ -0,0 +1,110 @@ +parseEmbed($url); + } + + // Canonical / redirect form: vkvideo.ru/video-{oid}_{id}, clip-, m./new./vksport. + if (preg_match('#(?:video|clip)(-?\d+_\d+)#i', $url, $m)) { + $type = stripos($url, 'clip') !== false ? self::TYPE_CLIP : self::TYPE_VIDEO; + $fullId = $m[1]; + + return $this->makeRef($fullId, $type, $this->extractListParam($url)); + } + + throw new ParseFailedException("URL does not look like a VK video: {$url}"); + } + + /** + * Parse the embed endpoint `video_ext.php?oid=...&id=...&hash=...`. + */ + private function parseEmbed(string $url): VideoRef + { + $queryStr = (string) parse_url($url, PHP_URL_QUERY); + parse_str($queryStr, $query); + + $oid = $query['oid'] ?? null; + $id = $query['id'] ?? null; + + if (!is_numeric($oid) || !is_numeric($id)) { + throw new ParseFailedException("Embed URL missing oid/id: {$url}"); + } + + $fullId = sprintf('%s_%s', $oid, $id); + + return new VideoRef( + platform: Platform::VK, + id: $fullId, + oid: (string) $oid, + hash: isset($query['hash']) ? (string) $query['hash'] : null, + type: self::TYPE_EMBED, + query: array_filter($query, fn ($v) => is_scalar($v)), + ); + } + + private function makeRef(string $fullId, string $type, array $query): VideoRef + { + [$oid, $id] = explode('_', $fullId, 2) + [1 => null]; + + return new VideoRef( + platform: Platform::VK, + id: $fullId, + oid: $oid, + type: $type, + query: $query, + ); + } + + /** + * Capture the optional `list` query param used by some videos. + * + * @return array + */ + private function extractListParam(string $url): array + { + $queryStr = (string) parse_url($url, PHP_URL_QUERY); + if ($queryStr === '') { + return []; + } + parse_str($queryStr, $query); + + if (isset($query['list']) && is_string($query['list'])) { + return ['list' => $query['list']]; + } + + return []; + } +} diff --git a/src/Platform/Platform.php b/src/Platform/Platform.php new file mode 100644 index 0000000..ae69bf6 --- /dev/null +++ b/src/Platform/Platform.php @@ -0,0 +1,15 @@ +parseHost($url); + + if ($host === null) { + throw new PlatformNotSupportedException("Could not parse a host from URL: {$url}"); + } + + foreach (self::VK_HOSTS as $fragment) { + if ($host === $fragment || str_ends_with($host, '.' . $fragment)) { + return Platform::VK; + } + } + + foreach (self::RUTUBE_HOSTS as $fragment) { + if ($host === $fragment || str_ends_with($host, '.' . $fragment)) { + return Platform::RUTUBE; + } + } + + foreach (self::OK_HOSTS as $fragment) { + if ($host === $fragment || str_ends_with($host, '.' . $fragment)) { + return Platform::OK; + } + } + + throw new PlatformNotSupportedException("Unsupported video host: {$host}"); + } + + private function parseHost(string $url): ?string + { + // Ensure there is a scheme so parse_url can extract a host reliably. + if (!preg_match('#^[a-z]+://#i', $url)) { + $url = 'https://' . $url; + } + + $host = parse_url($url, PHP_URL_HOST); + + return $host === false ? null : strtolower((string) $host); + } +} diff --git a/src/Strategies/OkRu/OkRuStrategy.php b/src/Strategies/OkRu/OkRuStrategy.php new file mode 100644 index 0000000..6183a58 --- /dev/null +++ b/src/Strategies/OkRu/OkRuStrategy.php @@ -0,0 +1,357 @@ +` attribute holding an HTML-entity- + * encoded JSON. Decoding pipeline: + * regex -> html_entity_decode -> json_decode (outer) + * -> json_decode(flashvars.metadata) (inner) + * Yields `movie.poster`, `videos[]`, `hlsManifestUrl`, `likeCount`, etc. + * + * 2. (fallback) Open Graph / `ya:ovs` `` tags in the head, which are + * populated even for external/restricted videos where the player payload is + * absent. Provides thumbnail + basic metadata (no formats). + * + * The legacy `m.ok.ru` mobile template (`data-video`) is deprecated — it now + * serves the same page as desktop — so OG meta is the meaningful fallback. + */ +final class OkRuStrategy implements StrategyInterface +{ + private const VIDEO_URL = 'https://ok.ru/video/'; + + /** @var array OK.ru quality names -> heights */ + private const QUALITY_HEIGHTS = [ + 'mobile' => 144, + 'lowest' => 240, + 'low' => 360, + 'sd' => 480, + 'hd' => 720, + 'full' => 1080, + 'full_hd' => 1080, + 'quad_hd' => 1440, + 'ultra_hd' => 2160, + ]; + + public function __construct( + private readonly HttpClient $http, + private readonly MetaTagParser $metaTags = new MetaTagParser(), + ) {} + + public function extract(VideoRef $ref): VideoInfoDto + { + $html = $this->fetchHtml($ref); + + // Try the rich player payload first. + $metadata = $this->parseDataOptions($html); + + if ($metadata !== null) { + return $this->mapFromMetadata($ref, $html, $metadata); + } + + // Fall back to Open Graph / ya:ovs meta tags (no formats available). + return $this->mapFromMeta($ref, $html); + } + + public function extractThumbnail(VideoRef $ref): ?string + { + $html = $this->fetchHtml($ref); + + // Prefer the player poster; fall back to og:image. + $metadata = $this->parseDataOptions($html); + if ($metadata !== null) { + $poster = self::readStringPath($metadata, ['movie', 'poster']); + if ($poster !== null) { + return $poster; + } + } + + return $this->metaTags->get($html, 'og:image'); + } + + /** + * Fetch the video page and surface availability errors. + * + * @throws VideoNotFoundException|LoginRequiredException + */ + private function fetchHtml(VideoRef $ref): string + { + $response = $this->http->get(self::VIDEO_URL . $ref->id); + + if ($response->status() === 404) { + throw new VideoNotFoundException("OK.ru video not found: {$ref->id}"); + } + + $html = $this->http->bodyUtf8($response); + + $this->guardErrors($html, $ref->id); + + return $html; + } + + /** + * Detect the stub error block and access-restriction notices. + */ + private function guardErrors(string $html, string $id): void + { + if (preg_match('/class="[^"]*vp_video_stub_txt[^"]*"[^>]*>([^<]+)|null null when absent or unparseable + */ + private function parseDataOptions(string $html): ?array + { + if (preg_match('/data-options="([^"]*)"/', $html, $attrMatch) !== 1) { + return null; + } + + $decoded = html_entity_decode($attrMatch[1], ENT_QUOTES | ENT_HTML5, 'UTF-8'); + + $outer = json_decode($decoded, true); + if (!is_array($outer)) { + return null; + } + + $flashvars = is_array($outer['flashvars'] ?? null) ? $outer['flashvars'] : []; + + // Prefer the inline metadata string; fall back to metadataUrl (unused here). + $metadataRaw = is_string($flashvars['metadata'] ?? null) ? $flashvars['metadata'] : null; + if ($metadataRaw === null) { + return null; + } + + $metadata = json_decode($metadataRaw, true); + + return is_array($metadata) ? $metadata : null; + } + + /** + * Build the DTO from the parsed player metadata, supplementing fields the + * player does not carry (views, tags, upload date, description) with meta + * tags. + * + * @param array $metadata + */ + private function mapFromMetadata(VideoRef $ref, string $html, array $metadata): VideoInfoDto + { + $movie = is_array($metadata['movie'] ?? null) ? $metadata['movie'] : []; + $author = is_array($metadata['author'] ?? null) ? $metadata['author'] : []; + + $formats = $this->extractFormats($metadata); + $poster = self::readStringPath($movie, ['poster']) ?? $this->metaTags->get($html, 'og:image'); + + $thumbnails = []; + if ($poster !== null) { + $thumbnails[] = new ThumbnailDto( + $poster, + $this->metaTags->getInt($html, 'og:image:width'), + $this->metaTags->getInt($html, 'og:image:height'), + ); + } + + return new VideoInfoDto( + platform: Platform::OK, + id: $ref->id, + title: self::readStringPath($movie, ['title']) ?? $this->metaTags->get($html, 'og:title'), + description: $this->metaTags->get($html, 'og:description'), + duration: self::readInt($movie, 'duration') ?? $this->metaTags->getInt($html, 'og:video:duration'), + uploader: self::readStringPath($metadata, ['compilationTitle']) + ?? self::readStringPath($author, ['name']) + ?? $this->metaTags->get($html, 'ya:ovs:login'), + uploaderId: self::readStringPath($author, ['id']), + timestamp: $this->metaTags->getTimestamp($html, 'ya:ovs:upload_date'), + viewCount: $this->metaTags->getInt($html, 'ya:ovs:views_total'), + likeCount: self::readInt($metadata, 'likeCount') ?? $this->metaTags->getInt($html, 'ya:ovs:likes'), + commentCount: $this->metaTags->getInt($html, 'ya:ovs:comments'), + ageLimit: ($this->metaTags->getBool($html, 'ya:ovs:adult') ?? false) ? 18 : 0, + isLive: self::readBool($movie, 'isLive'), + thumbnails: $thumbnails, + formats: $formats, + categories: [], + formatExpiresAt: $this->earliestExpiry($formats), + ); + } + + /** + * Build a formats-only-from-meta DTO (no playable streams available). + */ + private function mapFromMeta(VideoRef $ref, string $html): VideoInfoDto + { + $image = $this->metaTags->get($html, 'og:image'); + + $thumbnails = []; + if ($image !== null) { + $thumbnails[] = new ThumbnailDto( + $image, + $this->metaTags->getInt($html, 'og:image:width'), + $this->metaTags->getInt($html, 'og:image:height'), + ); + } + + return new VideoInfoDto( + platform: Platform::OK, + id: $ref->id, + title: $this->metaTags->get($html, 'og:title'), + description: $this->metaTags->get($html, 'og:description'), + duration: $this->metaTags->getInt($html, 'og:video:duration'), + uploader: $this->metaTags->get($html, 'ya:ovs:login'), + timestamp: $this->metaTags->getTimestamp($html, 'ya:ovs:upload_date'), + viewCount: $this->metaTags->getInt($html, 'ya:ovs:views_total'), + likeCount: $this->metaTags->getInt($html, 'ya:ovs:likes'), + commentCount: $this->metaTags->getInt($html, 'ya:ovs:comments'), + ageLimit: ($this->metaTags->getBool($html, 'ya:ovs:adult') ?? false) ? 18 : 0, + thumbnails: $thumbnails, + categories: [], + ); + } + + /** + * Collect mp4 + HLS formats from the metadata. + * + * @param array $metadata + * @return FormatDto[] + */ + private function extractFormats(array $metadata): array + { + $formats = []; + + foreach (is_array($metadata['videos'] ?? null) ? $metadata['videos'] : [] as $video) { + if (!is_array($video)) { + continue; + } + $url = self::str($video, 'url'); + $name = self::str($video, 'name'); + if ($url === null || $name === null) { + continue; + } + $formats[] = new FormatDto( + url: $url, + formatId: $name, + ext: 'mp4', + height: self::QUALITY_HEIGHTS[$name] ?? null, + expiresAt: $this->parseExpiresSeconds($url), + ); + } + + $hls = self::str($metadata, 'hlsManifestUrl') + ?? self::str($metadata, 'ondemandHls'); + if ($hls !== null) { + $formats[] = new FormatDto($hls, 'hls', 'mp4', null, $this->parseExpiresSeconds($hls)); + } + + return $formats; + } + + /** + * @param FormatDto[] $formats + */ + private function earliestExpiry(array $formats): ?int + { + $earliest = null; + foreach ($formats as $format) { + if ($format->expiresAt !== null && ($earliest === null || $format->expiresAt < $earliest)) { + $earliest = $format->expiresAt; + } + } + + return $earliest; + } + + /** + * OK.ru `expires=` is in milliseconds; normalise to seconds. + */ + private function parseExpiresSeconds(string $url): ?int + { + $queryStr = (string) parse_url($url, PHP_URL_QUERY); + if ($queryStr === '') { + return null; + } + parse_str($queryStr, $query); + $expires = $query['expires'] ?? null; + if (!is_numeric($expires)) { + return null; + } + + $value = (int) $expires; + + return $value > 1_000_000_000_000 ? intdiv($value, 1000) : $value; + } + + /** + * @param array $array + * @param array $path + */ + private static function readStringPath(array $array, array $path): ?string + { + $current = $array; + foreach ($path as $key) { + if (!is_array($current) || !array_key_exists($key, $current)) { + return null; + } + $current = $current[$key]; + } + + return is_string($current) && $current !== '' ? $current : null; + } + + /** + * @param array $array + */ + private static function readInt(array $array, string|int $key): ?int + { + $value = $array[$key] ?? null; + + return is_numeric($value) ? (int) $value : null; + } + + /** + * @param array $array + */ + private static function readBool(array $array, string|int $key): ?bool + { + $value = $array[$key] ?? null; + + return is_bool($value) ? $value : null; + } + + /** + * @param array $array + */ + private static function str(array $array, string|int $key): ?string + { + $value = $array[$key] ?? null; + + return is_string($value) && $value !== '' ? $value : null; + } +} diff --git a/src/Strategies/Rutube/RutubeApiStrategy.php b/src/Strategies/Rutube/RutubeApiStrategy.php new file mode 100644 index 0000000..8d9aa26 --- /dev/null +++ b/src/Strategies/Rutube/RutubeApiStrategy.php @@ -0,0 +1,272 @@ +fetchMeta($ref); + + $formats = []; + $formatExpiresAt = null; + try { + $options = $this->fetchOptions($ref); + $formats = $this->extractFormats($options); + $formatExpiresAt = $this->earliestFormatExpiry($formats); + } catch (VideoNotFoundException) { + // Options may 404 for live/private streams; metadata is still useful. + } + + return $this->mapMeta($ref, $meta, $formats, $formatExpiresAt); + } + + public function extractThumbnail(VideoRef $ref): ?string + { + return $this->str($this->fetchMeta($ref), 'thumbnail_url'); + } + + /** + * @return array + * + * @throws VideoNotFoundException + */ + private function fetchMeta(VideoRef $ref): array + { + $response = $this->http->get(self::META_URL . $ref->id . '/', [ + 'format' => 'json', + ...$ref->query, + ]); + + if ($response->status() === 404) { + throw new VideoNotFoundException("Rutube video not found: {$ref->id}"); + } + $response->throw(); + + return $response->json() ?? []; + } + + /** + * @return array + * + * @throws VideoNotFoundException + * @throws GeoBlockedException + */ + private function fetchOptions(VideoRef $ref): array + { + $response = $this->http->get(self::OPTIONS_URL . $ref->id . '/', [ + 'format' => 'json', + ...$ref->query, + ]); + + if ($response->status() === 404) { + throw new VideoNotFoundException("Rutube options not found: {$ref->id}"); + } + $response->throw(); + + $data = $response->json() ?? []; + + $this->guardAccess($data, $ref->id); + + return $data; + } + + /** + * Raise {@see GeoBlockedException} when the access verdict is negative. + * + * @param array $options + */ + private function guardAccess(array $options, string $id): void + { + $acl = is_array($options['acl_access'] ?? null) ? $options['acl_access'] : []; + $allowed = $acl['allowed'] ?? null; + if ($allowed === false) { + $code = $acl['err_code'] ?? null; + $text = is_string($acl['err_text'] ?? null) ? $acl['err_text'] : ''; + + throw new GeoBlockedException( + $text !== '' ? "Rutube access denied ({$code}): {$text}" : "Rutube access denied for video: {$id}", + ); + } + } + + /** + * @param array $meta + * @param FormatDto[] $formats + */ + private function mapMeta(VideoRef $ref, array $meta, array $formats, ?int $formatExpiresAt): VideoInfoDto + { + $author = is_array($meta['author'] ?? null) ? $meta['author'] : []; + $category = is_array($meta['category'] ?? null) ? $meta['category'] : []; + $categoryName = is_string($category['name'] ?? null) ? (string) $category['name'] : null; + + $thumbnails = []; + $thumbUrl = $this->str($meta, 'thumbnail_url'); + if ($thumbUrl !== null) { + $thumbnails[] = new ThumbnailDto($thumbUrl); + } + + return new VideoInfoDto( + platform: Platform::RUTUBE, + id: $ref->id, + title: $this->str($meta, 'title'), + description: $this->str($meta, 'description'), + duration: $this->int($meta, 'duration'), + uploader: $this->str($author, 'name'), + uploaderId: $author['id'] !== null ? (string) $author['id'] : null, + timestamp: $this->parseTimestamp($this->str($meta, 'created_ts')), + viewCount: $this->int($meta, 'hits'), + likeCount: null, + commentCount: null, + ageLimit: $meta['is_adult'] === true ? 18 : 0, + isLive: $meta['is_livestream'] === true, + thumbnails: $thumbnails, + formats: $formats, + categories: $categoryName !== null ? [$categoryName] : [], + formatExpiresAt: $formatExpiresAt, + ); + } + + /** + * Rutube exposes a master HLS playlist in `video_balancer` (keys `default` + * and `m3u8`, usually identical). Live streams additionally provide + * `live_streams.hls[].url`. + * + * @param array $options + * @return FormatDto[] + */ + private function extractFormats(array $options): array + { + $formats = []; + $seen = []; + + $balancer = is_array($options['video_balancer'] ?? null) ? $options['video_balancer'] : []; + foreach (['m3u8', 'default'] as $key) { + $url = $this->str($balancer, $key); + if ($url !== null && !isset($seen[$url])) { + $seen[$url] = true; + $formats[] = new FormatDto( + url: $url, + formatId: 'hls_' . $key, + ext: 'mp4', + height: null, + expiresAt: $this->parseExpireSeconds($url), + ); + } + } + + $liveStreams = is_array($options['live_streams'] ?? null) ? $options['live_streams'] : []; + foreach (is_array($liveStreams['hls'] ?? null) ? $liveStreams['hls'] : [] as $stream) { + if (!is_array($stream)) { + continue; + } + $url = $this->str($stream, 'url'); + if ($url !== null && !isset($seen[$url])) { + $seen[$url] = true; + $formats[] = new FormatDto( + url: $url, + formatId: 'hls_live', + ext: 'mp4', + height: null, + expiresAt: $this->parseExpireSeconds($url), + ); + } + } + + return $formats; + } + + /** + * @param FormatDto[] $formats + */ + private function earliestFormatExpiry(array $formats): ?int + { + $earliest = null; + foreach ($formats as $format) { + if ($format->expiresAt !== null && ($earliest === null || $format->expiresAt < $earliest)) { + $earliest = $format->expiresAt; + } + } + + return $earliest; + } + + /** + * Rutube `expire=` is a Unix timestamp in seconds. + */ + private function parseExpireSeconds(string $url): ?int + { + $queryStr = (string) parse_url($url, PHP_URL_QUERY); + if ($queryStr === '') { + return null; + } + parse_str($queryStr, $query); + $expire = $query['expire'] ?? null; + + return is_numeric($expire) ? (int) $expire : null; + } + + /** + * Rutube timestamps look like `2025-08-06T14:23:14` (no timezone suffix, + * server is Europe/Moscow). We treat them as UTC for portability. + */ + private function parseTimestamp(?string $value): ?int + { + if ($value === null) { + return null; + } + $ts = strtotime($value . ' UTC'); + + return $ts === false ? null : $ts; + } + + /** + * @param array $array + */ + private function str(array $array, string|int $key): ?string + { + $value = $array[$key] ?? null; + + return is_string($value) && $value !== '' ? $value : null; + } + + /** + * @param array $array + */ + private function int(array $array, string|int $key): ?int + { + $value = $array[$key] ?? null; + + return is_numeric($value) ? (int) $value : null; + } +} diff --git a/src/Strategies/StrategyInterface.php b/src/Strategies/StrategyInterface.php new file mode 100644 index 0000000..3c0c850 --- /dev/null +++ b/src/Strategies/StrategyInterface.php @@ -0,0 +1,22 @@ + map of `url` params to heights */ + private const URL_HEIGHTS = [ + 'url144' => 144, + 'url240' => 240, + 'url360' => 360, + 'url480' => 480, + 'url720' => 720, + 'url1080' => 1080, + 'url1440' => 1440, + 'url2160' => 2160, + ]; + + public function __construct( + private readonly HttpClient $http, + ) {} + + public function extract(VideoRef $ref): VideoInfoDto + { + $data = $this->fetchParams($ref); + + $mvData = is_array($data['_mvData'] ?? null) ? $data['_mvData'] : []; + + return new VideoInfoDto( + platform: Platform::VK, + id: $ref->id, + title: self::str($mvData, 'title') ?? self::str($data, 'md_title'), + description: self::str($mvData, 'desc') ?? self::str($data, 'description'), + duration: self::int($mvData, 'duration') ?? self::int($data, 'duration'), + uploader: self::str($data, 'md_author'), + uploaderId: self::str($data, 'author_id'), + timestamp: self::int($data, 'date'), + viewCount: null, + likeCount: self::int($mvData, 'likes'), + commentCount: self::int($mvData, 'commcount'), + isLive: self::int($data, 'live') === 2, + thumbnails: $this->extractThumbnails($data), + formats: $this->extractFormats($data), + categories: [], + formatExpiresAt: null, + ); + } + + public function extractThumbnail(VideoRef $ref): ?string + { + return self::str($this->fetchParams($ref), 'jpg'); + } + + /** + * Perform the POST and return the resolved `player.params[0]` array, + * alongside the `mvData` metadata under the `_mvData` key. + * + * @return array + * + * @throws LoginRequiredException payload code 3 + * @throws VideoNotFoundException payload code 8 + */ + private function fetchParams(VideoRef $ref): array + { + $response = $this->http->post(self::ENDPOINT, [ + 'act' => 'show', + 'al' => 1, + 'video' => $ref->id, + ...($ref->query !== [] ? ['list' => $ref->query['list'] ?? null] : []), + ], [ + 'Referer' => 'https://vk.com/', + 'X-Requested-With' => 'XMLHttpRequest', + ]); + + $payload = json_decode($this->http->bodyUtf8($response, 'Windows-1251'), true); + + if (!is_array($payload) || !isset($payload['payload']) || !is_array($payload['payload'])) { + throw new VideoNotFoundException("Unexpected VK al_video response for {$ref->id}"); + } + + [$code, $body] = $payload['payload']; + $code = (int) $code; + + if ($code === 3) { + throw new LoginRequiredException("VK video requires login: {$ref->id}"); + } + if ($code === 8) { + // payload body[0] holds the raw quoted message, e.g. + // "\"Video has been deleted.\"" -> trim the surrounding quotes. + $message = isset($body[0]) && is_string($body[0]) ? trim($body[0], '"') : ''; + throw new VideoNotFoundException( + $message !== '' ? "VK video unavailable: {$message}" : "VK video unavailable: {$ref->id}", + ); + } + + $opts = is_array($body) && isset($body[4]) && is_array($body[4]) ? $body[4] : []; + $player = is_array($opts['player'] ?? null) ? $opts['player'] : []; + $params = is_array($player['params'] ?? null) ? $player['params'] : []; + $params0 = is_array($params[0] ?? null) ? $params[0] : []; + + // Attach mvData for the caller without polluting the params namespace. + $params0['_mvData'] = is_array($opts['mvData'] ?? null) ? $opts['mvData'] : []; + + return $params0; + } + + /** + * @param array $data + * @return ThumbnailDto[] + */ + private function extractThumbnails(array $data): array + { + $jpg = self::str($data, 'jpg'); + if ($jpg === null) { + return []; + } + + return [new ThumbnailDto($jpg)]; + } + + /** + * @param array $data + * @return FormatDto[] + */ + private function extractFormats(array $data): array + { + $formats = []; + foreach (self::URL_HEIGHTS as $key => $height) { + $url = self::str($data, $key); + if ($url !== null) { + $formats[] = new FormatDto( + url: $url, + formatId: $key, + ext: 'mp4', + height: $height, + expiresAt: self::parseExpiresSeconds($url), + ); + } + } + + $hls = self::str($data, 'hls'); + if ($hls !== null) { + $formats[] = new FormatDto($hls, 'hls', 'mp4', null, self::parseExpiresSeconds($hls)); + } + + $dash = self::str($data, 'dash_sep'); + if ($dash !== null) { + $formats[] = new FormatDto($dash, 'dash_sep', 'mp4', null, self::parseExpiresSeconds($dash)); + } + + return $formats; + } +} diff --git a/src/Strategies/Vk/InteractsWithVkPayload.php b/src/Strategies/Vk/InteractsWithVkPayload.php new file mode 100644 index 0000000..3b83e10 --- /dev/null +++ b/src/Strategies/Vk/InteractsWithVkPayload.php @@ -0,0 +1,107 @@ + $array + */ + private static function str(array $array, string|int $key): ?string + { + $value = $array[$key] ?? null; + + return is_string($value) && $value !== '' ? $value : null; + } + + /** + * @param array $array + */ + private static function int(array $array, string|int $key): ?int + { + $value = $array[$key] ?? null; + + return is_numeric($value) ? (int) $value : null; + } + + /** + * @param array $array + * @param array $path + * @return array + */ + private static function readArrayPath(array $array, array $path): array + { + $current = $array; + foreach ($path as $key) { + if (!is_array($current) || !array_key_exists($key, $current)) { + return []; + } + $current = $current[$key]; + } + + return is_array($current) ? $current : []; + } + + /** + * @param array $array + * @param array $path + */ + private static function readStringPath(array $array, array $path): ?string + { + $current = $array; + foreach ($path as $key) { + if (!is_array($current) || !array_key_exists($key, $current)) { + return null; + } + $current = $current[$key]; + } + + return is_string($current) && $current !== '' ? $current : null; + } + + /** + * @param array $array + * @param array $path + */ + private static function intPath(array $array, array $path): ?int + { + $current = $array; + foreach ($path as $key) { + if (!is_array($current) || !array_key_exists($key, $current)) { + return null; + } + $current = $current[$key]; + } + + return is_numeric($current) ? (int) $current : null; + } + + /** + * VK `expires=` is in milliseconds; convert to seconds when it looks like + * a millisecond timestamp. + */ + private static function parseExpiresSeconds(string $url): ?int + { + $queryStr = (string) parse_url($url, PHP_URL_QUERY); + if ($queryStr === '') { + return null; + } + parse_str($queryStr, $query); + $expires = $query['expires'] ?? null; + if (!is_numeric($expires)) { + return null; + } + + $value = (int) $expires; + + return $value > 1_000_000_000_000 ? intdiv($value, 1000) : $value; + } +} diff --git a/src/Strategies/Vk/VideoExtStrategy.php b/src/Strategies/Vk/VideoExtStrategy.php new file mode 100644 index 0000000..687b665 --- /dev/null +++ b/src/Strategies/Vk/VideoExtStrategy.php @@ -0,0 +1,215 @@ + map of `mp4_` file keys to heights */ + private const MP4_HEIGHTS = [ + 'mp4_144' => 144, + 'mp4_240' => 240, + 'mp4_360' => 360, + 'mp4_480' => 480, + 'mp4_720' => 720, + 'mp4_1080' => 1080, + 'mp4_1440' => 1440, + 'mp4_2160' => 2160, + ]; + + public function __construct( + private readonly HttpClient $http, + private readonly EmbeddedJsonExtractor $jsonExtractor = new EmbeddedJsonExtractor(), + ) {} + + public function extract(VideoRef $ref): VideoInfoDto + { + $item = $this->fetchItem($ref); + + /** @var array $item */ + return new VideoInfoDto( + platform: Platform::VK, + id: $ref->id, + title: self::str($item, 'title'), + description: self::str($item, 'description'), + duration: self::int($item, 'duration'), + uploader: self::readStringPath($item, ['action_button', 'snippet', 'title']), + uploaderId: self::str($item, 'owner_id'), + timestamp: self::int($item, 'date'), + viewCount: self::int($item, 'views'), + likeCount: self::intPath($item, ['likes', 'count']), + commentCount: self::int($item, 'comments'), + isLive: false, + thumbnails: $this->extractThumbnails($item), + formats: $this->extractFormats(self::readArrayPath($item, ['files'])), + categories: [], + formatExpiresAt: null, + ); + } + + public function extractThumbnail(VideoRef $ref): ?string + { + $item = $this->fetchItem($ref); + + $images = self::readArrayPath($item, ['image']); + if ($images === []) { + $images = self::readArrayPath($item, ['first_frame']); + } + + $best = null; + foreach ($images as $entry) { + if (!is_array($entry)) { + continue; + } + $url = self::str($entry, 'url'); + if ($url === null) { + continue; + } + $thumb = new ThumbnailDto($url, self::int($entry, 'width'), self::int($entry, 'height')); + if ($best === null || $thumb->area() > $best->area()) { + $best = $thumb; + } + } + + return $best?->url; + } + + /** + * Fetch the video item array, throwing when the video is unavailable. + * + * @return array + * + * @throws VideoNotFoundException + */ + private function fetchItem(VideoRef $ref): array + { + [$oid, $id] = self::splitOidId($ref); + + $response = $this->http->get(self::EMBED_URL, array_filter([ + 'oid' => $oid, + 'id' => $id, + 'hash' => $ref->hash, + ])); + + $html = $this->http->bodyUtf8($response, 'Windows-1251'); + $cur = $this->jsonExtractor->extractAfterMarker($html, 'window.cur = Object.assign('); + + $cache = self::readArrayPath($cur, ['apiPrefetchCache']); + if ($cache === []) { + throw new VideoNotFoundException("VK video not found: {$ref->id}"); + } + + $item = self::readArrayPath($cache[0], ['response', 'items', 0]); + if ($item === []) { + throw new VideoNotFoundException("VK video has no items: {$ref->id}"); + } + + return $item; + } + + /** + * @param array $item + * @return ThumbnailDto[] + */ + private function extractThumbnails(array $item): array + { + $thumbnails = []; + foreach (['image', 'first_frame'] as $key) { + foreach (self::readArrayPath($item, [$key]) as $entry) { + if (!is_array($entry)) { + continue; + } + $url = self::str($entry, 'url'); + if ($url !== null) { + $thumbnails[] = new ThumbnailDto( + $url, + self::int($entry, 'width'), + self::int($entry, 'height'), + ); + } + } + } + + return $thumbnails; + } + + /** + * @param array $files + * @return FormatDto[] + */ + private function extractFormats(array $files): array + { + $formats = []; + foreach (self::MP4_HEIGHTS as $key => $height) { + $url = self::str($files, $key); + if ($url !== null) { + $formats[] = new FormatDto( + url: $url, + formatId: $key, + ext: 'mp4', + height: $height, + expiresAt: self::parseExpiresSeconds($url), + ); + } + } + + $hls = self::str($files, 'hls'); + if ($hls !== null) { + $formats[] = new FormatDto( + url: $hls, + formatId: 'hls', + ext: 'mp4', + height: null, + expiresAt: self::parseExpiresSeconds($hls), + ); + } + + $dash = self::str($files, 'dash_sep'); + if ($dash !== null) { + $formats[] = new FormatDto($dash, 'dash_sep', 'mp4', null, self::parseExpiresSeconds($dash)); + } + + return $formats; + } + + /** + * @return array{0:string,1:string} + */ + private static function splitOidId(VideoRef $ref): array + { + if ($ref->oid !== null && str_contains($ref->id, '_')) { + [$oid, $id] = explode('_', $ref->id, 2); + + return [$oid, $id]; + } + + return ['', '']; + } +} diff --git a/src/Strategies/Vk/VkStrategyChain.php b/src/Strategies/Vk/VkStrategyChain.php new file mode 100644 index 0000000..c6c8c7d --- /dev/null +++ b/src/Strategies/Vk/VkStrategyChain.php @@ -0,0 +1,45 @@ +primary->extract($ref); + } catch (VideoNotFoundException) { + return $this->fallback->extract($ref); + } + } + + public function extractThumbnail(VideoRef $ref): ?string + { + try { + return $this->primary->extractThumbnail($ref); + } catch (VideoNotFoundException) { + return $this->fallback->extractThumbnail($ref); + } + } +} diff --git a/src/VideoExtractor.php b/src/VideoExtractor.php new file mode 100644 index 0000000..fe2e5c2 --- /dev/null +++ b/src/VideoExtractor.php @@ -0,0 +1,168 @@ + {@see PlatformDetector} -> platform-specific parser -> + * {@see VideoRef} -> {@see StrategyInterface} -> result. For VK the strategy is + * a {@see VkStrategyChain} that retries the rich-thumbnail primary + * ({@see VideoExtStrategy}) through yt-dlp's proven fallback + * ({@see AlVideoStrategy}). + * + * Thumbnail-URL results are cached long-lived (they are stable CDN URLs); + * full extraction is cached with a short TTL because format URLs expire. + */ +final class VideoExtractor +{ + private const CACHE_TAG_THUMBNAIL = 'thumbnail'; + private const CACHE_TAG_INFO = 'info'; + + private ?Cache $cache = null; + + public function __construct( + private readonly HttpClient $http, + private readonly PlatformDetector $detector = new PlatformDetector(), + private readonly VkUrlParser $vkParser = new VkUrlParser(), + private readonly RutubeUrlParser $rutubeParser = new RutubeUrlParser(), + private readonly OkRuUrlParser $okRuParser = new OkRuUrlParser(), + private readonly EmbeddedJsonExtractor $jsonExtractor = new EmbeddedJsonExtractor(), + private readonly bool $cacheEnabled = true, + private readonly int $thumbnailTtl = 7 * 24 * 60 * 60, + private readonly int $infoTtl = 10 * 60, + ) {} + + /** + * Extract full metadata, thumbnails and formats for the given URL. + * + * @throws VideoExtractionException + */ + public function extract(string $url): VideoInfoDto + { + $ref = $this->resolve($url); + + if ($this->cacheEnabled) { + $cached = $this->cache()?->get($ref->cacheKey(self::CACHE_TAG_INFO)); + if ($cached instanceof VideoInfoDto) { + return $cached; + } + } + + $info = $this->strategy($ref)->extract($ref); + + if ($this->cacheEnabled) { + // Cap TTL by the soonest format expiry so stale URLs are never served. + $ttl = $info->formatExpiresAt !== null + ? min($this->infoTtl, max(60, $info->formatExpiresAt - time())) + : $this->infoTtl; + $this->cache()?->put($ref->cacheKey(self::CACHE_TAG_INFO), $info, $ttl); + } + + return $info; + } + + /** + * Lightweight path: fetch only the best thumbnail URL for the given URL. + */ + public function thumbnail(string $url): ?string + { + $ref = $this->resolve($url); + + if ($this->cacheEnabled) { + $key = $ref->cacheKey(self::CACHE_TAG_THUMBNAIL); + if ($this->cache()?->has($key)) { + return $this->cache()->get($key); + } + } + + $thumbnailUrl = $this->strategy($ref)->extractThumbnail($ref); + + if ($this->cacheEnabled && $thumbnailUrl !== null) { + $this->cache()?->put($ref->cacheKey(self::CACHE_TAG_THUMBNAIL), $thumbnailUrl, $this->thumbnailTtl); + } + + return $thumbnailUrl; + } + + /** + * Override the cache repository (used by the service provider to apply a + * configured store). Pass-through to the default store when not called. + */ + public function setCacheRepository(Cache $cache): void + { + $this->cache = $cache; + } + + /** + * Resolve a raw URL into a {@see VideoRef} (platform + id + extras). + * + * @throws VideoExtractionException when the platform is unsupported or the URL is malformed + */ + public function resolve(string $url): VideoRef + { + $platform = $this->detector->detect($url); + + try { + return match ($platform) { + Platform::VK => $this->vkParser->parse($url), + Platform::RUTUBE => $this->rutubeParser->parse($url), + Platform::OK => $this->okRuParser->parse($url), + }; + } catch (ParseFailedException $e) { + throw new VideoExtractionException( + "Could not parse {$platform->value} URL: {$e->getMessage()}", + previous: $e, + ); + } + } + + /** + * Pick the strategy to use for a video reference. + */ + private function strategy(VideoRef $ref): StrategyInterface + { + return match ($ref->platform) { + Platform::VK => new VkStrategyChain( + new VideoExtStrategy($this->http, $this->jsonExtractor), + new AlVideoStrategy($this->http), + ), + Platform::RUTUBE => new RutubeApiStrategy($this->http), + Platform::OK => new OkRuStrategy($this->http), + }; + } + + /** + * Resolve the cache repository, lazily defaulting to the default store. + */ + private function cache(): ?Cache + { + if (! $this->cacheEnabled) { + return null; + } + + return $this->cache ??= CacheFacade::driver(); + } +} diff --git a/src/VideoExtractorServiceProvider.php b/src/VideoExtractorServiceProvider.php new file mode 100644 index 0000000..507a9b7 --- /dev/null +++ b/src/VideoExtractorServiceProvider.php @@ -0,0 +1,61 @@ +mergeConfigFrom(__DIR__ . '/../config/video-extractor.php', 'video-extractor'); + + $this->app->singleton(HttpClient::class, function (Application $app): HttpClient { + $config = $app->make('config')->get('video-extractor.http', []); + + return new HttpClient( + timeout: (int) ($config['timeout'] ?? 30), + userAgent: (string) ($config['user_agent'] ?? HttpClient::DEFAULT_USER_AGENT), + ); + }); + + $this->app->singleton(VideoExtractor::class, function (Application $app): VideoExtractor { + $config = $app->make('config')->get('video-extractor', []); + $cache = $config['cache'] ?? []; + + /** @var HttpClient $http */ + $http = $app->make(HttpClient::class); + + $extractor = new VideoExtractor( + http: $http, + cacheEnabled: (bool) ($cache['enabled'] ?? true), + thumbnailTtl: (int) ($cache['thumbnail_ttl'] ?? 7 * 24 * 60 * 60), + infoTtl: (int) ($cache['info_ttl'] ?? 10 * 60), + ); + + // Resolve a specific cache store when configured. + $store = $cache['store'] ?? null; + if ($store !== null && $store !== '') { + $extractor->setCacheRepository($app->make('cache')->store($store)); + } + + return $extractor; + }); + + $this->app->bind('video-extractor', fn (Application $app): VideoExtractor => $app->make(VideoExtractor::class)); + } + + public function boot(): void + { + if ($this->app->runningInConsole()) { + $this->publishes([ + __DIR__ . '/../config/video-extractor.php' => config_path('video-extractor.php'), + ], 'video-extractor-config'); + } + } +} diff --git a/tests/Feature/OkRuExtractionTest.php b/tests/Feature/OkRuExtractionTest.php new file mode 100644 index 0000000..f6f8d7c --- /dev/null +++ b/tests/Feature/OkRuExtractionTest.php @@ -0,0 +1,132 @@ + Http::response($this->fixture('ok/video_success.html'), 200), + ]); + + $strategy = new OkRuStrategy($this->app->make(HttpClient::class)); + $info = $strategy->extract($this->ref()); + + self::assertSame('15019292494547', $info->id); + self::assertSame('Falcon mystery in the hood', $info->title); + self::assertSame('A radical shift of optics.', $info->description); + self::assertSame(709, $info->duration); + self::assertSame('Egor Akimov', $info->uploader); + self::assertSame(45, $info->likeCount); + self::assertSame(3419, $info->viewCount); + self::assertSame(0, $info->ageLimit); + self::assertFalse($info->isLive); + self::assertSame(strtotime('2026-07-01T19:28:15+03:00'), $info->timestamp); + + // Thumbnail from movie.poster with OG dimensions. + self::assertNotEmpty($info->thumbnails); + self::assertSame(720, $info->bestThumbnail()?->width); + self::assertStringContainsString('iv.okcdn.ru', (string) $info->thumbnailUrl()); + + // 6 mp4 + 1 hls = 7 formats. + self::assertCount(7, $info->formats); + + $full = null; + foreach ($info->formats as $format) { + if ($format->formatId === 'full') { + $full = $format; + } + } + self::assertNotNull($full); + self::assertSame(1080, $full->height); + // expires=1784298004897 ms -> 1784298004 seconds. + self::assertSame(1784298004, $full->expiresAt); + self::assertSame(1784298004, $info->formatExpiresAt); + } + + public function test_thumbnail_fast_path_uses_poster(): void + { + Http::fakeSequence()->push($this->fixture('ok/video_success.html'), 200); + + $strategy = new OkRuStrategy($this->app->make(HttpClient::class)); + $url = $strategy->extractThumbnail($this->ref()); + + self::assertNotNull($url); + self::assertStringContainsString('iv.okcdn.ru', $url); + } + + public function test_falls_back_to_og_meta_when_no_data_options(): void + { + // Page without the inline player payload (external/restricted video). + Http::fake([ + 'ok.ru/video/*' => Http::response($this->fixture('ok/video_meta_only.html'), 200), + ]); + + $strategy = new OkRuStrategy($this->app->make(HttpClient::class)); + $info = $strategy->extract($this->ref()); + + self::assertSame('External embed video', $info->title); + self::assertSame('Some Channel', $info->uploader); + self::assertSame(1529, $info->duration); + self::assertSame(12345, $info->viewCount); + // No formats available from the OG fallback. + self::assertSame([], $info->formats); + // Thumbnail still recovered from og:image. + self::assertStringContainsString('EXTERNAL', (string) $info->thumbnailUrl()); + } + + public function test_thumbnail_falls_back_to_og_image(): void + { + Http::fakeSequence()->push($this->fixture('ok/video_meta_only.html'), 200); + + $strategy = new OkRuStrategy($this->app->make(HttpClient::class)); + $url = $strategy->extractThumbnail($this->ref()); + + self::assertSame('https://iv.okcdn.ru/i?r=EXTERNAL&fn=external_8', $url); + } + + public function test_throws_not_found_for_stub_error(): void + { + Http::fake([ + 'ok.ru/video/*' => Http::response($this->fixture('ok/video_not_found.html'), 200), + ]); + + $strategy = new OkRuStrategy($this->app->make(HttpClient::class)); + + $this->expectException(VideoNotFoundException::class); + $this->expectExceptionMessage('author of this video has not been found'); + $strategy->extract($this->ref()); + } + + public function test_throws_login_required_for_restricted_video(): void + { + Http::fake([ + 'ok.ru/video/*' => Http::response( + '
Access to this video is restricted
', + 200, + ), + ]); + + $strategy = new OkRuStrategy($this->app->make(HttpClient::class)); + + $this->expectException(LoginRequiredException::class); + $strategy->extract($this->ref()); + } +} diff --git a/tests/Feature/RutubeExtractionTest.php b/tests/Feature/RutubeExtractionTest.php new file mode 100644 index 0000000..004b262 --- /dev/null +++ b/tests/Feature/RutubeExtractionTest.php @@ -0,0 +1,124 @@ + Http::response($this->fixture('rutube/video_meta.json'), 200), + 'rutube.ru/api/play/options/*' => Http::response($this->fixture('rutube/play_options.json'), 200), + ]); + + $strategy = new RutubeApiStrategy($this->app->make(HttpClient::class)); + $info = $strategy->extract($this->ref()); + + self::assertSame('373ba417757981facf06c55ea5c46cb4', $info->id); + self::assertSame('First words of a toddler. Big collection.', $info->title); + self::assertSame(3756, $info->duration); + self::assertSame(6048782, $info->viewCount); + self::assertSame('Alena Vasilieva', $info->uploader); + self::assertSame('40529538', $info->uploaderId); + self::assertSame(['Education'], $info->categories); + self::assertSame(0, $info->ageLimit); + self::assertFalse($info->isLive); + + // created_ts "2025-08-06T14:23:14" treated as UTC. + self::assertSame(strtotime('2025-08-06T14:23:14 UTC'), $info->timestamp); + + // Thumbnail URL is stable. + self::assertStringContainsString('pic.rtbcdn.ru/video', (string) $info->thumbnailUrl()); + + // Formats: video_balancer has default+m3u8 (deduplicated to one) -> 1 format. + self::assertCount(1, $info->formats); + self::assertSame('hls_m3u8', $info->formats[0]->formatId); + self::assertSame(1784295930, $info->formats[0]->expiresAt); + self::assertSame(1784295930, $info->formatExpiresAt); + } + + public function test_thumbnail_only_uses_single_request(): void + { + Http::fakeSequence()->push( + $this->fixture('rutube/video_meta.json'), + 200, + ); + + $strategy = new RutubeApiStrategy($this->app->make(HttpClient::class)); + $url = $strategy->extractThumbnail($this->ref()); + + self::assertSame('https://pic.rtbcdn.ru/video/2025-08-07/0a/5f/0a5f3ef60eb641935f678c5f543a92fe.jpg', $url); + } + + public function test_throws_when_meta_returns_404(): void + { + Http::fake([ + 'rutube.ru/api/video/*' => Http::response($this->fixture('rutube/video_meta_404.json'), 404), + ]); + + $strategy = new RutubeApiStrategy($this->app->make(HttpClient::class)); + + $this->expectException(VideoNotFoundException::class); + $strategy->extract($this->ref()); + } + + public function test_throws_geo_blocked_when_acl_denies(): void + { + Http::fake([ + 'rutube.ru/api/video/*' => Http::response($this->fixture('rutube/video_meta.json'), 200), + 'rutube.ru/api/play/options/*' => Http::response( + $this->fixture('rutube/play_options_geo_blocked.json'), + 200, + ), + ]); + + $strategy = new RutubeApiStrategy($this->app->make(HttpClient::class)); + + $this->expectException(GeoBlockedException::class); + $this->expectExceptionMessage('Not available in your region'); + $strategy->extract($this->ref()); + } + + public function test_private_token_is_forwarded_to_api(): void + { + Http::fake([ + 'rutube.ru/api/video/*' => Http::response($this->fixture('rutube/video_meta.json'), 200), + ]); + + Http::fakeSequence()->push($this->fixture('rutube/video_meta.json'), 200); + + $ref = new VideoRef( + platform: Platform::RUTUBE, + id: '884fb55f07a97ab673c7d654553e0f48', + query: ['p' => 'PRIVATE_TOKEN'], + ); + + $strategy = new RutubeApiStrategy($this->app->make(HttpClient::class)); + $url = $strategy->extractThumbnail($ref); + + self::assertNotNull($url); + // The Http::fakeSequence captured the request; assert the token was sent. + Http::assertSent(function ($request): bool { + return str_contains($request->url(), 'p=PRIVATE_TOKEN'); + }); + } +} diff --git a/tests/Feature/VideoExtractorTest.php b/tests/Feature/VideoExtractorTest.php new file mode 100644 index 0000000..5c297d0 --- /dev/null +++ b/tests/Feature/VideoExtractorTest.php @@ -0,0 +1,96 @@ + Http::response( + $this->fixture('vk/video_ext_success.html'), + 200, + ['Content-Type' => 'text/html; charset=windows-1251'], + ), + ]); + + $info = Video::extract('https://vkvideo.ru/video-18403220_456239696'); + + self::assertSame('Trump cancels grants // DeepSeek revolution', $info->title); + self::assertSame(1920, $info->bestThumbnail()?->width); + } + + public function test_facade_extracts_rutube_video_end_to_end(): void + { + Http::fake([ + 'rutube.ru/api/video/*' => Http::response($this->fixture('rutube/video_meta.json'), 200), + 'rutube.ru/api/play/options/*' => Http::response($this->fixture('rutube/play_options.json'), 200), + ]); + + $info = Video::extract('https://rutube.ru/video/373ba417757981facf06c55ea5c46cb4/'); + + self::assertSame('First words of a toddler. Big collection.', $info->title); + self::assertStringContainsString('pic.rtbcdn.ru', (string) $info->thumbnailUrl()); + } + + public function test_thumbnail_shortcut_skips_format_fetch(): void + { + Http::fakeSequence() + ->push($this->fixture('rutube/video_meta.json'), 200); + + $url = Video::thumbnail('https://rutube.ru/video/373ba417757981facf06c55ea5c46cb4/'); + + self::assertNotNull($url); + self::assertStringContainsString('pic.rtbcdn.ru', $url); + } + + public function test_vk_falls_back_to_al_video_when_embed_fails(): void + { + // Primary (video_ext) reports not found; fallback (al_video) succeeds. + Http::fake([ + 'vk.com/video_ext.php*' => Http::response( + $this->fixture('vk/video_ext_not_found.html'), + 200, + ['Content-Type' => 'text/html; charset=windows-1251'], + ), + 'vk.com/al_video.php' => Http::response( + $this->fixture('vk/al_video_success.json'), + 200, + ['Content-Type' => 'application/json; charset=windows-1251'], + ), + ]); + + $info = Video::extract('https://vkvideo.ru/video-18403220_456239696'); + + // Title now comes from the al_video fallback payload. + self::assertSame('Trump cancels grants // DeepSeek revolution', $info->title); + // AlVideo returns a single 800x450 thumbnail. + self::assertCount(1, $info->thumbnails); + } + + public function test_vk_raises_not_found_when_both_strategies_fail(): void + { + Http::fake([ + 'vk.com/video_ext.php*' => Http::response( + $this->fixture('vk/video_ext_not_found.html'), + 200, + ['Content-Type' => 'text/html; charset=windows-1251'], + ), + 'vk.com/al_video.php' => Http::response( + $this->fixture('vk/al_video_deleted.json'), + 200, + ['Content-Type' => 'application/json; charset=windows-1251'], + ), + ]); + + $this->expectException(VideoNotFoundException::class); + Video::extract('https://vkvideo.ru/video-18403220_456239696'); + } +} diff --git a/tests/Feature/VkExtractionTest.php b/tests/Feature/VkExtractionTest.php new file mode 100644 index 0000000..2668c0a --- /dev/null +++ b/tests/Feature/VkExtractionTest.php @@ -0,0 +1,157 @@ + Http::response( + $this->fixture('vk/video_ext_success.html'), + 200, + ['Content-Type' => 'text/html; charset=windows-1251'], + ), + ]); + + $strategy = new VideoExtStrategy($this->app->make(HttpClient::class)); + $info = $strategy->extract($this->ref()); + + self::assertSame('-18403220_456239696', $info->id); + self::assertSame('Trump cancels grants // DeepSeek revolution', $info->title); + self::assertSame(1983, $info->duration); + self::assertSame(1738252883, $info->timestamp); + self::assertSame(699108, $info->viewCount); + self::assertSame(4320, $info->likeCount); + self::assertSame(213, $info->commentCount); + self::assertSame('Ruslan Usachev', $info->uploader); + + // Thumbnails: multiple resolutions; best should be 1920x1080. + self::assertNotEmpty($info->thumbnails); + self::assertSame(1920, $info->bestThumbnail()?->width); + self::assertStringContainsString('video_thumb', (string) $info->thumbnailUrl()); + } + + public function test_video_ext_strategy_extracts_only_thumbnail(): void + { + Http::fake([ + 'vk.com/video_ext.php*' => Http::response( + $this->fixture('vk/video_ext_success.html'), + 200, + ['Content-Type' => 'text/html; charset=windows-1251'], + ), + ]); + + $strategy = new VideoExtStrategy($this->app->make(HttpClient::class)); + $url = $strategy->extractThumbnail($this->ref()); + + self::assertNotNull($url); + self::assertStringContainsString('video_thumb', $url); + } + + public function test_video_ext_strategy_extracts_formats_with_expiry(): void + { + Http::fake([ + 'vk.com/video_ext.php*' => Http::response( + $this->fixture('vk/video_ext_success.html'), + 200, + ['Content-Type' => 'text/html; charset=windows-1251'], + ), + ]); + + $strategy = new VideoExtStrategy($this->app->make(HttpClient::class)); + $info = $strategy->extract($this->ref()); + + // 6 mp4 heights + hls + dash_sep = 8 formats. + self::assertCount(8, $info->formats); + + $mp41080 = null; + foreach ($info->formats as $format) { + if ($format->formatId === 'mp4_1080') { + $mp41080 = $format; + } + } + self::assertNotNull($mp41080); + self::assertSame(1080, $mp41080->height); + // expires=1784467826444 is milliseconds -> 1784467826 seconds. + self::assertSame(1784467826, $mp41080->expiresAt); + } + + public function test_video_ext_strategy_throws_when_video_not_found(): void + { + Http::fake([ + 'vk.com/video_ext.php*' => Http::response( + $this->fixture('vk/video_ext_not_found.html'), + 200, + ['Content-Type' => 'text/html; charset=windows-1251'], + ), + ]); + + $strategy = new VideoExtStrategy($this->app->make(HttpClient::class)); + + $this->expectException(VideoNotFoundException::class); + $strategy->extract($this->ref()); + } + + public function test_al_video_strategy_extracts_info(): void + { + Http::fake([ + 'vk.com/al_video.php' => Http::response( + $this->fixture('vk/al_video_success.json'), + 200, + ['Content-Type' => 'application/json; charset=windows-1251'], + ), + ]); + + $strategy = new AlVideoStrategy($this->app->make(HttpClient::class)); + $info = $strategy->extract($this->ref()); + + self::assertSame('Trump cancels grants // DeepSeek revolution', $info->title); + self::assertSame(1983, $info->duration); + self::assertSame('Ruslan Usachev', $info->uploader); + self::assertSame(4320, $info->likeCount); + self::assertSame(213, $info->commentCount); + + // AlVideo returns a single jpg thumbnail. + self::assertCount(1, $info->thumbnails); + self::assertStringContainsString('800x450', (string) $info->thumbnailUrl()); + } + + public function test_al_video_strategy_throws_for_deleted_video(): void + { + Http::fake([ + 'vk.com/al_video.php' => Http::response( + $this->fixture('vk/al_video_deleted.json'), + 200, + ['Content-Type' => 'application/json; charset=windows-1251'], + ), + ]); + + $strategy = new AlVideoStrategy($this->app->make(HttpClient::class)); + + $this->expectException(VideoNotFoundException::class); + $this->expectExceptionMessage('Video has been deleted'); + $strategy->extract($this->ref()); + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php new file mode 100644 index 0000000..3212b1e --- /dev/null +++ b/tests/TestCase.php @@ -0,0 +1,55 @@ + + */ + protected function getPackageProviders($app): array + { + return [VideoExtractorServiceProvider::class]; + } + + /** + * @param \Illuminate\Foundation\Application $app + * @return array + */ + protected function getPackageAliases($app): array + { + return [ + 'Video' => \Apoloz\VideoExtractor\Facades\Video::class, + ]; + } + + /** + * Disable caching by default in tests for deterministic behaviour. + * + * @param \Illuminate\Foundation\Application $app + */ + protected function getEnvironmentSetUp($app): void + { + $app['config']->set('video-extractor.cache.enabled', false); + } + + /** + * Read a fixture file from tests/fixtures. + */ + protected function fixture(string $path): string + { + $full = __DIR__ . '/fixtures/' . ltrim($path, '/'); + + if (!is_file($full)) { + self::fail("Fixture not found: {$full}"); + } + + return (string) file_get_contents($full); + } +} diff --git a/tests/Unit/EmbeddedJsonExtractorTest.php b/tests/Unit/EmbeddedJsonExtractorTest.php new file mode 100644 index 0000000..3c2a2d7 --- /dev/null +++ b/tests/Unit/EmbeddedJsonExtractorTest.php @@ -0,0 +1,80 @@ +extractor = new EmbeddedJsonExtractor(); + } + + public function test_extracts_flat_object(): void + { + $html = 'prefix window.cur = Object.assign(window.cur || {}, {"a":1,"b":"x"}); suffix'; + + $result = $this->extractor->extractAfterMarker($html, 'window.cur = Object.assign('); + + self::assertSame(['a' => 1, 'b' => 'x'], $result); + } + + public function test_extracts_deeply_nested_structure(): void + { + $html = 'x = Object.assign({}, {"apiPrefetchCache":[{"response":{"items":[{"id":1,"files":{"hls":"u"}}]}}]});'; + + $result = $this->extractor->extractAfterMarker($html, 'Object.assign('); + + self::assertSame(1, $result['apiPrefetchCache'][0]['response']['items'][0]['id']); + self::assertSame('u', $result['apiPrefetchCache'][0]['response']['items'][0]['files']['hls']); + } + + public function test_ignores_braces_inside_strings(): void + { + // A brace and quote inside a string must not unbalance the count. + $html = 'm({"msg":"has } and \" escaped","n":2});'; + + $result = $this->extractor->extractAfterMarker($html, 'm('); + + self::assertSame('has } and " escaped', $result['msg']); + self::assertSame(2, $result['n']); + } + + public function test_extracts_array_root(): void + { + $html = 'data = assign([], [1, [2, 3], {"k": "v"}]);'; + + $result = $this->extractor->extractAfterMarker($html, 'assign([], '); + + self::assertSame([1, [2, 3], ['k' => 'v']], $result); + } + + public function test_throws_when_marker_missing(): void + { + $this->expectException(ParseFailedException::class); + $this->extractor->extractAfterMarker('no marker here', 'window.cur'); + } + + public function test_throws_on_unbalanced_json(): void + { + $this->expectException(ParseFailedException::class); + $this->extractor->extractAfterMarker('m({"a":1', 'm('); + } + + public function test_handles_cyrillic_content(): void + { + $html = 'window.cur = Object.assign(window.cur || {}, {"title":"Трамп отменяет гранты"});'; + + $result = $this->extractor->extractAfterMarker($html, 'window.cur = Object.assign('); + + self::assertSame('Трамп отменяет гранты', $result['title']); + } +} diff --git a/tests/Unit/HttpClientTest.php b/tests/Unit/HttpClientTest.php new file mode 100644 index 0000000..3151d71 --- /dev/null +++ b/tests/Unit/HttpClientTest.php @@ -0,0 +1,90 @@ + Http::response( + $cp1251, + 200, + ['Content-Type' => 'application/json; charset=windows-1251'], + ), + ]); + + $client = new HttpClient(); + $response = $client->get('https://example.com/test'); + + self::assertSame('Трамп', $client->bodyUtf8($response)); + } + + public function test_force_charset_overrides_header_detection(): void + { + // "Алена" encoded as Windows-1251 bytes, but the server declares UTF-8. + $cp1251 = "\xc0\xeb\xe5\xed\xe0"; + + Http::fake([ + 'example.com/*' => Http::response( + $cp1251, + 200, + ['Content-Type' => 'application/json; charset=utf-8'], + ), + ]); + + $client = new HttpClient(); + $response = $client->get('https://example.com/test'); + + // Forcing cp1251 recovers correct Cyrillic regardless of the header. + self::assertSame('Алена', $client->bodyUtf8($response, 'Windows-1251')); + } + + public function test_leaves_utf8_body_untouched(): void + { + Http::fake([ + 'example.com/*' => Http::response('Привет', 200, ['Content-Type' => 'application/json; charset=utf-8']), + ]); + + $client = new HttpClient(); + $response = $client->get('https://example.com/test'); + + self::assertSame('Привет', $client->bodyUtf8($response)); + } + + public function test_returns_body_verbatim_when_no_charset(): void + { + Http::fake([ + 'example.com/*' => Http::response('plain ascii', 200, ['Content-Type' => 'text/html']), + ]); + + $client = new HttpClient(); + $response = $client->get('https://example.com/test'); + + self::assertSame('plain ascii', $client->bodyUtf8($response)); + } + + public function test_detects_challenge_redirect(): void + { + // VK answers 429 on the challenge URL itself; in Http::fake the + // effective URI mirrors the request URL, so we hit it directly. + Http::fake([ + 'vk.com/challenge.html*' => Http::response('challenge', 429), + ]); + + $client = new HttpClient(); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('JS challenge'); + $client->get('https://vk.com/challenge.html?hash429=abc'); + } +} diff --git a/tests/Unit/OkRuUrlParserTest.php b/tests/Unit/OkRuUrlParserTest.php new file mode 100644 index 0000000..a17bc60 --- /dev/null +++ b/tests/Unit/OkRuUrlParserTest.php @@ -0,0 +1,55 @@ +parser = new OkRuUrlParser(); + } + + #[DataProvider('urlShapes')] + public function test_parses_canonical_and_embed_urls(string $url, string $expectedId): void + { + $ref = $this->parser->parse($url); + + self::assertSame(Platform::OK, $ref->platform); + self::assertSame($expectedId, $ref->id); + } + + public function test_parses_legacy_deep_link_query(): void + { + $ref = $this->parser->parse('https://ok.ru/dk?st.cmd=movieLayer&st.mvId=863789452017&tkn=3648'); + + self::assertSame('863789452017', $ref->id); + } + + public function test_parses_id_with_segment_suffix(): void + { + $ref = $this->parser->parse('https://ok.ru/video/63567059965189-0'); + + self::assertSame('63567059965189-0', $ref->id); + } + + public static function urlShapes(): array + { + return [ + 'video' => ['https://ok.ru/video/15019292494547', '15019292494547'], + 'videoembed' => ['https://ok.ru/videoembed/2932705602075', '2932705602075'], + 'live' => ['https://ok.ru/live/484531969818', '484531969818'], + 'moviePlayer API' => ['https://ok.ru/web-api/video/moviePlayer/20079905452', '20079905452'], + 'mobile' => ['https://m.ok.ru/video/2361249957145', '2361249957145'], + ]; + } +} diff --git a/tests/Unit/PlatformDetectorTest.php b/tests/Unit/PlatformDetectorTest.php new file mode 100644 index 0000000..1e707fa --- /dev/null +++ b/tests/Unit/PlatformDetectorTest.php @@ -0,0 +1,85 @@ +detector = new PlatformDetector(); + } + + #[DataProvider('vkUrls')] + public function test_detects_vk(string $url): void + { + self::assertSame(Platform::VK, $this->detector->detect($url)); + } + + #[DataProvider('rutubeUrls')] + public function test_detects_rutube(string $url): void + { + self::assertSame(Platform::RUTUBE, $this->detector->detect($url)); + } + + #[DataProvider('okUrls')] + public function test_detects_ok(string $url): void + { + self::assertSame(Platform::OK, $this->detector->detect($url)); + } + + public function test_throws_for_unsupported_host(): void + { + $this->expectException(PlatformNotSupportedException::class); + $this->detector->detect('https://youtube.com/watch?v=abc'); + } + + public function test_normalises_scheme_less_urls(): void + { + self::assertSame(Platform::VK, $this->detector->detect('vkvideo.ru/video-1_2')); + } + + public static function vkUrls(): array + { + return [ + 'vkvideo.ru' => ['https://vkvideo.ru/video-18403220_456239696'], + 'vk.com' => ['https://vk.com/video-18403220_456239696'], + 'm.vk.com' => ['https://m.vk.com/video205387401_165548505'], + 'new.vk.com' => ['http://new.vk.com/video205387401_165548505'], + 'vksport.vkvideo.ru' => ['https://vksport.vkvideo.ru/video-124096712_456240773'], + 'vk.ru' => ['https://vk.ru/video-220754053_456242564'], + 'daxab.com' => ['https://www.daxab.com/embed/abc'], + ]; + } + + public static function rutubeUrls(): array + { + return [ + 'rutube.ru video' => ['https://rutube.ru/video/373ba417757981facf06c55ea5c46cb4/'], + 'rutube.ru live' => ['https://rutube.ru/live/video/c58f502c7bb34a8fcdd976b221fca292/'], + 'rutube.ru embed' => ['https://rutube.ru/play/embed/03a9cb54bac3376af4c5cb0f18444e01'], + ]; + } + + public static function okUrls(): array + { + return [ + 'ok.ru video' => ['https://ok.ru/video/15019292494547'], + 'ok.ru videoembed' => ['https://ok.ru/videoembed/2932705602075'], + 'ok.ru live' => ['https://ok.ru/live/484531969818'], + 'm.ok.ru' => ['https://m.ok.ru/video/2361249957145'], + 'www.ok.ru' => ['https://www.ok.ru/video/20648036891'], + 'odnoklassniki.ru' => ['https://odnoklassniki.ru/video/20079905452'], + ]; + } +} diff --git a/tests/Unit/RutubeUrlParserTest.php b/tests/Unit/RutubeUrlParserTest.php new file mode 100644 index 0000000..934930a --- /dev/null +++ b/tests/Unit/RutubeUrlParserTest.php @@ -0,0 +1,65 @@ +parser = new RutubeUrlParser(); + } + + public function test_parses_canonical_url(): void + { + $ref = $this->parser->parse('https://rutube.ru/video/373ba417757981facf06c55ea5c46cb4/'); + + self::assertSame(Platform::RUTUBE, $ref->platform); + self::assertSame('373ba417757981facf06c55ea5c46cb4', $ref->id); + } + + public function test_normalises_uppercase_hex(): void + { + $ref = $this->parser->parse('https://rutube.ru/video/373BA417757981FACF06C55EA5C46CB4/'); + + self::assertSame('373ba417757981facf06c55ea5c46cb4', $ref->id); + } + + public function test_parses_live_url(): void + { + $ref = $this->parser->parse('https://rutube.ru/live/video/c58f502c7bb34a8fcdd976b221fca292/'); + + self::assertSame('c58f502c7bb34a8fcdd976b221fca292', $ref->id); + } + + public function test_parses_embed_url(): void + { + $ref = $this->parser->parse('https://rutube.ru/play/embed/03a9cb54bac3376af4c5cb0f18444e01'); + + self::assertSame('03a9cb54bac3376af4c5cb0f18444e01', $ref->id); + } + + public function test_captures_private_token(): void + { + $ref = $this->parser->parse('https://rutube.ru/video/private/884fb55f07a97ab673c7d654553e0f48/?p=x2QojCumHTS3rsKHWXN8Lg'); + + self::assertSame('884fb55f07a97ab673c7d654553e0f48', $ref->id); + self::assertSame(['p' => 'x2QojCumHTS3rsKHWXN8Lg'], $ref->query); + } + + public function test_parses_numeric_embed_id(): void + { + $ref = $this->parser->parse('https://rutube.ru/video/embed/6722881'); + + self::assertSame('6722881', $ref->id); + self::assertSame('embed_numeric', $ref->type); + } +} diff --git a/tests/Unit/VkUrlParserTest.php b/tests/Unit/VkUrlParserTest.php new file mode 100644 index 0000000..42fec59 --- /dev/null +++ b/tests/Unit/VkUrlParserTest.php @@ -0,0 +1,79 @@ +parser = new VkUrlParser(); + } + + public function test_parses_canonical_vkvideo_url(): void + { + $ref = $this->parser->parse('https://vkvideo.ru/video-18403220_456239696'); + + self::assertSame(Platform::VK, $ref->platform); + self::assertSame('-18403220_456239696', $ref->id); + self::assertSame('-18403220', $ref->oid); + self::assertSame('video', $ref->type); + } + + public function test_parses_positive_owner_id(): void + { + $ref = $this->parser->parse('https://vkvideo.ru/video224967259_456239337'); + + self::assertSame('224967259_456239337', $ref->id); + self::assertSame('224967259', $ref->oid); + } + + public function test_parses_clip_url(): void + { + $ref = $this->parser->parse('https://vk.com/clip-74006511_456247211'); + + self::assertSame('-74006511_456247211', $ref->id); + self::assertSame('clip', $ref->type); + } + + public function test_parses_mobile_url(): void + { + $ref = $this->parser->parse('https://m.vk.com/video205387401_165548505'); + + self::assertSame('205387401_165548505', $ref->id); + self::assertSame('video', $ref->type); + } + + public function test_parses_embed_url_with_hash(): void + { + $ref = $this->parser->parse('https://vk.com/video_ext.php?oid=-77521&id=162222515&hash=87b046504ccd8bfa'); + + self::assertSame('-77521_162222515', $ref->id); + self::assertSame('-77521', $ref->oid); + self::assertSame('87b046504ccd8bfa', $ref->hash); + self::assertSame('embed', $ref->type); + } + + public function test_parses_embed_url_without_hash(): void + { + $ref = $this->parser->parse('https://vk.com/video_ext.php?oid=-18403220&id=456239696'); + + self::assertSame('-18403220_456239696', $ref->id); + self::assertNull($ref->hash); + } + + public function test_cache_key_is_stable(): void + { + $ref = $this->parser->parse('https://vkvideo.ru/video-18403220_456239696'); + + self::assertSame('video:vk:-18403220_456239696:thumb', $ref->cacheKey('thumb')); + } +} diff --git a/tests/fixtures/ok/video_meta_only.html b/tests/fixtures/ok/video_meta_only.html new file mode 100644 index 0000000..0db1422 --- /dev/null +++ b/tests/fixtures/ok/video_meta_only.html @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + +
+ + diff --git a/tests/fixtures/ok/video_not_found.html b/tests/fixtures/ok/video_not_found.html new file mode 100644 index 0000000..b49f356 --- /dev/null +++ b/tests/fixtures/ok/video_not_found.html @@ -0,0 +1,10 @@ + + + + + Video + + +
The author of this video has not been found or is blocked
+ + diff --git a/tests/fixtures/ok/video_success.html b/tests/fixtures/ok/video_success.html new file mode 100644 index 0000000..5b767d3 --- /dev/null +++ b/tests/fixtures/ok/video_success.html @@ -0,0 +1 @@ +
\ No newline at end of file diff --git a/tests/fixtures/rutube/play_options.json b/tests/fixtures/rutube/play_options.json new file mode 100644 index 0000000..9da04e8 --- /dev/null +++ b/tests/fixtures/rutube/play_options.json @@ -0,0 +1,28 @@ +{ + "id": 429712102, + "video_id": "373ba417757981facf06c55ea5c46cb4", + "effective_video": "373ba417757981facf06c55ea5c46cb4", + "thumbnail_url": "https://pic.rtbcdn.ru/video/2025-08-07/0a/5f/0a5f3ef60eb641935f678c5f543a92fe.jpg", + "duration": 3756, + "acl_access": { + "allowed": true, + "err_code": null, + "err_text": "" + }, + "limits": [], + "video_balancer": { + "default": "https://bl.rutube.ru/route/373ba417757981facf06c55ea5c46cb4.m3u8?guids=variant&sign=abc&expire=1784295930&scheme=https", + "m3u8": "https://bl.rutube.ru/route/373ba417757981facf06c55ea5c46cb4.m3u8?guids=variant&sign=abc&expire=1784295930&scheme=https" + }, + "live_streams": {}, + "captions": [ + { + "sub_id": 9565685, + "format": "srt", + "langTitle": "Russian", + "file": "https://pic.rtbcdn.ru/subtitle/2026/02/58/91/sub.srt", + "is_autogenerated": true, + "code": "ru" + } + ] +} diff --git a/tests/fixtures/rutube/play_options_geo_blocked.json b/tests/fixtures/rutube/play_options_geo_blocked.json new file mode 100644 index 0000000..a4b7ae1 --- /dev/null +++ b/tests/fixtures/rutube/play_options_geo_blocked.json @@ -0,0 +1,11 @@ +{ + "video_id": "blocked123", + "effective_video": "blocked123", + "acl_access": { + "allowed": false, + "err_code": 403, + "err_text": "Not available in your region" + }, + "video_balancer": {}, + "live_streams": {} +} diff --git a/tests/fixtures/rutube/video_meta.json b/tests/fixtures/rutube/video_meta.json new file mode 100644 index 0000000..09869bb --- /dev/null +++ b/tests/fixtures/rutube/video_meta.json @@ -0,0 +1,31 @@ +{ + "id": "373ba417757981facf06c55ea5c46cb4", + "title": "First words of a toddler. Big collection.", + "description": "My speech launch programs - https://example.com", + "duration": 3756, + "created_ts": "2025-08-06T14:23:14", + "hits": 6048782, + "is_livestream": false, + "is_adult": false, + "thumbnail_url": "https://pic.rtbcdn.ru/video/2025-08-07/0a/5f/0a5f3ef60eb641935f678c5f543a92fe.jpg", + "preview_url": "https://preview.rutube.ru/preview/373ba417757981facf06c55ea5c46cb4.webp?exp=1784295827&s=token", + "video_url": "https://rutube.ru/video/373ba417757981facf06c55ea5c46cb4/", + "embed_url": "https://rutube.ru/play/embed/373ba417757981facf06c55ea5c46cb4", + "author": { + "id": 40529538, + "name": "Alena Vasilieva", + "avatar_url": "https://pic.rtbcdn.ru/user/avatar.jpg", + "site_url": "https://rutube.ru/video/person/40529538/?format=json" + }, + "category": { + "id": 17, + "name": "Education", + "short_name": "education" + }, + "restrictions": { + "country": { + "allowed": ["-"], + "restricted": [] + } + } +} diff --git a/tests/fixtures/rutube/video_meta_404.json b/tests/fixtures/rutube/video_meta_404.json new file mode 100644 index 0000000..d8c10f7 --- /dev/null +++ b/tests/fixtures/rutube/video_meta_404.json @@ -0,0 +1,4 @@ +{ + "detail": "Video not found", + "error": true +} diff --git a/tests/fixtures/vk/al_video_deleted.json b/tests/fixtures/vk/al_video_deleted.json new file mode 100644 index 0000000..4a5aba4 --- /dev/null +++ b/tests/fixtures/vk/al_video_deleted.json @@ -0,0 +1 @@ +{"payload":["8",["\"Video has been deleted.\"","false","\"\""]]} diff --git a/tests/fixtures/vk/al_video_success.json b/tests/fixtures/vk/al_video_success.json new file mode 100644 index 0000000..8417667 --- /dev/null +++ b/tests/fixtures/vk/al_video_success.json @@ -0,0 +1 @@ +{"payload":[0,["Trump cancels grants // DeepSeek revolution","
info
","addTemplates({});","
meta
",{"lang":0,"mvData":{"title":"Trump cancels grants // DeepSeek revolution","desc":"Episode description text","duration":1983,"likes":4320,"commcount":213},"player":{"params":[{"md_title":"Trump cancels grants // DeepSeek revolution","description":"Episode description text","md_author":"Ruslan Usachev","author_id":"-18403220","duration":1983,"date":1738252883,"live":0,"jpg":"https://sun1-26.userapi.com/impg/VImlA7tmduaGgsXCXB47LMdBj9rwqtaMy6q3wA/2u6v-BU5VKM.jpg?size=800x450&quality=95&type=video_thumb","url144":"https://vkvd685.okcdn.ru/?expires=1784467826444&sig=a","url240":"https://vkvd685.okcdn.ru/?expires=1784467826444&sig=b","url360":"https://vkvd685.okcdn.ru/?expires=1784467826444&sig=c","url480":"https://vkvd685.okcdn.ru/?expires=1784467826444&sig=d","url720":"https://vkvd685.okcdn.ru/?expires=1784467826444&sig=e","url1080":"https://vkvd685.okcdn.ru/?expires=1784467826444&sig=f","hls":"https://vkvd685.okcdn.ru/video.m3u8?expires=1784467826444&sig=g","dash_sep":"https://vkvd685.okcdn.ru/?expires=1784467826444&sig=h"}]}}]]} diff --git a/tests/fixtures/vk/video_ext_not_found.html b/tests/fixtures/vk/video_ext_not_found.html new file mode 100644 index 0000000..346b4d5 --- /dev/null +++ b/tests/fixtures/vk/video_ext_not_found.html @@ -0,0 +1,14 @@ + + + + + Video embed + + +
Video not found.
+ + + diff --git a/tests/fixtures/vk/video_ext_success.html b/tests/fixtures/vk/video_ext_success.html new file mode 100644 index 0000000..e080a0b --- /dev/null +++ b/tests/fixtures/vk/video_ext_success.html @@ -0,0 +1,13 @@ + + + + + Video embed + + + + +