This is the template project that's checked out and configured when you run the bando-up command from ljsthw-bandolier. This is where the code really lives.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
bandolier-template/commands/media.js

186 lines
6.1 KiB

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 <seconds_list>", "time where to get screenshot (comma list)", "40,10,0"
],
[ "--force", "force recreating the torrents and screenshots.", false],
[ "--input <glob>", "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();
}