@devbab/plex-tags
Version:
Add Faces and Places tags to photos in Plex Database
687 lines (536 loc) • 21.4 kB
JavaScript
;
require("dotenv").config();
const log = require("@devbab/logger").child({ label: "Plex" });
require("colors");
const Database = require("better-sqlite3");
const fs = require("fs");
const path = require("path");
const { exec } = require("child_process");
const csv = require("csvtojson");
const dayjs = require("dayjs");
const PLEXLIB =
process.env.PLEXLIB ||
path.join(
process.env.LOCALAPPDATA,
"Plex Media Server/Plug-in Support/Databases/com.plexapp.plugins.library.db"
);
const PLEX_MEDIA_PATH =
process.env.PLEXMEDIA ||
path.join(process.env.LOCALAPPDATA, "Plex Media Server/Media/localhost");
const PLEXMEDIASERVER =
process.env.PLEXMEDIASERVER || "C:\\Program Files (x86)\\Plex\\Plex Media Server\\Plex Media Server.exe";
const PHOTO_EXT = "('jpeg','png','raw')";
const MAXLENSQL = 5000;
/*
FACE : in table tags, tag_type 0 && extra_tag = FACE
PLACE : in table tags, tag_type 4000 && extra_tag = PLACE
*/
/** Plex Media Server commands
* -append append the database to the end of the file
-ascii set output mode to 'ascii'
-bail stop after hitting an error
-batch force batch I/O
-box set output mode to 'box'
-column set output mode to 'column'
-cmd COMMAND run "COMMAND" before reading stdin
-csv set output mode to 'csv'
-echo print commands before execution
-init FILENAME read/process named file
-[no]header turn headers on or off
-help show this message
-html set output mode to HTML
-interactive force interactive I/O
-json set output mode to 'json'
-line set output mode to 'line'
-list set output mode to 'list'
-lookaside SIZE N use N entries of SZ bytes for lookaside memory
-markdown set output mode to 'markdown'
-memtrace trace all memory allocations and deallocations
-mmap N default mmap size set to N
-newline SEP set output row separator. Default: '\n'
-nofollow refuse to open symbolic links to database files
-nullvalue TEXT set text string for NULL values. Default ''
-pagecache SIZE N use N slots of SZ bytes each for page cache memory
-quote set output mode to 'quote'
-readonly open the database read-only
-separator SEP set output column separator. Default: '|'
-stats print memory stats before each finalize
-table set output mode to 'table'
-tabs set output mode to 'tabs'
-version show SQLite version
-vfs NAME use NAME as the default VFS
*/
let db = null;
function init() {
//log.debug("plex.init");
if (!fs.existsSync(PLEXLIB)) {
console.error(`ERROR: ${PLEXLIB} does not EXIST`.red);
process.exit(1);
}
// open the database
db = new Database(PLEXLIB, {
// verbose: console.log,
fileMustExist: true,
});
db.pragma("journal_mode = WAL");
db.pragma("synchronous = NORMAL");
return db;
}
/**
* close connection to db
*/
function end() {
db.close();
//log.debug("plex.end");
}
function patch() {
let sql, stmt;
sql = 'ALTER TABLE media_items ADD COLUMN "Face_updated_at" datetime';
try {
stmt = db.prepare(sql);
stmt.run();
}
catch (e) {
console.error(e.message);
}
sql = 'ALTER TABLE media_items ADD COLUMN "Place_updated_at" datetime';
try {
stmt = db.prepare(sql);
stmt.run();
}
catch (e) {
console.error(e.message);
}
sql = 'ALTER TABLE tags ADD COLUMN "extra_tag" varchar(64)';
try {
stmt = db.prepare(sql);
stmt.run();
}
catch (e) {
console.error(e.message);
}
sql = 'ALTER TABLE taggings ADD COLUMN "extra_tag" varchar(64)';
try {
stmt = db.prepare(sql);
stmt.run();
}
catch (e) {
console.error(e.message);
}
}
function listSections() {
let sql = "SELECT id,name, section_type FROM library_sections";
let stmt = db.prepare(sql);
return stmt.all();
}
/**
* List all photos & thumbnails
* @returns
*/
function listPhotosThumbs(file = null) {
let sql = `SELECT MP.file as file,MP.updated_at as updated_at, LS.name as section, MDI.user_thumb_url as thumb_file `;
sql += `FROM media_parts as MP, media_items as MI, metadata_items as MDI, library_sections as LS `;
sql += `WHERE MP.media_item_id = MI.id AND MI.metadata_item_id = MDI.id `;
sql += `AND MI.library_section_id in (SELECT id FROM library_sections where section_type = 13) `;
sql += `AND LS.id = MI.library_section_id `;
sql += `AND MI.container in ${PHOTO_EXT} `;
if (file) sql += `AND MP.file = '${file}' `;
log.debug("listPhotosThumbs", sql);
const stmt = db.prepare(sql);
let req = stmt.all();
req = req.map((elt) => {
elt.file = path.normalize(elt.file);
elt.thumb_file = path.normalize(PLEX_MEDIA_PATH + elt.thumb_file.substr(7));
return elt;
});
return req;
}
/**
* List all photos & thumbnails
* @returns
*/
function listPhotos(file) {
let sql = `SELECT A.file as file,MDI.user_thumb_url as thumb_file `;
sql += `FROM media_parts as A, media_items as MI, metadata_items as MDI `;
sql += `WHERE A.media_item_id = MI.id AND MI.metadata_item_id = MDI.id `;
sql += `AND MI.library_section_id in (SELECT id FROM library_sections where section_type = 13) `;
if (file)
sql += `AND A.file = '${file}'`
//log.debug("listPhotos", sql);
const stmt = db.prepare(sql);
let req = stmt.all();
req = req.map((elt) => {
return {
file: path.normalize(elt.file),
thumb_file: path.normalize(PLEX_MEDIA_PATH + elt.thumb_file.substr(7)),
};
});
return req;
}
/**
* List all photos & thumbnails
* @returns {array} array of {file,mid,FaceUpdateTime, PlaceUpdateTime}
*/
function listPhotosPatched() {
let sql = `SELECT MP.file as file,MI.metadata_item_id as mid, MI.Face_updated_at as FaceUpdateTime, MI.Place_updated_at as PlaceUpdateTime `;
sql += `FROM media_parts as MP, media_items as MI `;
sql += `WHERE MP.media_item_id = MI.id `;
sql += `AND MI.library_section_id in (SELECT id FROM library_sections where section_type = 13)`;
sql += `AND MI.container in ${PHOTO_EXT} `;
const stmt = db.prepare(sql);
let req = stmt.all();
req = req.map((elt) => {
elt.file = path.normalize(elt.file)
return elt;
});
return req;
}
/**
* run a SQL command thourgh Plex Media Server
* NOTE it seems like there a limit in number of results, between 10K and 20K
*
* @param {*} sql
* @returns {Object] json answer
*/
async function run(sql) {
/* let fileOut = path.normalize(`${TMP}/${uniqid.time()}.sql`);
log.debug("temp file", fileOut);
const buffer =
`.echo off
.header on
.open ${PLEXLIB}
.output
${sql}`;
await fsPromises.writeFile(fileOut, buffer).catch(console.error);
//fileOut = path.normalize("c:\\temp\\test.sql");
*/
// mode -json does not work well
const command = `"${PLEXMEDIASERVER}" --sqlite -header "${PLEXLIB}" "${sql}"`;
//const command = `"${PLEXSQL}" -init "${fileOut}" "${PLEXLIB}" .quit`;
log.debug("Plex.run command", command);
return new Promise((resolve, reject) => {
exec(command, (err, stdout, stderr) => {
if (err) {
//some err occurred
console.error("Plex.run Stderr", stderr.red);
reject(stderr);
} else {
//log.debug("stdout:", stdout);
//resolve(stdout);
csv({
noheader: false,
delimiter: "|",
trim: true
})
.fromString(stdout)
.then((json) => { resolve(json); });
}
});
});
}
/**
* run a big SQL command by joining mulitple lines of list until the SQL queries reaches a maxlen.
* joining is done with "",""
*
* @param {array} list list of entries to add
* @param {string} sqlIntro list of initial SQL bit, for instance "INSERT INTO tags (tag, tag_type, extra_tag) VALUES "
* @param {function} buildEntry function called as buildEntry(elt) and returns the sql line to add
* @returns void
*/
async function runBig(list, sqlIntro, buildEntry) {
if (!list?.length) return;
let newbit = [];
// take each elent to add, check if not too long and send to run if adding next one makes it too long
while (list.length > 0) {
//const entry = `('${list[0]}',0,'FACE')`;
const entry = buildEntry(list[0]);
const newlen = sqlIntro.length + newbit.join(",").length + entry.length + 1; // +1 for comma
// log.debug(`addFaces current diff,newbit, len`, diff, newbit, newlen);
if (newlen < MAXLENSQL) { // this will fit, add in the newbit
newbit.push(entry);
list.shift(); // command not run, we consider the next one
}
// command too long or nothing anymore to add
if (newlen > MAXLENSQL || list.length == 0) {
const sql = sqlIntro + newbit.join(",");
log.debug(`plex.runBig sends sql`, sql);
await run(sql).catch((err) => { console.error("plex.runBig error", err); });
newbit = [];
}
}
}
/**
* details main info of a file
* @param {string} imageFile - name of the image file
* @returns {string} - description
*/
function listTagsForImage(imageFile, type, fancy = false) {
let tag_type;
switch (type) {
case 'PLACE': tag_type = 400; break;
case 'FACE': tag_type = 0; break;
default: throw `plex.listTags: wrong type ${type}`
}
let sql = `SELECT T.tag, T.id, TG."index" `;
sql += `from media_parts as MP, media_items as MI, taggings as TG, tags as T `;
sql += `WHERE MP.file = '${imageFile}' AND MP.media_item_id = MI.id AND MI.metadata_item_id = TG.metadata_item_id AND TG.tag_id = T.id `;
sql += `AND T.tag_type = ${tag_type}`;
const stmt = db.prepare(sql);
let resp = stmt.all();
if (fancy && type == "PLACE") { // clarifiy districty, city etc
resp = resp.map(elt => {
switch (elt.index) {
case 0: elt.place_level = "country"; break;
case 1: elt.place_level = "district/urban area"; break;
case 2: elt.place_level = "county/region"; break;
case 3: elt.place_level = "city"; break;
case 4: elt.place_level = "Street/poi"; break;
default: elt.place_level = "unknown"; break;
}
return elt;
});
}
return resp;
}
function findFile(options) {
log.verbose(`findFile`, options);
if (!options?.file && !options?.mid) return;
//m id = media_items.metadata_item_id
// media_items.metadata_item_id
let sql = `SELECT MP.file,MI.metadata_item_id as mid,Place_updated_at, Face_updated_at `;
sql += `from media_parts as MP, media_items as MI `;
sql += `WHERE MP.media_item_id = MI.id `;
if (options?.file) sql += `AND MP.file like '${options.file}' `;
if (options?.mid) sql += `AND MI.metadata_item_id = ${options.mid} `;
log.debug(`findFile SQL ${sql}`);
const stmt = db.prepare(sql);
return stmt.all();
}
function details(options) {
log.verbose(`details`, options);
if (!options?.file && !options?.mid) return;
let resp = findFile(options);
if (resp.length == 0) return {};
let output = {
file: resp[0].file,
mid: resp[0].mid,
Place_updated_at: resp[0].Place_updated_at,
Face_updated_at: resp[0].Face_updated_at
}
//m id = media_items.metadata_item_id
// looking for some tags
let sql = `SELECT TG.id, TG.tag_id as tag_id, TG."index", T.tag_type, T.tag_value,T.tag,T.extra_tag `;
sql += `from media_parts as MP, media_items as MI, taggings as TG, tags as T `;
sql += `WHERE MP.media_item_id = MI.id AND MI.metadata_item_id = TG.metadata_item_id AND TG.tag_id = T.id `;
sql += `AND T.tag_type in (0,400) `;
sql += `AND MI.metadata_item_id = ${resp[0].mid} `;
sql += `ORDER by T.tag ASC`;
log.debug(`details Tag SQL ${sql}`);
const stmt = db.prepare(sql);
resp = stmt.all();
if (resp.length == 0) return output; // no tags, answer with what we have
output.FACES = resp.filter(elt => elt.extra_tag == "FACE").map(elt => {
return {
taggings: `id: ${elt.id}, tag_id: ${elt.tag_id}`,
tag: `tag: "${elt.tag}", index: ${elt.index}`
}
});
output.PLACES = resp.filter(elt => elt.extra_tag == "PLACE").map(elt => {
return {
taggings: `id: ${elt.id}, tag_id: ${elt.tag_id}`,
tag: `tag: "${elt.tag}", index: ${elt.index}, tag_value:${elt.tag_value}`
}
});
return output;
/*
resp = resp.map(elt => {
switch (elt.index) {
case 0: elt.place_level = "country"; break;
case 1: elt.place_level = "district/urban area"; break;
case 2: elt.place_level = "county/region"; break;
case 3: elt.place_level = "city"; break;
case 4: elt.place_level = "Street/poi"; break;
default: elt.place_level = "unknown"; break;
}
return elt;
});
return resp;
*/
}
/**
* returns all Taggings entries which concerns us
* eg index in (0,1,2,3,4). 0 = user tag, (0...4 ) = place tags
* @params {string} type = FACE or PLACE
* @returns
*/
function listTagging(type = null) {
let sql = `select id,metadata_item_id as mid,tag_id,"index" from taggings `;
sql += `WHERE "index" in (0,1,2,3,4) `;
if (type) sql += `AND extra_tag = '${type}'`;
sql += `ORDER by metadata_item_id, tag_id ASC `;
log.debug(`listTagging SQL`, sql);
const stmt = db.prepare(sql);
return stmt.all();
}
/**
* returns all Faces, eg list of entries in tags where extra_tag = 'FACE'
* @param {array} names array of names to which to restrict the search
* @returns
*/
function listFaces(names = null) {
let sql = `SELECT T.id, T.tag from tags as T WHERE T.extra_tag ='FACE' `;
if (names?.length > 0) {
const list = names.map(elt => {
elt = elt.replace(/'/g, "''"); // replace any potential quote by quote uote
return `'${elt}'`;
}).join(",");
sql += `AND T.Tag in (${list}) `; // [A,B] => 'A','B'
}
sql += `ORDER by T.tag ASC`;
log.debug(`listFaces SQL:`, sql);
const stmt = db.prepare(sql);
return stmt.all();
}
/**
* returns all Places, eg list of entries in tags where extra_tag = 'PLACE'
* @param {array} names array of names to which to restrict the search
* @returns array of {id,tag,index}
*/
function listPlaces(names = null) {
let sql = `SELECT T.id, T.tag, T.tag_value from tags as T WHERE T.extra_tag ='PLACE' `;
if (names?.length > 0) {
const list = names.map(elt => {
elt = elt.replace(/'/g, "''"); // replace any potential quote by quote uote
return `'${elt}'`;
}).join(",");
sql += `AND T.Tag in (${list}) `; // [A,B] => 'A','B'
}
sql += `ORDER by T.tag ASC`;
log.debug(`listPlaces SQL:`, sql);
const stmt = db.prepare(sql);
return stmt.all();
}
/**
* return list of Places with level info
* @param {*} name
* @returns
*/
function listFancyPlaces(name = null) {
const resp = listPlaces(name);
return resp.map(elt => {
switch (elt.tag_value) {
case 10: elt.level = "Country"; break;
case 40: elt.level = "District/urban area"; break;
case 20: elt.level = "County/region"; break;
case 30: elt.level = "City"; break;
case 50: elt.level = "Street/POI"; break;
}
return elt;
});
}
/**
*
* @param {array} mids - array of mid , where mid = field metadata_item_id in table media_items
* @param {array} type - one or two of ["PLACE","FACE"]
*
* @return void
*
*/
async function markImagesAsUpdated(mids, type) {
//debug(`markImagesAsUpdated #mid: ${mids.length}`, type);
log.verbose(`markImagesAsUpdated #mid: ${mids.length}`, type);
if (!mids.length) return;
if (!type?.length) return;
let newbit = [];
const now = new dayjs().format("YYYY-MM-DD HH:mm:ss");
let fields = [];
if (type.includes("FACE")) fields.push(`Face_updated_at = '${now}'`);
if (type.includes("PLACE")) fields.push(`Place_updated_at = '${now}'`);
// take each element to add, check if not too long and send to run if adding next one makes it too long
while (mids.length > 0) {
const entry = "" + mids[0]; // converted in string to get a valid entry.length
const sql = `UPDATE media_items SET ${fields.join(",")} where media_items.metadata_item_id in (${newbit.join(",")})`;
const newlen = sql.length + entry.length + 1; // +1 for comma
if (newlen < MAXLENSQL) { // this will fit, add in the newbit
newbit.push(entry);
mids.shift(); // command not run, we consider the next one
}
// command too long or nothing anymore to add
if (newlen > MAXLENSQL || mids.length == 0) {
const sql = `UPDATE media_items SET ${fields.join(",")} where media_items.metadata_item_id in (${newbit.join(",")})`;
log.verbose(`markImagesAsUpdated sends sql`, sql);
await run(sql).catch((err) => { console.error("plex.runBig error", err); });
newbit = [];
}
}
}
function whichDB() {
return PLEXLIB;
}
/**
* delete FACES NOT
*/
function clean() {
log.verbose(`clean`);
//select * from tags as T, taggings as TG where TG.metadata_item_id = 418670 and TG.tag_id = T.id
// file: 'D:\\Photos Malgosia\\360 FRANCE JULY 2016\\20160703 FETE JANEM\\Janem MA_084562.JPG',
//mid: 418670,
let sql;
//sql = `ALTER TABLE media_items DROP COLUMN 'TTP_updated_at'`;
// remove tags not referenced in taggings
sql = `DELETE from taggings where tag_id not in (select id from tags) `;
log.debug(`Clean SQL`, sql);
run(sql);
// remove tags not referenced in taggings
sql = `DELETE from tags where id not in (select tag_id from taggings) AND extra_tag = 'FACE' `;
log.debug(`Clean SQL`, sql);
run(sql);
}
/**
* Find images and thumns totaly within a north-east south-west bound
* @param {Object} ne {latitude,longitude}
* @param {Object} sw {latitude,longitude}
*/
function listBounds({ ne, sw, center }) {
log.verbose(`listBounds`);
console.log(`Plex:Logger`, log.version());
let sql = `select MP.file, MP.hash,MDI.user_thumb_url as thumb_file, MI.metadata_item_id as mid, L.lat_min, L.lat_max, L.lon_min, L.lon_max `;
if (center) sql += `,((${center.latitude} - L.lat_min) * (${center.latitude} - L.lat_min) + (${center.longitude} - L.lon_min)* (${center.longitude} - L.lon_min)) as dist `;
sql += `from media_items as MI, locatables as LT, locations as L, media_parts as MP, metadata_items as MDI `;
sql += `WHERE mid = LT.locatable_id AND LT.location_id = L.id AND MP.media_item_id = mid AND MDI.id = mid `;
sql += ` AND L.lat_min >= ${sw.latitude} AND L.lat_max <= ${ne.latitude} `;
sql += ` AND L.lon_min >= ${sw.longitude} AND L.lon_max <= ${ne.longitude} `;
if (center) sql += `ORDER by dist ASC`;
log.debug(`log.debug: listBounds SQL:`, sql);
const stmt = db.prepare(sql);
let resp = stmt.all();
resp = resp.map((elt) => {
elt.file = path.normalize(elt.file);
elt.thumb_fle = path.normalize(PLEX_MEDIA_PATH + elt.thumb_fle.substr(7));
return elt;
});
return resp;
}
module.exports = {
whichDB,
init,
end,
patch,
clean,
run,
runBig,
listSections,
findFile,
details,
// generic about tagging of photo, place
listTagsForImage,
listFaces,
listPlaces,
listFancyPlaces,
listTagging,
listPhotos,
listPhotosThumbs,
listPhotosPatched, // list of photos, with PLACE, TTP update fields
markImagesAsUpdated,
listBounds
};