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.
186 lines
6.1 KiB
186 lines
6.1 KiB
2 years ago
|
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();
|
||
|
}
|