حزمة Node.js
حزمة @videohati/node الرسمية — إنشاء عميل ورفع الفيديو وإنشاء جلسات التشغيل وإدارة Webhooks ومعالجة الأخطاء من خادم Node.js.
@videohati/node هي حزمة الخادم الرسمية. وهي بصيغة ESM، بلا تبعيات وقت التشغيل،
ومكتوبة بأنواع مطابقة لمواصفة OpenAPI 3.1 المنشورة على
https://api.videohati.com/openapi.json. الإصدار 1.0.0-rc.0.
التثبيت
Node.js الإصدار 18 فأحدث. الحزمة بصيغة ESM فقط — استخدم import لا require.
npm install @videohati/nodeإنشاء عميل
أنشئ عميلًا واحدًا وأعد استخدامه. يتبع الوضع (live أو test) بادئة المفتاح
دائمًا — فمفتاح vh_test_ يعمل مقابل بيانات الاختبار.
import { Videohati } from "@videohati/node";
const videohati = new Videohati({
apiKey: process.env.VIDEOHATI_API_KEY, // vh_live_... أو vh_test_...
});خيارات VideohatiOptions:
| الخيار | الافتراضي | ماذا يفعل |
|---|---|---|
apiKey | — | مفتاح API للمشروع (vh_live_... أو vh_test_...). |
env | — | "live" | "test" — تأكيد بأن بادئة المفتاح تطابقه. |
baseUrl | https://api.videohati.com | أصل واجهة API. |
maxRetries | 2 | إعادة المحاولة للطلبات الآمنة وردود 429 و5xx. |
timeoutMs | 30000 | مهلة كل محاولة بالميلي ثانية. |
fetch | fetch العام | تطبيق fetch مخصّص. |
لا تُرسل مفتاح API إلى المتصفح أبدًا. يستطيع أي شخص قراءته من مصدر الصفحة واستخدام حسابك. للتشغيل، أنشئ الجلسة في الخادم وسلّم رمز الجلسة فقط إلى الصفحة.
المصادقة
يصادق العميل بمفتاح API للمشروع يُرسَل رمزًا حاملًا (bearer). استخدم مفتاح
vh_live_ في الإنتاج ومفتاح vh_test_ مقابل بيانات الاختبار — تستنتج الحزمة
الوضع من البادئة. تنشئ المفاتيح وتديرها من
لوحة التحكّم.
الفيديوهات
يغطّي المورد videos دورة الحياة كاملة: الإنشاء والرفع والقراءة وإدارة الصور
المصغّرة.
| الدالة | التوقيع | ماذا تفعل |
|---|---|---|
create | videos.create({ originalFilename, idempotencyKey?, projectId? }) | تُسجّل فيديو. تُعيد { videoId, state: "uploading" }. الخطوة 1. |
list | videos.list({ limit?, cursor?, state?, q?, tag?, projectId? }) | صفحة واحدة من الفيديوهات: { data, nextCursor? }. |
get | videos.get(videoId, { projectId? }) | التفاصيل الكاملة، بما فيها إسقاطا الرفع والترميز. |
update | videos.update(videoId, { title?, description?, tags?, visibility?, projectId? }) | تحديث جزئي للبيانات الوصفية؛ يُعيد الفيديو بعد التحديث. |
delete | videos.delete(videoId, { projectId? }) | حذف ناعم للفيديو؛ يُلغي أي رفع نشط. |
bulk | videos.bulk({ action, videoIds, visibility?, projectId? }) | يُطبّق إجراءً واحدًا على 1–100 فيديو؛ نتيجة لكل معرّف. |
setThumbnail | videos.setThumbnail(videoId, { data, contentType, projectId? }) | يضبط صورة مصغّرة مخصّصة (JPEG/PNG/WebP، بحدّ أقصى 5 ميغابايت). |
resetThumbnail | videos.resetThumbnail(videoId, { projectId? }) | يعود إلى إطار الملصق التلقائي. |
startUpload | videos.startUpload(videoId, { sizeBytes, contentType, checksumSha256?, idempotencyKey?, projectId? }) | يفتح رفعًا مُجزّأً؛ يُعيد حجم الجزء وعددها وقالب رابط الأجزاء. الخطوة 2. |
completeUpload | videos.completeUpload(videoId, { checksumSha256?, projectId? }) | يُنهي الرفع. الخطوة 3. |
abortUpload | videos.abortUpload(videoId, { projectId? }) | يُلغي رفعًا مفتوحًا. |
upload | videos.upload(file, options?) | يُنفّذ إنشاء ← بدء ← رفع الأجزاء ← إكمال في استدعاء واحد. |
waitForState | videos.waitForState(videoId, { targetStates?, intervalMs?, timeoutMs?, projectId? }) | يتابع الحالة حتى يبلغ الفيديو حالة هدفًا. |
الرفع في استدعاء واحد
videos.upload(file, options) يُنفّذ تدفّق الرفع المُجزّأ كاملًا في استدعاء
واحد. وfile مسار ملف أو Buffer/Uint8Array أو Blob/File.
const uploaded = await videohati.videos.upload("./lecture-01.mp4", {
onProgress: (p) => console.log(`${p.uploadedBytes}/${p.totalBytes} bytes`),
});
// استقصِ الحالة حتى يصبح الفيديو قابلًا للتشغيل من طرف إلى طرف:
const video = await videohati.videos.waitForState(uploaded.videoId);
console.log(video.state); // "ready"خيارات UploadOptions:
| الخيار | الافتراضي | ماذا يفعل |
|---|---|---|
projectId | — | يُتجاهَل مع مصادقة مفتاح API (المفتاح مقيَّد بمشروع أصلًا). |
filename | اسم الملف، وإلا upload.bin | الاسم الأصلي المخزَّن للملف. |
contentType | يُستنتَج من الامتداد | نوع MIME المُرسَل إلى الخادم. |
checksumSha256 | — | تجزئة SHA-256 بالنظام الستّ عشري الصغير للملف كاملًا؛ يتحقّق منها الخادم. |
idempotencyKey | — | هوية ثابتة لهذه المحاولة؛ تجعل إعادة المحاولة تُعيد الفيديو نفسه وجلسة الرفع نفسها. انظر أدناه. |
onProgress | — | (progress) => void بـ uploadedBytes وtotalBytes وpartsCompleted وtotalParts. |
partConcurrency | 4 | عدد الأجزاء المرفوعة بالتوازي. |
maxPartAttempts | 3 | عدد المحاولات لكل جزء. |
videoId | — | إعادة استخدام فيديو منشأ مسبقًا بدل إنشاء واحد. |
signal | — | AbortSignal لإلغاء الرفع. |
fetch | fetch العام | fetch مخصّص لرفع الأجزاء. |
يفترض waitForState القيم targetStates: ["ready", "failed"] وintervalMs: 3000
وtimeoutMs: 900000 (15 دقيقة)؛ ويُطلق VideohatiError عند انتهاء المهلة.
ما تفعله إعادة المحاولة وما لا تفعله
مرّر idempotencyKey فتصبح الكتابتان اللتان تُنشئان حالة على الخادم — الفيديو
وجلسة الرفع — آمنتين للتكرار. استدعِ upload مرة أخرى بعد إخفاق فتحصل على
معرّف الفيديو نفسه وجلسة الرفع نفسها، حتى لو ضاعت استجابة المحاولة
الأولى في الطريق بعد أن أثبتها الخادم فعلًا. وبغير مفتاح تصبح تلك الاستجابة
الضائعة لا تُميَّز عن طلب لم يصل قطّ، فتُنشئ إعادة المحاولة فيديو ثانيًا.
ثلاثة قيود تستحقّ القراءة قبل الاعتماد عليها:
- يجب أن يبقى المفتاح حيًّا بعد ما تحاول التعافي منه. أمان إعادة المحاولة
بعد إعادة تشغيل العملية لا يصحّ إلا إذا عادت القيمة نفسها بعد إعادة التشغيل،
فاشتقّها من حالة دائمة — معرّف عنصر في طابور، معرّف سجلّ في قاعدة بيانات. أمّا
قيمة من
Math.random()أوDate.now()أو عدّاد في الذاكرة فلا تمنحك حماية إطلاقًا، لأن إعادة المحاولة تُرسل مفتاحًا مختلفًا. - لا شيء من نقل البايتات يُستأنف. إعادة المحاولة يُعيد إرسال كل الأجزاء من البداية. الضمان يخصّ عدم تكرار الفيديو، لا توفير حجم النقل.
- المفتاح الواحد لملف واحد. تربط الحزمة المفتاح باسم الملف وحجمه (وبـ
checksumSha256إن مرّرته)، فإعادة استخدام مفتاح لملف مختلف تبدأ رفعًا جديدًا فعليًا بدل إعادة فيديو الملف الأول. ومع ذلك، فضّل مفتاحًا جديدًا لكل ملف.
// يأتي المفتاح من سجلّ المهمة، فيُعاد استخدامه بعد أي انهيار وإعادة تشغيل.
const uploaded = await videohati.videos.upload(job.path, {
idempotencyKey: job.id,
checksumSha256: job.sha256,
});إعادة محاولة الإنشاء وحده
تقبل videos.create المفتاح idempotencyKey نفسه، إن كنت تُدير خطوات الرفع
الثلاث بنفسك. والمطابقة على (project, idempotencyKey) بقاعدة الأسبق يفوز،
والمفتاح هو الهوية كاملة — فـ originalFilename ليس جزءًا منها أبدًا. وتكرار
استدعاء أُثبت يُعيد فيديو الاستدعاء الأول، بأي حالة بلغها بعد ذلك.
أعد استخدام مفتاح واحد مع ملف مختلف فستحصل على فيديو الاستدعاء الأول، باسم ملفه الأول؛ ولن يُرفَع ملفك الثاني قطّ، ولا تحمل النتيجة اسم ملف يكشف الاستبدال. اسكك مفتاحًا جديدًا لكل ملف — وUUID هو الشكل المقصود — بما في ذلك عند إعادة رفع ملف أرسلته قبلًا.
const { videoId } = await videohati.videos.create({
originalFilename: "lecture-01.mp4",
idempotencyKey: crypto.randomUUID(), // مُخزَّن مع مهمتك، لا يُولَّد من جديد
});البحث والتصفية
q مطابقة سلسلة فرعية حرفية دون حساسية لحالة الأحرف على title و
originalFilename — وهي ليست بحثًا تقريبيًا ولا بحثًا في النصّ الكامل، ولا
تُطابق description ولا الوسوم. وtag يُعيد الفيديوهات التي تحمل ذلك الوسم
بالضبط. وتُدمَج أيّ منهما مع state، ويُصفَّح الناتج بـ cursor.
const { data, nextCursor } = await videohati.videos.list({
q: "lecture 01",
tag: "arabic-101",
state: "ready",
limit: 50,
});تحديث البيانات الوصفية
videos.update تحديث جزئي: أرسل أي مجموعة فرعية من title وdescription و
tags وvisibility، وواحدًا منها على الأقل.
| الحقل | ماذا يفعل |
|---|---|
title | يُعيد تسمية الفيديو. ولا يتغيّر originalFilename أبدًا. |
description | نصّ حرّ. وdescription: null يمحوه. |
tags | يستبدل القائمة كاملة (20 وسمًا على الأكثر). |
visibility | "private" أو "unlisted" أو "public". وunlisted/public تُفعّل سطح التضمين المجهول — صفحة المشاهدة المستضافة وإطار iframe وoEmbed. |
const video = await videohati.videos.update(videoId, {
title: "المحاضرة 01 — مقدّمة",
tags: ["arabic-101", "semester-1"],
visibility: "unlisted",
description: null, // يمحوه
});الإجراءات المجمّعة
تُطبّق videos.bulk إجراءً واحدًا على 1–100 معرّف فيديو: { action: "delete" }
أو { action: "set_visibility", visibility }. ويتصرّف delete تمامًا مثل
videos.delete لكل معرّف.
يُستجاب للنداء بـ 200 حتى حين تفشل بعض المعرّفات، فافحص راية ok في كل عنصر —
تحمل results عنصرًا واحدًا لكل معرّف مطلوب فريد، بترتيب الطلب، ويحمل العنصر
الفاشل error.code وerror.message.
const { results } = await videohati.videos.bulk({
action: "set_visibility",
visibility: "private",
videoIds: [videoId, otherVideoId],
});
for (const result of results) {
if (!result.ok) console.error(result.videoId, result.error.code);
}القراءة والحذف
const { data } = await videohati.videos.list({ state: "ready" });
const detail = await videohati.videos.get(videoId);
console.log(detail.state); // uploading | uploaded | encoding | ready | failed
await videohati.videos.delete(videoId); // حذف ناعم؛ يُلغي أي رفع نشطالتشغيل
أنشئ الجلسة في خادمك وسلّم sessionToken فقط إلى الصفحة (ضمّنها مع
@videohati/player أو @videohati/react).
| الدالة | التوقيع | ماذا تفعل |
|---|---|---|
createSession | playback.createSession({ videoId, ttlSeconds?, referrerAllowlist?, watermarkText?, viewerId?, projectId? }) | تُنشئ جلسة في الخادم؛ تُعيد sessionToken للمشغّل. |
يستدعي تطبيقك createSession فقط؛ أمّا بقية نداءات التشغيل فيتولّاها المشغّل
بنفسه عبر رمز الجلسة.
معاملات createSession:
| المعامل | الافتراضي | ماذا يفعل |
|---|---|---|
videoId | مطلوب | الفيديو المراد تشغيله. |
ttlSeconds | افتراضي الخادم | عمر الجلسة. |
referrerAllowlist | — | حصر التشغيل في هذه المُحيلات. |
watermarkText | — | تفعيل العلامة المائية: نصّ يُرسم على الفيديو. ويكون كذلك مفتاح حدّ التزامن لكل مشاهد حين لا يُمرَّر viewerId. |
viewerId | — | معرّف المستخدم النهائي عندك. يكون مفتاح حدّ التزامن لكل مشاهد دون رسم أي شيء على الفيديو. |
projectId | — | يُتجاهَل مع مصادقة مفتاح API (المفتاح مقيَّد بمشروع أصلًا). |
const session = await videohati.playback.createSession({
videoId,
watermarkText: "أحمد ك.",
});
console.log(session.sessionToken);حدّ التزامن لكل مشاهد بلا علامة مائية
يُحسَب حدّ الجلسات المتزامنة لكل مشاهد، وتُحدَّد هوية المشاهد بهذا الترتيب:
viewerId، ثم watermarkText، ثم تجزئة عنوان IP للعميل. وبغير أي من الحقلين
يرتدّ كل مستخدم نهائي خلف مفتاح API واحد إلى تجزئة IP — فتنطوي شبكة مشتركة
بأكملها في حدٍّ واحد مشترك.
مرّر viewerId لإصلاح ذلك مع الحفاظ على تشغيل نظيف: فهو يُعرّف المشاهد لأجل
الحدّ فقط. ولا يُخزَّن أبدًا — إذ لا تصل الجلسة إلا تجزئته — ولا يرسم شيئًا.
وتظهر العلامة المائية إن ضبطت watermarkText وفقط إن ضبطته، فـ viewerId وحده
لا يُشعل علامة مائية أبدًا.
// حدود مستقلّة لكل مستخدم نهائي، بلا أي طبقة على الفيديو.
const session = await videohati.playback.createSession({
videoId,
viewerId: user.id,
});
// الاثنان معًا، حين تريد مشاهدًا معرَّفًا وعلامة مائية ظاهرة.
const identified = await videohati.playback.createSession({
videoId,
viewerId: user.id,
watermarkText: user.name,
});Webhooks
سجّل نقاط النهاية، وافحص التسليمات، وتحقّق من التواقيع. التدفّق الكامل في دليل Webhooks.
| الدالة | التوقيع | ماذا تفعل |
|---|---|---|
register | webhooks.register(projectId, { url, description? }) | تُسجّل نقطة نهاية. يُعاد secret مرة واحدة. |
list | webhooks.list(projectId) | تسرد نقاط النهاية. |
get | webhooks.get(projectId, id) | نقطة نهاية واحدة. |
update | webhooks.update(projectId, id, { url?, description?, enabled? }) | تُحدّث نقطة نهاية. |
delete | webhooks.delete(projectId, id) | حذف ناعم لنقطة نهاية. |
rotateSecret | webhooks.rotateSecret(projectId, id) | تسكّ سرًّا جديدًا (يُعاد مرة واحدة). |
reopenCircuit | webhooks.reopenCircuit(projectId, id) | تُعيد نقطة نهاية متعثّرة إلى الخدمة. |
sendTest | webhooks.sendTest(projectId, id) | تُدرِج تسليمًا اصطناعيًا من نوع video.encoding.ready. |
listDeliveries | webhooks.listDeliveries(projectId, id, { limit?, cursor?, state?, eventType? }) | صفحة واحدة من محاولات التسليم. |
verify | webhooks.verify({ payload, header, secret, toleranceSeconds? }) | تتحقّق من توقيع وتُعيد الغلاف المُحلَّل. |
التحقّق من توقيع
يُصدَّر verifyWebhookSignature أيضًا مستقلًّا. مرّر جسم الطلب الخام — لا
JSON مُعاد تسلسله.
import { verifyWebhookSignature, SIGNATURE_HEADER } from "@videohati/node";
app.post("/webhooks/videohati", (req, res) => {
const { valid, event } = verifyWebhookSignature({
payload: req.rawBody, // نص أو Uint8Array، كما وصل تمامًا
header: req.headers[SIGNATURE_HEADER], // "videohati-signature"
secret: process.env.VIDEOHATI_WEBHOOK_SECRET,
});
if (!valid) return res.status(400).end();
if (event?.type === "video.encoding.ready") {
// الفيديو جاهز — أنشئ جلسة تشغيل، أبلغ المستخدم، إلخ.
}
res.status(200).end();
});الترويسة هي Videohati-Signature: t=<unix_ts>,v1=<hex_sha256> (HMAC-SHA256 على
<unix_ts>.<raw_body>)؛ والتحقّق ثابت الزمن ويرفض الطوابع الزمنية الأقدم من 300
ثانية (اضبطها بـ toleranceSeconds).
المشاريع ومفاتيح API
تنشئ المشاريع ومفاتيح API وتديرها من لوحة التحكّم، لا عبر الحزمة. انظر المفاهيم لمعرفة العلاقة بين الحسابات والمشاريع والمفاتيح.
الأخطاء
كل استجابة غير 2xx تُطلق فئة فرعية مكتوبة من VideohatiError، تحمل status
وcode (رمز الآلة من واجهة API، مثل video_not_found). لا تحتوي الأخطاء على
مفتاح API أبدًا.
| الفئة | الحالة | متى |
|---|---|---|
VideohatiConnectionError | — | لم يصل الطلب إلى واجهة API (DNS، TLS، المقبس). |
VideohatiTimeoutError | — | أُلغي الطلب بعد timeoutMs. فئة فرعية من خطأ الاتصال. |
VideohatiValidationError | 400 | فشل الجسم أو الاستعلام في التحقّق. |
VideohatiAuthenticationError | 401 | اعتماد مفقود أو غير صالح. |
VideohatiPermissionError | 403 | اعتماد صالح، لكن الصلاحية غير كافية. |
VideohatiNotFoundError | 404 | المورد مفقود أو غير مرئيّ لهذا المستدعي. |
VideohatiConflictError | 409 | تعارض مع الحالة الراهنة للمورد. |
VideohatiRateLimitError | 429 | تجاوز حدّ المعدّل؛ اقرأ retryAfterSeconds. |
VideohatiServerError | 5xx | فشلت واجهة API في إتمام الطلب. |
import {
VideohatiError,
VideohatiNotFoundError,
VideohatiRateLimitError,
} from "@videohati/node";
try {
await videohati.videos.get(videoId);
} catch (error) {
if (error instanceof VideohatiNotFoundError) {
// 404 — error.code === "video_not_found"
} else if (error instanceof VideohatiRateLimitError) {
await sleep((error.retryAfterSeconds ?? 1) * 1000);
} else if (error instanceof VideohatiError) {
console.error(error.status, error.code, error.message);
}
}تُعاد محاولة طلبات GET وDELETE وردود 429 و5xx بتراجع أُسّي محدود بـ 8 ثوانٍ —
محاولتان افتراضيًا. ويحترم ردّ 429 ترويسة Retry-After. اضبط السلوك بـ
maxRetries وtimeoutMs.