فيديوهاتيالتوثيق

حزمة Laravel

حزمة ‎videohati/laravel‎ الرسمية — التثبيت والإعداد ورفع الفيديو وإنشاء جلسات التشغيل والتحقّق من Webhooks وتضمين المشغّل من تطبيق Laravel.

videohati/laravel هي حزمة PHP الرسمية لإطار Laravel. يسجّل مزوّد الخدمة وواجهة ‏Videohati تلقائيًا، والتحقّق من شهادة TLS مفعّل دائمًا، ولا تحتوي الاستثناءات على مفتاحك أبدًا. تتطلّب PHP الإصدار 8.2 فأحدث، وLaravel 10 أو 11. الإصدار ‏1.0.0-rc.0.

التثبيت

composer require videohati/laravel

اضبط الاعتمادات في .env:

VIDEOHATI_API_KEY=vh_live_...
VIDEOHATI_PROJECT_ID=01JZ9WV3N8GQ5T2M7K4C6XBARF

يضع مفتاح vh_test_... العميل مقابل بيانات الاختبار؛ ويتبع الوضع بادئة المفتاح دائمًا.

الإعداد

انشر ملف الإعداد لتغيير القيم الافتراضية:

php artisan vendor:publish --tag=videohati-config

يقرأ config/videohati.php متغيّرات البيئة الآتية:

المتغيّرالافتراضيماذا يضبط
VIDEOHATI_API_KEYمفتاح API للمشروع.
VIDEOHATI_BASE_URLhttps://api.videohati.comأصل واجهة API.
VIDEOHATI_PROJECT_IDالمشروع الافتراضي لموجّه @videohatiPlayer.
VIDEOHATI_WEBHOOK_SECRETسرّ نقطة النهاية لـ SignatureVerifier.
VIDEOHATI_PLAYER_URLhttps://cdn.videohati.com/player.jsرابط حزمة المشغّل للموجّه.
VIDEOHATI_TIMEOUT30مهلة HTTP بالثواني.

الواجهة وحقن التبعية

اصل إلى العميل عبر واجهة Videohati أو بحقن Videohati\Laravel\Client.

use Videohati\Laravel\Facades\Videohati;

$videos = Videohati::videos()->list(state: 'ready');
use Videohati\Laravel\Client;

class LessonController
{
    public function __construct(private readonly Client $videohati) {}

    public function show(string $videoId)
    {
        $video = $this->videohati->videos()->get($videoId);
        // ...
    }
}

يكشف العميل الدوال videos() وplayback() وwebhookEndpoints() وwebhooks() (مُتحقّق التوقيع).

الفيديوهات

تُعيد videos()->list() مجموعة Laravel Collection<VideoDto>.

الدالةالتوقيعماذا تفعل
listlist(?string $state, int $limit = 20, ?string $cursor, ?string $q, ?string $tag)صفحة واحدة بصيغة Collection<VideoDto>.
listPagelistPage(?string $state, int $limit = 20, ?string $cursor, ?string $q, ?string $tag)مصفوفة الصفحة الخام، بما فيها nextCursor.
getget(string $videoId)كائن VideoDto واحد.
createcreate(string $originalFilename, ?string $idempotencyKey)تُسجّل فيديو؛ تُعيد معرّفه. الخطوة 1.
updateupdate(string $videoId, ?string $visibility, ?string $title, ?string $description, ?array $tags)تحديث جزئي للبيانات الوصفية؛ يُعيد VideoDto بعد التحديث.
deletedelete(string $videoId)حذف ناعم؛ يُلغي أي رفع نشط.
bulkbulk(array $videoIds, string $action, ?string $visibility)يُطبّق إجراءً واحدًا على 1–100 معرّف؛ نتيجة لكل معرّف.
startUploadstartUpload(string $videoId, int $sizeBytes, string $contentType, ?string $checksumSha256, ?string $idempotencyKey)يفتح رفعًا مُجزّأً؛ يُعيد UploadStartDto. الخطوة 2.
completeUploadcompleteUpload(string $videoId, ?string $checksumSha256)يُنهي الرفع. الخطوة 3.
abortUploadabortUpload(string $videoId)يُلغي رفعًا مفتوحًا.
uploadupload(string $filePath, ?string $contentType, ?string $idempotencyKey, ?callable $onProgress, ?GuzzleClient $partHttp)يُنفّذ التدفّق كاملًا في استدعاء واحد.
use Videohati\Laravel\Facades\Videohati;

$videos = Videohati::videos()->list(state: 'ready', limit: 20);
$videos->each(fn ($video) => logger($video->id . ' ' . $video->state));

$video = Videohati::videos()->get($videoId); // VideoDto
Videohati::videos()->delete($videoId);       // حذف ناعم

