import path from "path"; import fs from "fs"; import os from "os"; import { execSync, exec } from "child_process"; import { Media } from "../lib/models.js"; import { knex } from "../lib/ormish.js"; import { defer } from "../lib/api.js"; import assert from "assert"; import ffmpeg from "fluent-ffmpeg"; import slugify from "slugify"; import { glob, mkdir_to, changed } from "../lib/builderator.js"; const default_glob = "./media/videos/**/*.mp4"; export const description = "Load the media into the database, creating screenshots."; export const options = [ [ "--screenshot_at ", "time where to get screenshot (comma list)", "40,10,0" ], [ "--force", "force recreating the torrents and screenshots.", false], [ "--input ", "the files to process (glob)", default_glob] ]; export const screenshot = async (video_file, force=false, seek_time=40) => { // parse the filename and extract the date let pinfo = path.parse(video_file); const ffmpeg_defer = defer(); const [target_dir, target_name] = [pinfo.dir.replace(/\\/g,"/").replace("/videos/","/images/"), pinfo.name]; const screen_name = `${target_dir}/${target_name}.screen.jpg`; mkdir_to(screen_name); if(force || changed(video_file, screen_name)) { console.log("SCREEN", screen_name, "at time", seek_time); ffmpeg(video_file) .seekInput(seek_time) .frames(1) .size("?x720") .on("error", (err, stdout, stderr) => { console.error("FAIL", err.message); }) .on("end", () => ffmpeg_defer.resolve()) .save(screen_name); } else { ffmpeg_defer.resolve(); } await ffmpeg_defer; return fs.existsSync(screen_name) ? screen_name : null; } export const check_video = (video_file) => { // this is about the dumbest thing you have to do to see if a video is streamable const stdout = exec(`ffprobe -v debug -print_format json ${video_file}`, {maxBuffer: 30 * 1048 * 1024}, (err, stdout, stderr) => { // ignore error because the command will overload stderr buffer because it's ffmpeg assert(stderr.includes('seeks:0'), `Video file ${video_file} does not have moov faststart.`); }); } export const torrent = async (format, force=false) => { const { loader, media_servers } = await import("../lib/config.js"); const torrent = `${format.filename}.torrent`; const tracker = loader.tracker; const video_file_url = format.filename.slice(1); const video_file = format.filename; const mktorrent = os.platform() === "win32" ? "wsl mktorrent" : "mktorrent"; assert(loader.tracker, "You must set tracker in config/secrets.json:loader"); assert(loader.tracker.startsWith("ws"), `Tracker in secrets/config.json needs to start with ws or wss, but is ${ loader.tracker }`); // mktorrent isn't on windows if(force || changed(video_file, torrent)) { console.log("TORRENT", torrent); if(fs.existsSync(torrent)) fs.unlinkSync(torrent); const web_seeds = media_servers.map(m => `-w "${m}${video_file_url}"`).join(" "); const stdout = execSync(`${mktorrent} -v ${web_seeds} -a "${tracker}" -o "${torrent}" -p -l 18 ${ video_file }`); } return torrent.slice(1); } export const video_meta = async (meta, opts={}) => { const fname = `.${meta.src}`; const stdout = execSync(`ffprobe -v quiet -show_format -show_streams -print_format json "${fname}"`); check_video(fname); Object.assign(meta, JSON.parse(stdout)); // find the screenshots for(let seek_to of opts.screenshot_at) { meta.poster = await screenshot(meta.format.filename, opts.force, seek_to); if(meta.poster) { break; } else { console.log(`${fname}: No screenshot at ${ seek_to }.`); } } meta.format.ctime = fs.statSync(fname).ctime; meta.format.created_at = new Date(meta.format.ctime); meta.format.torrent = await torrent(meta.format, opts.force); return meta; } export const upsert_media = async(video, desc) => { const video_url = video.format.filename.slice(1) const stream = video.streams[0]; const { name } = path.parse(video_url); const data = { "title": video.title, "description": video.description || desc, "created_at": video.format.created_at, // recorded at comes from the filename "updated_at": video.format.ctime, // ctime works better as the update time "src": video_url, "codec_name": stream.codec_name, "width": Number.parseInt(stream.width, 10), "height": Number.parseInt(stream.height, 10), "duration": Number.parseFloat(video.format.duration || stream.duration), "aspect_ratio": stream.display_aspect_ratio.replace(":", "/"), "preload": "auto", "torrent_url": video.format.torrent, "poster": video.poster ? video.poster.slice(1) : null, "slug": slugify(video.title || name, { lower: true, strict: true, trim: true}) } // the db doesn't return the id unless it's an insert, which is stupid await Media.upsert(data, "src"); // find the media we just updated const media = await Media.first({src: data.src}); assert(media, `Failed to find media for ${data.src} that was just added.`); return media; } /** * Takes a comma separated list and returns an Array of ints, or * undefined if this is undefined. * * @param { string } screenshot_at comma list of seek times * @return { Array } List of times in seconds or undefined */ const parse_seek_times = (screenshot_at) => { if(screenshot_at) { const listing = screenshot_at.split(",").map(i => parseInt(i, 10)); return listing; } else { return undefined; } } const load_media = async (opts) => { const media_files = glob(opts.input); opts.screenshot_at = parse_seek_times(opts.screenshot_at); for(let fname of media_files) { // remove the leading . for the src URL const src = fname.slice(1); // attempt to find this media in the database const original = await Media.first({src}) || { src }; const video = await video_meta(original, opts); // use the episode number since ID could not represent the sequence const media = await upsert_media(video, undefined); assert(media !== undefined, `Failed to upsert media for ${fname}.`); } } export const main = async (opts) => { await load_media(opts); knex.destroy(); }