mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
Adjusted
This commit is contained in:
@@ -0,0 +1,213 @@
|
||||
// services/chibisafe.service.js
|
||||
//
|
||||
// Wraps the Chibisafe REST API.
|
||||
//
|
||||
// Environment variables expected:
|
||||
// CHIBISAFE_BASE_URL – e.g. https://cdn.yourdomain.com
|
||||
// CHIBISAFE_API_KEY – your personal / service-account API key
|
||||
// CHIBISAFE_ALBUM_AVATARS – album UUID for avatar images
|
||||
// CHIBISAFE_ALBUM_VIDEOS – album UUID for videos
|
||||
// CHIBISAFE_ALBUM_DOCUMENTS – album UUID for documents (pdf, docx, ppt, txt…)
|
||||
// CHIBISAFE_ALBUM_THUMBNAILS – album UUID for video thumbnails
|
||||
// CHIBISAFE_ALBUM_ARCHIVED – album UUID used as the "trash" / archived album
|
||||
|
||||
const FormData = require("form-data");
|
||||
const fetch = require("node-fetch"); // npm i node-fetch@2 (CJS-compatible)
|
||||
|
||||
// ─── Config ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const BASE_URL = (process.env.CHIBISAFE_BASE_URL || "").replace(/\/$/, "");
|
||||
const API_KEY = process.env.CHIBISAFE_API_KEY || "";
|
||||
|
||||
const ALBUMS = {
|
||||
avatars: process.env.CHIBISAFE_ALBUM_AVATARS || null,
|
||||
videos: process.env.CHIBISAFE_ALBUM_VIDEOS || null,
|
||||
documents: process.env.CHIBISAFE_ALBUM_DOCUMENTS || null,
|
||||
thumbnails: process.env.CHIBISAFE_ALBUM_THUMBNAILS || null,
|
||||
archived: process.env.CHIBISAFE_ALBUM_ARCHIVED || null,
|
||||
};
|
||||
|
||||
// ─── Internal helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Resolve which Chibisafe album UUID should receive a file based on owner_type.
|
||||
* owner_type is the single source of truth for album routing:
|
||||
*
|
||||
* "avatar" → avatars album (profile pictures)
|
||||
* "video" → videos album (course/content videos)
|
||||
* "document" → documents album (pdf, docx, ppt, txt…)
|
||||
* "thumbnail" → thumbnails album (video cover images)
|
||||
* "image" → no album (general-purpose images)
|
||||
* anything else / null → no album
|
||||
*
|
||||
* @param {string} ownerType – value of the asset's owner_type field
|
||||
* @returns {string|null}
|
||||
*/
|
||||
function resolveAlbumUuid(ownerType = "") {
|
||||
switch (ownerType) {
|
||||
case "avatar": return ALBUMS.avatars;
|
||||
case "video": return ALBUMS.videos;
|
||||
case "document": return ALBUMS.documents;
|
||||
case "thumbnail": return ALBUMS.thumbnails;
|
||||
default: return null; // "image" and unknowns → no album
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build default headers for every Chibisafe request.
|
||||
*/
|
||||
function baseHeaders(extra = {}) {
|
||||
return {
|
||||
"x-api-key": API_KEY,
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Thin fetch wrapper that throws a descriptive error on non-2xx.
|
||||
*/
|
||||
async function chibiRequest(path, options = {}) {
|
||||
const url = `${BASE_URL}${path}`;
|
||||
const res = await fetch(url, options);
|
||||
|
||||
let body;
|
||||
try {
|
||||
body = await res.json();
|
||||
} catch {
|
||||
body = {};
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const msg = body?.message || body?.error || res.statusText;
|
||||
const err = new Error(`[Chibisafe] ${res.status} – ${msg}`);
|
||||
err.status = res.status;
|
||||
err.chibiBody = body;
|
||||
throw err;
|
||||
}
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
// ─── Public API ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Upload a file to Chibisafe, optionally straight into a typed album.
|
||||
*
|
||||
* @param {object} opts
|
||||
* @param {Buffer} opts.buffer – raw file bytes
|
||||
* @param {string} opts.originalname – original filename (for Content-Disposition)
|
||||
* @param {string} opts.mimetype – MIME type
|
||||
* @param {string} opts.ownerType – asset owner_type value used to resolve the album
|
||||
* ("avatar" | "video" | "document" | "thumbnail" | "image")
|
||||
*
|
||||
* @returns {Promise<{ uuid: string, url: string, name: string }>}
|
||||
*/
|
||||
async function uploadFile({ buffer, originalname, mimetype, ownerType = "" }) {
|
||||
if (!BASE_URL || !API_KEY) {
|
||||
throw new Error("[Chibisafe] CHIBISAFE_BASE_URL or CHIBISAFE_API_KEY is not configured.");
|
||||
}
|
||||
|
||||
const albumUuid = resolveAlbumUuid(ownerType);
|
||||
|
||||
const form = new FormData();
|
||||
form.append("file", buffer, {
|
||||
filename: originalname,
|
||||
contentType: mimetype,
|
||||
});
|
||||
|
||||
const headers = {
|
||||
...baseHeaders(),
|
||||
...form.getHeaders(),
|
||||
// Pass the album UUID at upload time so the file lands in the right album
|
||||
// in a single round-trip (official Chibisafe header).
|
||||
...(albumUuid ? { albumuuid: albumUuid } : {}),
|
||||
};
|
||||
|
||||
const data = await chibiRequest("/api/upload", {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: form,
|
||||
});
|
||||
|
||||
// Chibisafe returns: { name, uuid, url, ... }
|
||||
return {
|
||||
uuid: data.uuid,
|
||||
url: data.url,
|
||||
name: data.name,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Permanently delete one file from Chibisafe by its UUID.
|
||||
* Used for rollback cleanup when a DB transaction fails after a successful upload.
|
||||
*
|
||||
* @param {string} uuid – Chibisafe file UUID
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function deleteFile(uuid) {
|
||||
await chibiRequest(`/api/file/${uuid}`, {
|
||||
method: "DELETE",
|
||||
headers: baseHeaders(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Move one or more files into the "archived" album (soft-delete equivalent).
|
||||
* Preserves the file on Chibisafe but keeps it out of active albums.
|
||||
*
|
||||
* @param {string|string[]} uuids – Chibisafe file UUID(s)
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function archiveFiles(uuids) {
|
||||
if (!ALBUMS.archived) {
|
||||
throw new Error("[Chibisafe] CHIBISAFE_ALBUM_ARCHIVED is not configured.");
|
||||
}
|
||||
|
||||
const ids = Array.isArray(uuids) ? uuids : [uuids];
|
||||
if (!ids.length) return;
|
||||
|
||||
await chibiRequest("/api/files/album/add", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
...baseHeaders(),
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
files: ids,
|
||||
albumUuid: ALBUMS.archived,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Move one or more files into a specific album by UUID.
|
||||
* Used internally; you can also call it directly for custom album operations.
|
||||
*
|
||||
* @param {string|string[]} uuids
|
||||
* @param {string} albumUuid
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function addFilesToAlbum(uuids, albumUuid) {
|
||||
const ids = Array.isArray(uuids) ? uuids : [uuids];
|
||||
|
||||
await chibiRequest("/api/files/album/add", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
...baseHeaders(),
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
files: ids,
|
||||
albumUuid,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
uploadFile,
|
||||
deleteFile,
|
||||
archiveFiles,
|
||||
addFilesToAlbum,
|
||||
ALBUMS,
|
||||
resolveAlbumUuid,
|
||||
};
|
||||
@@ -0,0 +1,140 @@
|
||||
// services/ffprobe.service.js
|
||||
//
|
||||
// Extracts video metadata only (dimensions, duration, codecs, bitrate, frame rate).
|
||||
// Thumbnail is provided by the client as a separate uploaded file — not generated here.
|
||||
//
|
||||
// Dependencies:
|
||||
// npm install fluent-ffmpeg ffprobe-static
|
||||
|
||||
const ffmpeg = require("fluent-ffmpeg");
|
||||
const ffprobeStatic = require("ffprobe-static");
|
||||
const os = require("os");
|
||||
const path = require("path");
|
||||
const fs = require("fs");
|
||||
|
||||
// Use system ffprobe if available, otherwise fall back to the static binary.
|
||||
try {
|
||||
const { execSync } = require("child_process");
|
||||
execSync("which ffprobe", { stdio: "ignore" });
|
||||
// system binary found — fluent-ffmpeg picks it up automatically
|
||||
} catch {
|
||||
ffmpeg.setFfprobePath(ffprobeStatic.path);
|
||||
}
|
||||
|
||||
// ─── Internal helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
function writeTempFile(buffer, extension) {
|
||||
const tmpPath = path.join(
|
||||
os.tmpdir(),
|
||||
`asset_${Date.now()}_${Math.random().toString(36).slice(2)}.${extension}`,
|
||||
);
|
||||
fs.writeFileSync(tmpPath, buffer);
|
||||
return tmpPath;
|
||||
}
|
||||
|
||||
function cleanupTempFile(filePath) {
|
||||
try { fs.unlinkSync(filePath); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse frame rate from ffprobe's fraction string (e.g. "30/1", "24000/1001").
|
||||
*/
|
||||
function parseFrameRate(rateStr = "") {
|
||||
if (!rateStr || rateStr === "0/0") return null;
|
||||
const [num, den] = rateStr.split("/").map(Number);
|
||||
if (!den || den === 0) return num || null;
|
||||
return parseFloat((num / den).toFixed(3));
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve human-readable resolution label. Mirrors the controller helper.
|
||||
*/
|
||||
function resolveResolution(width, height) {
|
||||
if (!width || !height) return null;
|
||||
const h = Math.min(width, height);
|
||||
if (h >= 2160) return "4K";
|
||||
if (h >= 1440) return "1440p";
|
||||
if (h >= 1080) return "1080p";
|
||||
if (h >= 720) return "720p";
|
||||
if (h >= 480) return "480p";
|
||||
if (h >= 360) return "360p";
|
||||
if (h >= 240) return "240p";
|
||||
return `${width}x${height}`;
|
||||
}
|
||||
|
||||
function probeFile(filePath) {
|
||||
return new Promise((resolve, reject) => {
|
||||
ffmpeg.ffprobe(filePath, (err, metadata) => {
|
||||
if (err) return reject(err);
|
||||
resolve(metadata);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Public API ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Extract video metadata from a Buffer.
|
||||
* Thumbnail is NOT generated here — the client uploads it as a separate file.
|
||||
*
|
||||
* @param {object} opts
|
||||
* @param {Buffer} opts.buffer raw video bytes (multer memoryStorage)
|
||||
* @param {string} opts.extension file extension without dot, e.g. "mp4"
|
||||
*
|
||||
* @returns {Promise<VideoMeta>}
|
||||
*
|
||||
* @typedef {object} VideoMeta
|
||||
* @property {number|null} width
|
||||
* @property {number|null} height
|
||||
* @property {string|null} resolution "1080p", "720p", "4K", …
|
||||
* @property {number|null} duration seconds
|
||||
* @property {number|null} frame_rate fps
|
||||
* @property {number|null} bitrate bps
|
||||
* @property {string|null} video_codec "H.264", "H.265", …
|
||||
* @property {string|null} audio_codec "AAC", "MP3", …
|
||||
*/
|
||||
async function extractVideoMeta({ buffer, extension }) {
|
||||
const tmpPath = writeTempFile(buffer, extension || "mp4");
|
||||
|
||||
try {
|
||||
const raw = await probeFile(tmpPath);
|
||||
|
||||
const videoStream = raw.streams?.find((s) => s.codec_type === "video") || {};
|
||||
const audioStream = raw.streams?.find((s) => s.codec_type === "audio") || {};
|
||||
const format = raw.format || {};
|
||||
|
||||
const width = videoStream.width || null;
|
||||
const height = videoStream.height || null;
|
||||
const duration = parseFloat(format.duration || videoStream.duration || 0) || null;
|
||||
const bitrate = parseInt(format.bit_rate || videoStream.bit_rate || 0, 10) || null;
|
||||
const frame_rate = parseFrameRate(videoStream.r_frame_rate || videoStream.avg_frame_rate);
|
||||
const resolution = resolveResolution(width, height);
|
||||
|
||||
const VIDEO_CODEC_MAP = {
|
||||
h264: "H.264", avc1: "H.264",
|
||||
h265: "H.265", hevc: "H.265",
|
||||
vp8: "VP8", vp9: "VP9",
|
||||
av1: "AV1",
|
||||
};
|
||||
const AUDIO_CODEC_MAP = {
|
||||
aac: "AAC",
|
||||
mp3: "MP3", mp3float: "MP3",
|
||||
opus: "Opus",
|
||||
vorbis: "Vorbis",
|
||||
flac: "FLAC",
|
||||
pcm_s16le: "PCM",
|
||||
};
|
||||
|
||||
const video_codec = VIDEO_CODEC_MAP[(videoStream.codec_name || "").toLowerCase()]
|
||||
|| videoStream.codec_name || null;
|
||||
const audio_codec = AUDIO_CODEC_MAP[(audioStream.codec_name || "").toLowerCase()]
|
||||
|| audioStream.codec_name || null;
|
||||
|
||||
return { width, height, resolution, duration, frame_rate, bitrate, video_codec, audio_codec };
|
||||
|
||||
} finally {
|
||||
cleanupTempFile(tmpPath);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { extractVideoMeta };
|
||||
Reference in New Issue
Block a user