upload($filePath) يُنفّذ POST /v1/videos ثم POST .../upload/start ثم رفع الأجزاء تِباعًا (3 محاولات لكل جزء؛ يُعاد 5xx/429 ويفشل 4xx فورًا) ثم ‏POST .../upload/complete. ويستقبل ردّ التقدّم البايتات المرفوعة والمجموع. ويستبدل $partHttp عميل Guzzle المستخدَم لرفع الأجزاء (الاختبارات، الوسائط)؛ أمّا نداءات واجهة API فتمرّ دائمًا عبر العميل المُهيّأ.

$result = Videohati::videos()->upload(
    storage_path('app/lecture-01.mp4'),
    onProgress: fn (int $uploaded, int $total) => logger("{$uploaded}/{$total}"),
);

يحمل VideoDto الحقول id وstate وtitle وdescription وtags و originalFilename وsizeBytes وcontentType وchecksumSha256 وerrorCode و errorMessage وthumbnailUrl وthumbnailSource وvisibility و durationSeconds وreadyAt وfailedAt وcreatedAt وupdatedAt — وفي ردود التفاصيل upload وencoding. ويضيف UploadStartDto الدالة partUrl($partNumber) لبناء رابط رفع كل جزء.

ما تفعله إعادة المحاولة وما لا تفعله

مرّر $idempotencyKey فتصبح الكتابتان اللتان تُنشئان حالة على الخادم — الفيديو وجلسة الرفع — آمنتين للتكرار. استدعِ upload() مرة أخرى بعد إخفاق فتحصل على معرّف الفيديو نفسه وجلسة الرفع نفسها، حتى لو ضاعت استجابة المحاولة الأولى في الطريق بعد أن أثبتها الخادم فعلًا. وبغير مفتاح تصبح تلك الاستجابة الضائعة لا تُميَّز عن طلب لم يصل قطّ، فتُنشئ إعادة المحاولة فيديو ثانيًا.

ثلاثة قيود تستحقّ القراءة قبل الاعتماد عليها:

  • يجب أن يبقى المفتاح حيًّا بعد ما تحاول التعافي منه. أمان إعادة المحاولة بعد إعادة تشغيل العملية لا يصحّ إلا إذا عادت القيمة نفسها بعد إعادة التشغيل، فاشتقّها من حالة دائمة — معرّف مهمة في الطابور، معرّف سجلّ في قاعدة بيانات. أمّا قيمة من rand() أو time() أو عدّاد بعمر الطلب فلا تمنحك حماية إطلاقًا، لأن إعادة المحاولة تُرسل مفتاحًا مختلفًا.
  • لا شيء من نقل البايتات يُستأنف. إعادة المحاولة يُعيد إرسال كل الأجزاء من البداية. الضمان يخصّ عدم تكرار الفيديو، لا توفير حجم النقل.
  • المفتاح الواحد لملف واحد. تربط الحزمة المفتاح باسم الملف الأساسي وحجمه، فإعادة استخدام مفتاح لملف مختلف تبدأ رفعًا جديدًا فعليًا بدل إعادة فيديو الملف الأول. ومع ذلك، فضّل مفتاحًا جديدًا لكل ملف.
// يأتي المفتاح من سجلّ المهمة، فتُعيد المهمة المُعاد تنفيذها استخدامه.
$result = Videohati::videos()->upload(
    storage_path("app/{$this->job->path}"),
    idempotencyKey: $this->job->getKey(),
);

إعادة محاولة الإنشاء وحده

تقبل create() المفتاح $idempotencyKey نفسه، إن كنت تُدير خطوات الرفع الثلاث بنفسك. والمطابقة على (project, $idempotencyKey) بقاعدة الأسبق يفوز، والمفتاح هو الهوية كاملة — فـ $originalFilename ليس جزءًا منها أبدًا. وتكرار استدعاء أُثبت يُعيد معرّف فيديو الاستدعاء الأول.

أعد استخدام مفتاح واحد مع ملف مختلف فستحصل على فيديو الاستدعاء الأول، باسم ملفه الأول؛ ولن يُرفَع ملفك الثاني قطّ، والنتيجة معرّف فقط لا يحمل ما يكشف الاستبدال. اسكك مفتاحًا جديدًا لكل ملف — وUUID هو الشكل المقصود — بما في ذلك عند إعادة رفع ملف أرسلته قبلًا.

$videoId = Videohati::videos()->create('lecture-01.mp4', (string) Str::uuid());

البحث والتصفية

$q مطابقة سلسلة فرعية حرفية دون حساسية لحالة الأحرف على title و originalFilename — وهي ليست بحثًا تقريبيًا ولا بحثًا في النصّ الكامل، ولا تُطابق description ولا الوسوم. و$tag يُعيد الفيديوهات التي تحمل ذلك الوسم بالضبط. واستخدم listPage() حين تحتاج nextCursor للتصفيح اليدوي.

