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

حزمة 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" — تأكيد بأن بادئة المفتاح تطابقه.
baseUrlhttps://api.videohati.comأصل واجهة API.
maxRetries2إعادة المحاولة للطلبات الآمنة وردود 429 و5xx.
timeoutMs30000مهلة كل محاولة بالميلي ثانية.
fetchfetch العامتطبيق fetch مخصّص.

لا تُرسل مفتاح API إلى المتصفح أبدًا. يستطيع أي شخص قراءته من مصدر الصفحة واستخدام حسابك. للتشغيل، أنشئ الجلسة في الخادم وسلّم رمز الجلسة فقط إلى الصفحة.

المصادقة

يصادق العميل بمفتاح API للمشروع يُرسَل رمزًا حاملًا (bearer). استخدم مفتاح ‏vh_live_ في الإنتاج ومفتاح vh_test_ مقابل بيانات الاختبار — تستنتج الحزمة الوضع من البادئة. تنشئ المفاتيح وتديرها من لوحة التحكّم.

الفيديوهات

يغطّي المورد videos دورة الحياة كاملة: الإنشاء والرفع والقراءة وإدارة الصور المصغّرة.

الدالةالتوقيعماذا تفعل
createvideos.create({ originalFilename, idempotencyKey?, projectId? })تُسجّل فيديو. تُعيد { videoId, state: "uploading" }. الخطوة 1.
listvideos.list({ limit?, cursor?, state?, q?, tag?, projectId? })صفحة واحدة من الفيديوهات: { data, nextCursor? }.
getvideos.get(videoId, { projectId? })التفاصيل الكاملة، بما فيها إسقاطا الرفع والترميز.
updatevideos.update(videoId, { title?, description?, tags?, visibility?, projectId? })تحديث جزئي للبيانات الوصفية؛ يُعيد الفيديو بعد التحديث.
deletevideos.delete(videoId, { projectId? })حذف ناعم للفيديو؛ يُلغي أي رفع نشط.
bulkvideos.bulk({ action, videoIds, visibility?, projectId? })يُطبّق إجراءً واحدًا على 1–100 فيديو؛ نتيجة لكل معرّف.
setThumbnailvideos.setThumbnail(videoId, { data, contentType, projectId? })يضبط صورة مصغّرة مخصّصة (JPEG/PNG/WebP، بحدّ أقصى 5 ميغابايت).
resetThumbnailvideos.resetThumbnail(videoId, { projectId? })يعود إلى إطار الملصق التلقائي.
startUploadvideos.startUpload(videoId, { sizeBytes, contentType, checksumSha256?, idempotencyKey?, projectId? })يفتح رفعًا مُجزّأً؛ يُعيد حجم الجزء وعددها وقالب رابط الأجزاء. الخطوة 2.
completeUploadvideos.completeUpload(videoId, { checksumSha256?, projectId? })يُنهي الرفع. الخطوة 3.
abortUploadvideos.abortUpload(videoId, { projectId? })يُلغي رفعًا مفتوحًا.
uploadvideos.upload(file, options?)يُنفّذ إنشاء ← بدء ← رفع الأجزاء ← إكمال في استدعاء واحد.
waitForStatevideos.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.
partConcurrency4عدد الأجزاء المرفوعة بالتوازي.
maxPartAttempts3عدد المحاولات لكل جزء.
videoIdإعادة استخدام فيديو منشأ مسبقًا بدل إنشاء واحد.
signalAbortSignal لإلغاء الرفع.
fetchfetch العام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).

الدالةالتوقيعماذا تفعل
createSessionplayback.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.

الدالةالتوقيعماذا تفعل
registerwebhooks.register(projectId, { url, description? })تُسجّل نقطة نهاية. يُعاد secret مرة واحدة.
listwebhooks.list(projectId)تسرد نقاط النهاية.
getwebhooks.get(projectId, id)نقطة نهاية واحدة.
updatewebhooks.update(projectId, id, { url?, description?, enabled? })تُحدّث نقطة نهاية.
deletewebhooks.delete(projectId, id)حذف ناعم لنقطة نهاية.
rotateSecretwebhooks.rotateSecret(projectId, id)تسكّ سرًّا جديدًا (يُعاد مرة واحدة).
reopenCircuitwebhooks.reopenCircuit(projectId, id)تُعيد نقطة نهاية متعثّرة إلى الخدمة.
sendTestwebhooks.sendTest(projectId, id)تُدرِج تسليمًا اصطناعيًا من نوع video.encoding.ready.
listDeliverieswebhooks.listDeliveries(projectId, id, { limit?, cursor?, state?, eventType? })صفحة واحدة من محاولات التسليم.
verifywebhooks.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. فئة فرعية من خطأ الاتصال.
VideohatiValidationError400فشل الجسم أو الاستعلام في التحقّق.
VideohatiAuthenticationError401اعتماد مفقود أو غير صالح.
VideohatiPermissionError403اعتماد صالح، لكن الصلاحية غير كافية.
VideohatiNotFoundError404المورد مفقود أو غير مرئيّ لهذا المستدعي.
VideohatiConflictError409تعارض مع الحالة الراهنة للمورد.
VideohatiRateLimitError429تجاوز حدّ المعدّل؛ اقرأ retryAfterSeconds.
VideohatiServerError5xxفشلت واجهة 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.