$page = Videohati::videos()->listPage(
    state: 'ready',
    limit: 50,
    q: 'lecture 01',
    tag: 'arabic-101',
);

تحديث البيانات الوصفية

update() تحديث جزئي: مرّر أي مجموعة فرعية من $visibility و$title و $description و$tags، وواحدًا منها على الأقل.

المعاملماذا يفعل
$titleيُعيد تسمية الفيديو. ولا يتغيّر originalFilename أبدًا.
$descriptionنصّ حرّ.
$tagsيستبدل القائمة كاملة (20 وسمًا على الأكثر).
$visibility'private' أو 'unlisted' أو 'public'. وunlisted/public تُفعّل سطح التضمين المجهول — صفحة المشاهدة المستضافة وإطار iframe وoEmbed.
$video = Videohati::videos()->update(
    $videoId,
    visibility: 'unlisted',
    title: 'المحاضرة 01 — مقدّمة',
    tags: ['arabic-101', 'semester-1'],
);

تُحذَف القيم الفارغة (null) من الطلب، فلا تستطيع هذه الحزمة محو الوصف — ‏description: null يترك الحقل كما هو بدل تفريغه. أرسل سلسلة فارغة بدلًا منه.

الإجراءات المجمّعة

يُطبّق bulk() إجراءً واحدًا على 1–100 معرّف فيديو: 'delete' أو 'set_visibility' (ويحتاج $visibility). ويتصرّف delete تمامًا مثل delete() لكل معرّف.

الاستجابة 200 حتى حين تفشل بعض المعرّفات، فافحص راية ok في كل عنصر — تحمل المصفوفة المُعادة عنصرًا واحدًا لكل معرّف مطلوب فريد، بترتيب الطلب، ويحمل العنصر الفاشل error.code وerror.message.

$results = Videohati::videos()->bulk(
    videoIds: [$videoId, $otherVideoId],
    action: 'set_visibility',
    visibility: 'private',
);

foreach ($results as $result) {
    if (! $result['ok']) {
        logger()->error($result['videoId'] . ' ' . $result['error']['code']);
    }
}

إنشاء جلسة تشغيل

لا تُرسل مفتاح API إلى المتصفح أبدًا. أنشئ الجلسة في الخادم وأصدِر رمز الجلسة فقط إلى الصفحة.

تُعيد playback()->createSession() كائن PlaybackSessionDto يحمل sessionId وmanifestUrl وsource وsessionToken وwatermarkToken وtraceId و watermarkTiled وexpiresAt وheartbeatIntervalSeconds وshowFreeBadge.

$session = Videohati::playback()->createSession(
    videoId: $video->id,
    watermarkText: $user->name,
);
المعاملالافتراضيماذا يفعل
$videoIdمطلوبالفيديو المراد تشغيله.
$ttlSecondsافتراضي الخادمعمر الجلسة.
$referrerAllowlistحصر التشغيل في هذه المُحيلات.
$watermarkTextتفعيل العلامة المائية: نصّ يُرسم على الفيديو. ويكون كذلك مفتاح حدّ التزامن لكل مشاهد حين لا يُمرَّر $viewerId.
$viewerIdمعرّف المستخدم النهائي عندك. يكون مفتاح حدّ التزامن لكل مشاهد دون رسم أي شيء على الفيديو.

التوقيع الكامل هو createSession(string $videoId, ?int $ttlSeconds, ?array $referrerAllowlist, ?string $watermarkText, ?string $viewerId).

حدّ التزامن لكل مشاهد بلا علامة مائية

يُحسَب حدّ الجلسات المتزامنة لكل مشاهد، وتُحدَّد هوية المشاهد بهذا الترتيب: $viewerId، ثم $watermarkText، ثم تجزئة عنوان IP للعميل. وبغير أي من الحقلين يرتدّ كل مستخدم نهائي خلف مفتاح API واحد إلى تجزئة IP — فتنطوي شبكة مشتركة بأكملها في حدٍّ واحد مشترك.

مرّر $viewerId لإصلاح ذلك مع الحفاظ على تشغيل نظيف: فهو يُعرّف المشاهد لأجل الحدّ فقط. ولا يُخزَّن أبدًا — إذ لا تصل الجلسة إلا تجزئته — ولا يرسم شيئًا. وتظهر العلامة المائية إن ضبطت $watermarkText وفقط إن ضبطته، فـ $viewerId وحده لا يُشعل علامة مائية أبدًا.

// حدود مستقلّة لكل مستخدم نهائي، بلا أي طبقة على الفيديو.
$session = Videohati::playback()->createSession(
    videoId: $video->id,
    viewerId: (string) $user->getKey(),
);

// الاثنان معًا، حين تريد مشاهدًا معرَّفًا وعلامة مائية ظاهرة.
$identified = Videohati::playback()->createSession(
    videoId: $video->id,
    watermarkText: $user->name,
    viewerId: (string) $user->getKey(),
);

تضمين المشغّل

يُصدِر موجّه Blade المسمّى @videohatiPlayer مقتطف التضمين — وسم <script> للمشغّل مع عنصر data-videohati-*.

{{-- resources/views/lesson.blade.php --}}
@videohatiPlayer(['videoId' => $video->id, 'sessionToken' => $session->sessionToken])

يعود projectId افتراضيًا إلى VIDEOHATI_PROJECT_ID. المفاتيح الاختيارية: ‏projectId وautoplay ('muted' أو true) وwidth وheight وlang (‏'ar' / 'en') وviewerText وapiOrigin وplayerUrl. انظر صفحة المشغّل للسمات والأحداث.

نقاط نهاية Webhook

تدير webhookEndpoints() نقاط النهاية وتقرأ التسليمات. تُعيد كل دالة جسم JSON المُحلَّل مصفوفةً.

الدالةالتوقيعماذا تفعل
registerregister(string $projectId, array $params)تُسجّل نقطة نهاية. يُعاد secret مرة واحدة.
listlist(string $projectId)تسرد نقاط النهاية.
getget(string $projectId, string $id)نقطة نهاية واحدة.
updateupdate(string $projectId, string $id, array $params)تُحدّث نقطة نهاية.
deletedelete(string $projectId, string $id)حذف ناعم لنقطة نهاية.
rotateSecretrotateSecret(string $projectId, string $id)تسكّ سرًّا جديدًا (يُعاد مرة واحدة).
reopenreopen(string $projectId, string $id)تمحو قاطع الدائرة المتعثّر.
sendTestsendTest(string $projectId, string $id)تُدرِج تسليمًا اصطناعيًا من نوع video.encoding.ready.
listDeliverieslistDeliveries(string $projectId, string $id, array $query = [])صفحة واحدة من محاولات التسليم.

التحقّق من Webhooks

تُعيد webhooks() كائن SignatureVerifier. مرّر جسم الطلب الخام — لا JSON مُعاد تسلسله. تحقّق داخل متحكّم أو مسار، أو غلّفه في وسيطة (middleware).

use Illuminate\Http\Request;
use Videohati\Laravel\Webhooks\SignatureVerifier;

Route::post('/webhooks/videohati', function (Request $request) {
    $event = SignatureVerifier::verifyAndParse(
        $request->getContent(), // الجسم الخام
        $request->header(SignatureVerifier::HEADER, ''),
        config('videohati.webhook_secret'),
    );

    if ($event === null) {
        abort(400); // توقيع غير صالح
    }

    if ($event['type'] === 'video.encoding.ready') {
        // استجب لجاهزية الفيديو.
    }

    return response()->noContent(); // 204
});

تُعيد SignatureVerifier::verify(...) قيمة منطقية حين تحتاج التحقّق فقط؛ وتُعيد ‏verifyAndParse(...) مصفوفة الغلاف المُحلَّل أو null. الترويسة هي ‏Videohati-Signature: t=<unix_ts>,v1=<hex_sha256> (HMAC-SHA256 على ‏<unix_ts>.<raw_body>)؛ والمقارنة ثابتة الزمن، وتُرفَض الطوابع الزمنية الأقدم من 300 ثانية.

الأخطاء

كل استجابة غير 2xx تُطلق فئة فرعية مكتوبة من ‏Videohati\Laravel\Exceptions\VideohatiException، تحمل status وapiCode (رمز الآلة من واجهة API، مثل video_not_found).

الفئةالحالةمتى
ConnectionExceptionلم يصل الطلب إلى واجهة API (DNS، TLS، المقبس، أو انتهاء المهلة).
ValidationException400فشل الجسم أو الاستعلام في التحقّق.
AuthenticationException401اعتماد مفقود أو غير صالح.
PermissionException403اعتماد صالح، لكن الصلاحية غير كافية.
NotFoundException404المورد مفقود أو غير مرئيّ.
ConflictException409تعارض مع الحالة الراهنة للمورد.
RateLimitException429تجاوز حدّ المعدّل؛ اقرأ retryAfterSeconds.
ServerException5xxفشلت واجهة API في إتمام الطلب.
use Videohati\Laravel\Exceptions\NotFoundException;
use Videohati\Laravel\Exceptions\RateLimitException;
use Videohati\Laravel\Exceptions\VideohatiException;

try {
    $video = Videohati::videos()->get($videoId);
} catch (NotFoundException) {
    abort(404);
} catch (RateLimitException $e) {
    sleep($e->retryAfterSeconds ?? 1);
} catch (VideohatiException $e) {
    report($e); // $e->status, $e->apiCode, $e->getMessage()
}