From 35135739314809213b3412360f70d20cde804c47 Mon Sep 17 00:00:00 2001 From: DevBab Date: Sun, 22 Mar 2020 15:22:27 +0100 Subject: [PATCH] Add files from upload --- .eslintignore | 2 + .eslintrc.json | 46 ++ .gitignore | 6 + README.md | 33 + exif.js | 67 ++ iso3166.js | 1566 +++++++++++++++++++++++++++++++++++++++++++ package.json | 30 + plex-place.js | 364 ++++++++++ plex-ttp.js | 136 ++++ plex.js | 442 ++++++++++++ plex_screenshot.jpg | Bin 0 -> 17647 bytes 11 files changed, 2692 insertions(+) create mode 100644 .eslintignore create mode 100644 .eslintrc.json create mode 100644 .gitignore create mode 100644 README.md create mode 100644 exif.js create mode 100644 iso3166.js create mode 100644 package.json create mode 100644 plex-place.js create mode 100644 plex-ttp.js create mode 100644 plex.js create mode 100644 plex_screenshot.jpg diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..cdf0d4a --- /dev/null +++ b/.eslintignore @@ -0,0 +1,2 @@ +node_modules/* + diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..f154e23 --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,46 @@ +{ + "env": { + "node": true, + "es6": true + }, + "extends": "eslint:recommended", + "parserOptions": { + "ecmaVersion": 2017 + }, + "rules": { + "indent": [ + "error", + 4, + { + "SwitchCase": 1 + } + ], + "linebreak-style": [ + "error", + "windows" + ], + "quotes": [ + "error", + "double" + ], + "semi": [ + "error", + "always" + ], + "no-empty": [ + "error", + { + "allowEmptyCatch": true + } + ], + "no-console": [ + "error", + { + "allow": [ + "warn", + "error" + ] + } + ] + } +} \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9fb8bc7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +.vscode/* +database* +dbbrowser* +DBstructure* +package-lock.json \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..8e88cec --- /dev/null +++ b/README.md @@ -0,0 +1,33 @@ +# plex-ttp +[Plex](http://plex.tv) is an amazing software to organize, stream, and share your personal multimedia collections, including photos. + +[Tag That Photo](http://tagthatphoto.com), aka TTP, is an amazing software to recognise faces from your collection of photos. + +Unfortunately, Plex does not recognise the XMP tags created by TTP. This script allows one to insert TTP faces recorded into the photos into Plex database. + +## Warning +**The script interacts directly with Plex database.** +Make sure you have a [backup of your database](https://support.plex.tv/articles/201539237-backing-up-plex-media-server-data/) before using this script. +In case of any issues, [restore your database](https://support.plex.tv/articles/201539237-backing-up-plex-media-server-data/) ! + +## Installation + +1/ assuming node.js and npm are already installed, install the package as follow: + + npm install plex-ttp + +## Usage + + usage node plex-ttp.js [-s] [-h] [-l tag] [-d tag] + -s : scan images and put face tags into Plex + -l [tag] : list matching tags + -d tag: delete the tag + -h : show this help + + +**node plex-ttp.js -s** is the basic command to run to scan all photos from our Plex library, extract the tag faces and insert them into Plex. Face names are then available within the [Tag list of Plex](http:plex_screenshot.jpg) + + +* * * + +© 2020 devbab \ No newline at end of file diff --git a/exif.js b/exif.js new file mode 100644 index 0000000..0fe5b3e --- /dev/null +++ b/exif.js @@ -0,0 +1,67 @@ +//const exiftool = require("exiftool-vendored").exiftool; + +const ExifTool = require("exiftool-vendored").ExifTool; +let numCPUs = require("os").cpus().length; +const exiftool = new ExifTool({ + maxProcs: numCPUs +}); + +// maxProcs: DefaultMaxProcs, + +/** + * look for XMP tag and return an array of it + * @param {} filename + * @returns {modif,tags} + */ +function getFromImage(filename) { + // console.log(`getting exif for ${filename}`); + + return new Promise(function (resolve, reject) { + exiftool + .read(filename) + .then(tags => { + + //console.log("exif tags ",tags); + const d = tags.FileModifyDate; + const modif = new Date(d.year, d.month - 1, d.day, d.hour, d.minute, d.second, 0); + //console.log("fileModifyDate: ",d ); + + const lat = tags.GPSLatitude; + const lng = tags.GPSLongitude; + let pos = null; + if (lat && lng) + pos = { + lat: lat, + lng: lng + }; + + let faces = []; + if (Object.prototype.hasOwnProperty.call(tags, "PersonInImage")) + faces = tags.PersonInImage; + + resolve({ + modif: modif, + faces: faces, + pos: pos, + tags: tags + }); + + }) + .catch(err => { + reject(err); + + }); + + }); + +} + +function end() { + exiftool.end(); +} + + +module.exports = { + getFromImage: getFromImage, + end: end +}; \ No newline at end of file diff --git a/iso3166.js b/iso3166.js new file mode 100644 index 0000000..7a5c656 --- /dev/null +++ b/iso3166.js @@ -0,0 +1,1566 @@ +function whereCountry(name) { + name = name.toUpperCase(); + + return iso.find(function (country) { + return country.country.toUpperCase() === name; + }); +} + +function whereAlpha2(alpha2) { + alpha2 = alpha2.toUpperCase(); + + return iso.find(function (country) { + return country.alpha2 === alpha2; + }); +} + +function whereAlpha3(alpha3) { + alpha3 = alpha3.toUpperCase(); + + return iso.find(function (country) { + return country.alpha3 === alpha3; + }); +} + +function whereNumeric(numeric) { + numeric = numeric.toString(); + + return iso.find(function (country) { + return country.numeric === numeric; + }); +} + +const iso = [{ + country: "Afghanistan", + alpha2: "AF", + alpha3: "AFG", + numeric: "004" +}, +{ + country: "Åland Islands", + alpha2: "AX", + alpha3: "ALA", + numeric: "248" +}, +{ + country: "Albania", + alpha2: "AL", + alpha3: "ALB", + numeric: "008" +}, +{ + country: "Algeria", + alpha2: "DZ", + alpha3: "DZA", + numeric: "012" +}, +{ + country: "American Samoa", + alpha2: "AS", + alpha3: "ASM", + numeric: "016" +}, +{ + country: "Andorra", + alpha2: "AD", + alpha3: "AND", + numeric: "020", + format: "HN ST SN$PC CI$CN" +}, +{ + country: "Angola", + alpha2: "AO", + alpha3: "AGO", + numeric: "024" +}, +{ + country: "Anguilla", + alpha2: "AI", + alpha3: "AIA", + numeric: "660" +}, +{ + country: "Antarctica", + alpha2: "AQ", + alpha3: "ATA", + numeric: "010" +}, +{ + country: "Antigua and Barbuda", + alpha2: "AG", + alpha3: "ATG", + numeric: "028" +}, +{ + country: "Argentina", + alpha2: "AR", + alpha3: "ARG", + numeric: "032" +}, +{ + country: "Armenia", + alpha2: "AM", + alpha3: "ARM", + numeric: "051" +}, +{ + country: "Aruba", + alpha2: "AW", + alpha3: "ABW", + numeric: "533" +}, +{ + country: "Australia", + alpha2: "AU", + alpha3: "AUS", + numeric: "036" +}, +{ + country: "Austria", + alpha2: "AT", + alpha3: "AUT", + numeric: "040", + format: "ST SN HN$CI PC$CN" +}, +{ + country: "Azerbaijan", + alpha2: "AZ", + alpha3: "AZE", + numeric: "031" +}, +{ + country: "Bahamas", + alpha2: "BS", + alpha3: "BHS", + numeric: "044" +}, +{ + country: "Bahrain", + alpha2: "BH", + alpha3: "BHR", + numeric: "048" +}, +{ + country: "Bangladesh", + alpha2: "BD", + alpha3: "BGD", + numeric: "050" +}, +{ + country: "Barbados", + alpha2: "BB", + alpha3: "BRB", + numeric: "052" +}, +{ + country: "Belarus", + alpha2: "BY", + alpha3: "BLR", + numeric: "112" +}, +{ + country: "Belgium", + alpha2: "BE", + alpha3: "BEL", + numeric: "056", + format: "ST SN HN$PC CI$CN" + +}, +{ + country: "Belize", + alpha2: "BZ", + alpha3: "BLZ", + numeric: "084" +}, +{ + country: "Benin", + alpha2: "BJ", + alpha3: "BEN", + numeric: "204" +}, +{ + country: "Bermuda", + alpha2: "BM", + alpha3: "BMU", + numeric: "060" +}, +{ + country: "Bhutan", + alpha2: "BT", + alpha3: "BTN", + numeric: "064" +}, +{ + country: "Bolivia", + alpha2: "BO", + alpha3: "BOL", + numeric: "068" +}, +{ + country: "Bonaire, Sint Eustatius and Saba", + alpha2: "BQ", + alpha3: "BES", + numeric: "535" +}, +{ + country: "Bosnia and Herzegovina", + alpha2: "BA", + alpha3: "BIH", + numeric: "070" +}, +{ + country: "Botswana", + alpha2: "BW", + alpha3: "BWA", + numeric: "072" +}, +{ + country: "Bouvet Island", + alpha2: "BV", + alpha3: "BVT", + numeric: "074" +}, +{ + country: "Brazil", + alpha2: "BR", + alpha3: "BRA", + numeric: "076" +}, +{ + country: "British Indian Ocean Territory", + alpha2: "IO", + alpha3: "IOT", + numeric: "086" +}, +{ + country: "Brunei Darussalam", + alpha2: "BN", + alpha3: "BRN", + numeric: "096" +}, +{ + country: "Bulgaria", + alpha2: "BG", + alpha3: "BGR", + numeric: "100" +}, +{ + country: "Burkina Faso", + alpha2: "BF", + alpha3: "BFA", + numeric: "854" +}, +{ + country: "Burundi", + alpha2: "BI", + alpha3: "BDI", + numeric: "108" +}, +{ + country: "Cabo Verde", + alpha2: "CV", + alpha3: "CPV", + numeric: "132" +}, +{ + country: "Cambodia", + alpha2: "KH", + alpha3: "KHM", + numeric: "116" +}, +{ + country: "Cameroon", + alpha2: "CM", + alpha3: "CMR", + numeric: "120" +}, +{ + country: "Canada", + alpha2: "CA", + alpha3: "CAN", + numeric: "124" +}, +{ + country: "Cayman Islands", + alpha2: "KY", + alpha3: "CYM", + numeric: "136" +}, +{ + country: "Central African Republic", + alpha2: "CF", + alpha3: "CAF", + numeric: "140" +}, +{ + country: "Chad", + alpha2: "TD", + alpha3: "TCD", + numeric: "148" +}, +{ + country: "Chile", + alpha2: "CL", + alpha3: "CHL", + numeric: "152" +}, +{ + country: "China", + alpha2: "CN", + alpha3: "CHN", + numeric: "156" +}, +{ + country: "Christmas Island", + alpha2: "CX", + alpha3: "CXR", + numeric: "162" +}, +{ + country: "Cocos Islands", + alpha2: "CC", + alpha3: "CCK", + numeric: "166" +}, +{ + country: "Colombia", + alpha2: "CO", + alpha3: "COL", + numeric: "170" +}, +{ + country: "Comoros", + alpha2: "KM", + alpha3: "COM", + numeric: "174" +}, +{ + country: "Congo", + alpha2: "CG", + alpha3: "COG", + numeric: "178" +}, +{ + country: "Congo", + alpha2: "CD", + alpha3: "COD", + numeric: "180" +}, +{ + country: "Cook Islands", + alpha2: "CK", + alpha3: "COK", + numeric: "184" +}, +{ + country: "Costa Rica", + alpha2: "CR", + alpha3: "CRI", + numeric: "188" +}, +{ + country: "Côte d'Ivoire", + alpha2: "CI", + alpha3: "CIV", + numeric: "384" +}, +{ + country: "Croatia", + alpha2: "HR", + alpha3: "HRV", + numeric: "191" +}, +{ + country: "Cuba", + alpha2: "CU", + alpha3: "CUB", + numeric: "192" +}, +{ + country: "Curaçao", + alpha2: "CW", + alpha3: "CUW", + numeric: "531" +}, +{ + country: "Cyprus", + alpha2: "CY", + alpha3: "CYP", + numeric: "196" +}, +{ + country: "Czech Republic", + alpha2: "CZ", + alpha3: "CZE", + numeric: "203" +}, +{ + country: "Denmark", + alpha2: "DK", + alpha3: "DNK", + numeric: "208", + format: "ST SN HN $PC CI$CN" + +}, +{ + country: "Djibouti", + alpha2: "DJ", + alpha3: "DJI", + numeric: "262" +}, +{ + country: "Dominica", + alpha2: "DM", + alpha3: "DMA", + numeric: "212" +}, +{ + country: "Dominican Republic", + alpha2: "DO", + alpha3: "DOM", + numeric: "214" +}, +{ + country: "Ecuador", + alpha2: "EC", + alpha3: "ECU", + numeric: "218" +}, +{ + country: "Egypt", + alpha2: "EG", + alpha3: "EGY", + numeric: "818" +}, +{ + country: "El Salvador", + alpha2: "SV", + alpha3: "SLV", + numeric: "222" +}, +{ + country: "Equatorial Guinea", + alpha2: "GQ", + alpha3: "GNQ", + numeric: "226" +}, +{ + country: "Eritrea", + alpha2: "ER", + alpha3: "ERI", + numeric: "232" +}, +{ + country: "Estonia", + alpha2: "EE", + alpha3: "EST", + numeric: "233" +}, +{ + country: "Ethiopia", + alpha2: "ET", + alpha3: "ETH", + numeric: "231" +}, +{ + country: "Falkland Islands", + alpha2: "FK", + alpha3: "FLK", + numeric: "238" +}, +{ + country: "Faroe Islands", + alpha2: "FO", + alpha3: "FRO", + numeric: "234" +}, +{ + country: "Fiji", + alpha2: "FJ", + alpha3: "FJI", + numeric: "242" +}, +{ + country: "Finland", + alpha2: "FI", + alpha3: "FIN", + numeric: "246", + format: "ST SN HN$PC CI$CN" + +}, +{ + country: "France", + alpha2: "FR", + alpha3: "FRA", + numeric: "250", + format: "HN ST SN$PC CI$CN" + +}, +{ + country: "French Guiana", + alpha2: "GF", + alpha3: "GUF", + numeric: "254" +}, +{ + country: "French Polynesia", + alpha2: "PF", + alpha3: "PYF", + numeric: "258" +}, +{ + country: "French Southern Territories", + alpha2: "TF", + alpha3: "ATF", + numeric: "260" +}, +{ + country: "Gabon", + alpha2: "GA", + alpha3: "GAB", + numeric: "266" +}, +{ + country: "Gambia", + alpha2: "GM", + alpha3: "GMB", + numeric: "270" +}, +{ + country: "Georgia", + alpha2: "GE", + alpha3: "GEO", + numeric: "268" +}, +{ + country: "Germany", + alpha2: "DE", + alpha3: "DEU", + numeric: "276", + format: "ST SN HN$PC CI$CN" + +}, +{ + country: "Ghana", + alpha2: "GH", + alpha3: "GHA", + numeric: "288" +}, +{ + country: "Gibraltar", + alpha2: "GI", + alpha3: "GIB", + numeric: "292" +}, +{ + country: "Greece", + alpha2: "GR", + alpha3: "GRC", + numeric: "300" + +}, +{ + country: "Greenland", + alpha2: "GL", + alpha3: "GRL", + numeric: "304", + format: "ST SN HN$PC CI$CN" + +}, +{ + country: "Grenada", + alpha2: "GD", + alpha3: "GRD", + numeric: "308" +}, +{ + country: "Guadeloupe", + alpha2: "GP", + alpha3: "GLP", + numeric: "312" +}, +{ + country: "Guam", + alpha2: "GU", + alpha3: "GUM", + numeric: "316" +}, +{ + country: "Guatemala", + alpha2: "GT", + alpha3: "GTM", + numeric: "320" +}, +{ + country: "Guernsey", + alpha2: "GG", + alpha3: "GGY", + numeric: "831" +}, +{ + country: "Guinea", + alpha2: "GN", + alpha3: "GIN", + numeric: "324" +}, +{ + country: "Guinea-Bissau", + alpha2: "GW", + alpha3: "GNB", + numeric: "624" +}, +{ + country: "Guyana", + alpha2: "GY", + alpha3: "GUY", + numeric: "328" +}, +{ + country: "Haiti", + alpha2: "HT", + alpha3: "HTI", + numeric: "332" +}, +{ + country: "Heard Island and McDonald Islands", + alpha2: "HM", + alpha3: "HMD", + numeric: "334" +}, +{ + country: "Holy See", + alpha2: "VA", + alpha3: "VAT", + numeric: "336" +}, +{ + country: "Honduras", + alpha2: "HN", + alpha3: "HND", + numeric: "340" +}, +{ + country: "Hong Kong", + alpha2: "HK", + alpha3: "HKG", + numeric: "344" +}, +{ + country: "Hungary", + alpha2: "HU", + alpha3: "HUN", + numeric: "348" +}, +{ + country: "Iceland", + alpha2: "IS", + alpha3: "ISL", + numeric: "352" +}, +{ + country: "India", + alpha2: "IN", + alpha3: "IND", + numeric: "356" +}, +{ + country: "Indonesia", + alpha2: "ID", + alpha3: "IDN", + numeric: "360" +}, +{ + country: "Islamic Republic of Iran", + alpha2: "IR", + alpha3: "IRN", + numeric: "364" +}, +{ + country: "Iraq", + alpha2: "IQ", + alpha3: "IRQ", + numeric: "368" +}, +{ + country: "Ireland", + alpha2: "IE", + alpha3: "IRL", + numeric: "372", + format: "ST SN HN $CI PC$CN" + +}, +{ + country: "Isle of Man", + alpha2: "IM", + alpha3: "IMN", + numeric: "833" +}, +{ + country: "Israel", + alpha2: "IL", + alpha3: "ISR", + numeric: "376" +}, +{ + country: "Italy", + alpha2: "IT", + alpha3: "ITA", + numeric: "380", + format: "ST SN HN$PC CI$CN" + +}, +{ + country: "Jamaica", + alpha2: "JM", + alpha3: "JAM", + numeric: "388" +}, +{ + country: "Japan", + alpha2: "JP", + alpha3: "JPN", + numeric: "392" +}, +{ + country: "Jersey", + alpha2: "JE", + alpha3: "JEY", + numeric: "832" +}, +{ + country: "Jordan", + alpha2: "JO", + alpha3: "JOR", + numeric: "400" +}, +{ + country: "Kazakhstan", + alpha2: "KZ", + alpha3: "KAZ", + numeric: "398" +}, +{ + country: "Kenya", + alpha2: "KE", + alpha3: "KEN", + numeric: "404" +}, +{ + country: "Kiribati", + alpha2: "KI", + alpha3: "KIR", + numeric: "296" +}, +{ + country: "Democratic People's Republic of Korea", + alpha2: "KP", + alpha3: "PRK", + numeric: "408" +}, +{ + country: "Republic of Korea", + alpha2: "KR", + alpha3: "KOR", + numeric: "410" +}, +{ + country: "Kuwait", + alpha2: "KW", + alpha3: "KWT", + numeric: "414" +}, +{ + country: "Kyrgyzstan", + alpha2: "KG", + alpha3: "KGZ", + numeric: "417" +}, +{ + country: "Lao People's Democratic Republic", + alpha2: "LA", + alpha3: "LAO", + numeric: "418" +}, +{ + country: "Latvia", + alpha2: "LV", + alpha3: "LVA", + numeric: "428" +}, +{ + country: "Lebanon", + alpha2: "LB", + alpha3: "LBN", + numeric: "422" +}, +{ + country: "Lesotho", + alpha2: "LS", + alpha3: "LSO", + numeric: "426" +}, +{ + country: "Liberia", + alpha2: "LR", + alpha3: "LBR", + numeric: "430" +}, +{ + country: "Libya", + alpha2: "LY", + alpha3: "LBY", + numeric: "434" +}, +{ + country: "Liechtenstein", + alpha2: "LI", + alpha3: "LIE", + numeric: "438" +}, +{ + country: "Lithuania", + alpha2: "LT", + alpha3: "LTU", + numeric: "440" +}, +{ + country: "Luxembourg", + alpha2: "LU", + alpha3: "LUX", + numeric: "442" +}, +{ + country: "Macao", + alpha2: "MO", + alpha3: "MAC", + numeric: "446" +}, +{ + country: "Macedonia", + alpha2: "MK", + alpha3: "MKD", + numeric: "807" +}, +{ + country: "Madagascar", + alpha2: "MG", + alpha3: "MDG", + numeric: "450" +}, +{ + country: "Malawi", + alpha2: "MW", + alpha3: "MWI", + numeric: "454" +}, +{ + country: "Malaysia", + alpha2: "MY", + alpha3: "MYS", + numeric: "458" +}, +{ + country: "Maldives", + alpha2: "MV", + alpha3: "MDV", + numeric: "462" +}, +{ + country: "Mali", + alpha2: "ML", + alpha3: "MLI", + numeric: "466" +}, +{ + country: "Malta", + alpha2: "MT", + alpha3: "MLT", + numeric: "470" +}, +{ + country: "Marshall Islands", + alpha2: "MH", + alpha3: "MHL", + numeric: "584" +}, +{ + country: "Martinique", + alpha2: "MQ", + alpha3: "MTQ", + numeric: "474" +}, +{ + country: "Mauritania", + alpha2: "MR", + alpha3: "MRT", + numeric: "478" +}, +{ + country: "Mauritius", + alpha2: "MU", + alpha3: "MUS", + numeric: "480" +}, +{ + country: "Mayotte", + alpha2: "YT", + alpha3: "MYT", + numeric: "175" +}, +{ + country: "Mexico", + alpha2: "MX", + alpha3: "MEX", + numeric: "484" +}, +{ + country: "Federated States of Micronesia", + alpha2: "FM", + alpha3: "FSM", + numeric: "583" +}, +{ + country: "Republic of Moldova", + alpha2: "MD", + alpha3: "MDA", + numeric: "498" +}, +{ + country: "Monaco", + alpha2: "MC", + alpha3: "MCO", + numeric: "492" +}, +{ + country: "Mongolia", + alpha2: "MN", + alpha3: "MNG", + numeric: "496" +}, +{ + country: "Montenegro", + alpha2: "ME", + alpha3: "MNE", + numeric: "499" +}, +{ + country: "Montserrat", + alpha2: "MS", + alpha3: "MSR", + numeric: "500" +}, +{ + country: "Morocco", + alpha2: "MA", + alpha3: "MAR", + numeric: "504" +}, +{ + country: "Mozambique", + alpha2: "MZ", + alpha3: "MOZ", + numeric: "508" +}, +{ + country: "Myanmar", + alpha2: "MM", + alpha3: "MMR", + numeric: "104" +}, +{ + country: "Namibia", + alpha2: "NA", + alpha3: "NAM", + numeric: "516" +}, +{ + country: "Nauru", + alpha2: "NR", + alpha3: "NRU", + numeric: "520" +}, +{ + country: "Nepal", + alpha2: "NP", + alpha3: "NPL", + numeric: "524" +}, +{ + country: "Netherlands", + alpha2: "NL", + alpha3: "NLD", + numeric: "528" + , format: "ST SN HN$PC CI$CN" + +}, +{ + country: "New Caledonia", + alpha2: "NC", + alpha3: "NCL", + numeric: "540" +}, +{ + country: "New Zealand", + alpha2: "NZ", + alpha3: "NZL", + numeric: "554" +}, +{ + country: "Nicaragua", + alpha2: "NI", + alpha3: "NIC", + numeric: "558" +}, +{ + country: "Niger", + alpha2: "NE", + alpha3: "NER", + numeric: "562" +}, +{ + country: "Nigeria", + alpha2: "NG", + alpha3: "NGA", + numeric: "566" +}, +{ + country: "Niue", + alpha2: "NU", + alpha3: "NIU", + numeric: "570" +}, +{ + country: "Norfolk Island", + alpha2: "NF", + alpha3: "NFK", + numeric: "574" +}, +{ + country: "Northern Mariana Islands", + alpha2: "MP", + alpha3: "MNP", + numeric: "580" +}, +{ + country: "Norway", + alpha2: "NO", + alpha3: "NOR", + numeric: "578" +}, +{ + country: "Oman", + alpha2: "OM", + alpha3: "OMN", + numeric: "512" +}, +{ + country: "Pakistan", + alpha2: "PK", + alpha3: "PAK", + numeric: "586" +}, +{ + country: "Palau", + alpha2: "PW", + alpha3: "PLW", + numeric: "585" +}, +{ + country: "State of Palestine", + alpha2: "PS", + alpha3: "PSE", + numeric: "275" +}, +{ + country: "Panama", + alpha2: "PA", + alpha3: "PAN", + numeric: "591" +}, +{ + country: "Papua New Guinea", + alpha2: "PG", + alpha3: "PNG", + numeric: "598" +}, +{ + country: "Paraguay", + alpha2: "PY", + alpha3: "PRY", + numeric: "600" +}, +{ + country: "Peru", + alpha2: "PE", + alpha3: "PER", + numeric: "604" +}, +{ + country: "Philippines", + alpha2: "PH", + alpha3: "PHL", + numeric: "608" +}, +{ + country: "Pitcairn", + alpha2: "PN", + alpha3: "PCN", + numeric: "612" +}, +{ + country: "Poland", + alpha2: "PL", + alpha3: "POL", + numeric: "616" +}, +{ + country: "Portugal", + alpha2: "PT", + alpha3: "PRT", + numeric: "620", + format: "ST SN HN$PC CI$CN" + +}, +{ + country: "Puerto Rico", + alpha2: "PR", + alpha3: "PRI", + numeric: "630" +}, +{ + country: "Qatar", + alpha2: "QA", + alpha3: "QAT", + numeric: "634" +}, +{ + country: "Réunion", + alpha2: "RE", + alpha3: "REU", + numeric: "638" +}, +{ + country: "Romania", + alpha2: "RO", + alpha3: "ROU", + numeric: "642" +}, +{ + country: "Russia", + alpha2: "RU", + alpha3: "RUS", + numeric: "643", + format: "ST SN HN$CI$CN$PC" +}, +{ + country: "Rwanda", + alpha2: "RW", + alpha3: "RWA", + numeric: "646" +}, +{ + country: "Saint Barthélemy", + alpha2: "BL", + alpha3: "BLM", + numeric: "652" +}, +{ + country: "Saint Helena, Ascension and Tristan da Cunha", + alpha2: "SH", + alpha3: "SHN", + numeric: "654" +}, +{ + country: "Saint Kitts and Nevis", + alpha2: "KN", + alpha3: "KNA", + numeric: "659" +}, +{ + country: "Saint Lucia", + alpha2: "LC", + alpha3: "LCA", + numeric: "662" +}, +{ + country: "Saint Martin", + alpha2: "MF", + alpha3: "MAF", + numeric: "663" +}, +{ + country: "Saint Pierre and Miquelon", + alpha2: "PM", + alpha3: "SPM", + numeric: "666" +}, +{ + country: "Saint Vincent and the Grenadines", + alpha2: "VC", + alpha3: "VCT", + numeric: "670" +}, +{ + country: "Samoa", + alpha2: "WS", + alpha3: "WSM", + numeric: "882" +}, +{ + country: "San Marino", + alpha2: "SM", + alpha3: "SMR", + numeric: "674" +}, +{ + country: "Sao Tome and Principe", + alpha2: "ST", + alpha3: "STP", + numeric: "678" +}, +{ + country: "Saudi Arabia", + alpha2: "SA", + alpha3: "SAU", + numeric: "682" +}, +{ + country: "Senegal", + alpha2: "SN", + alpha3: "SEN", + numeric: "686" +}, +{ + country: "Serbia", + alpha2: "RS", + alpha3: "SRB", + numeric: "688" +}, +{ + country: "Seychelles", + alpha2: "SC", + alpha3: "SYC", + numeric: "690" +}, +{ + country: "Sierra Leone", + alpha2: "SL", + alpha3: "SLE", + numeric: "694" +}, +{ + country: "Singapore", + alpha2: "SG", + alpha3: "SGP", + numeric: "702" +}, +{ + country: "Sint Maarten", + alpha2: "SX", + alpha3: "SXM", + numeric: "534" +}, +{ + country: "Slovakia", + alpha2: "SK", + alpha3: "SVK", + numeric: "703" +}, +{ + country: "Slovenia", + alpha2: "SI", + alpha3: "SVN", + numeric: "705" +}, +{ + country: "Solomon Islands", + alpha2: "SB", + alpha3: "SLB", + numeric: "090" +}, +{ + country: "Somalia", + alpha2: "SO", + alpha3: "SOM", + numeric: "706" +}, +{ + country: "South Africa", + alpha2: "ZA", + alpha3: "ZAF", + numeric: "710" +}, +{ + country: "South Georgia and the South Sandwich Islands", + alpha2: "GS", + alpha3: "SGS", + numeric: "239" +}, +{ + country: "South Sudan", + alpha2: "SS", + alpha3: "SSD", + numeric: "728" +}, +{ + country: "Spain", + alpha2: "ES", + alpha3: "ESP", + numeric: "724", + format: "HN ST SN$PC CI$CN" + +}, +{ + country: "Sri Lanka", + alpha2: "LK", + alpha3: "LKA", + numeric: "144" +}, +{ + country: "Sudan", + alpha2: "SD", + alpha3: "SDN", + numeric: "729" +}, +{ + country: "Suriname", + alpha2: "SR", + alpha3: "SUR", + numeric: "740" +}, +{ + country: "Svalbard and Jan Mayen", + alpha2: "SJ", + alpha3: "SJM", + numeric: "744" +}, +{ + country: "Swaziland", + alpha2: "SZ", + alpha3: "SWZ", + numeric: "748" +}, +{ + country: "Sweden", + alpha2: "SE", + alpha3: "SWE", + numeric: "752", + format: "ST SN HN$PC CI$CN" +}, +{ + country: "Switzerland", + alpha2: "CH", + alpha3: "CHE", + numeric: "756", + format: "ST SN HN$PC CI$CN" +}, +{ + country: "Syrian Arab Republic", + alpha2: "SY", + alpha3: "SYR", + numeric: "760" +}, +{ + country: "Taiwan, Province of China", + alpha2: "TW", + alpha3: "TWN", + numeric: "158" +}, +{ + country: "Tajikistan", + alpha2: "TJ", + alpha3: "TJK", + numeric: "762" +}, +{ + country: "United Republic of Tanzania", + alpha2: "TZ", + alpha3: "TZA", + numeric: "834" +}, +{ + country: "Thailand", + alpha2: "TH", + alpha3: "THA", + numeric: "764" +}, +{ + country: "Timor-Leste", + alpha2: "TL", + alpha3: "TLS", + numeric: "626" +}, +{ + country: "Togo", + alpha2: "TG", + alpha3: "TGO", + numeric: "768" +}, +{ + country: "Tokelau", + alpha2: "TK", + alpha3: "TKL", + numeric: "772" +}, +{ + country: "Tonga", + alpha2: "TO", + alpha3: "TON", + numeric: "776" +}, +{ + country: "Trinidad and Tobago", + alpha2: "TT", + alpha3: "TTO", + numeric: "780" +}, +{ + country: "Tunisia", + alpha2: "TN", + alpha3: "TUN", + numeric: "788" +}, +{ + country: "Turkey", + alpha2: "TR", + alpha3: "TUR", + numeric: "792" +}, +{ + country: "Turkmenistan", + alpha2: "TM", + alpha3: "TKM", + numeric: "795" +}, +{ + country: "Turks and Caicos Islands", + alpha2: "TC", + alpha3: "TCA", + numeric: "796" +}, +{ + country: "Tuvalu", + alpha2: "TV", + alpha3: "TUV", + numeric: "798" +}, +{ + country: "Uganda", + alpha2: "UG", + alpha3: "UGA", + numeric: "800" +}, +{ + country: "Ukraine", + alpha2: "UA", + alpha3: "UKR", + numeric: "804" +}, +{ + country: "United Arab Emirates", + alpha2: "AE", + alpha3: "ARE", + numeric: "784" +}, +{ + country: "United Kingdom", + alpha2: "GB", + alpha3: "GBR", + numeric: "826", + format: "ST SN HN$CI PC$CN" + +}, +{ + country: "United States of America", + alpha2: "US", + alpha3: "USA", + numeric: "840" + , format: "HN ST SN$CI PC$CN" + +}, +{ + country: "United States Minor Outlying Islands", + alpha2: "UM", + alpha3: "UMI", + numeric: "581" +}, +{ + country: "Uruguay", + alpha2: "UY", + alpha3: "URY", + numeric: "858" +}, +{ + country: "Uzbekistan", + alpha2: "UZ", + alpha3: "UZB", + numeric: "860" +}, +{ + country: "Vanuatu", + alpha2: "VU", + alpha3: "VUT", + numeric: "548" +}, +{ + country: "Venezuela", + alpha2: "VE", + alpha3: "VEN", + numeric: "862" +}, +{ + country: "Viet Nam", + alpha2: "VN", + alpha3: "VNM", + numeric: "704" +}, +{ + country: "Virgin Islands", + alpha2: "VG", + alpha3: "VGB", + numeric: "092" +}, +{ + country: "Virgin Islands", + alpha2: "VI", + alpha3: "VIR", + numeric: "850" +}, +{ + country: "Wallis and Futuna", + alpha2: "WF", + alpha3: "WLF", + numeric: "876" +}, +{ + country: "Western Sahara", + alpha2: "EH", + alpha3: "ESH", + numeric: "732" +}, +{ + country: "Yemen", + alpha2: "YE", + alpha3: "YEM", + numeric: "887" +}, +{ + country: "Zambia", + alpha2: "ZM", + alpha3: "ZMB", + numeric: "894" +}, +{ + country: "Zimbabwe", + alpha2: "ZW", + alpha3: "ZWE", + numeric: "716" +} +]; + +module.exports = { + whereCountry: whereCountry, + whereAlpha2: whereAlpha2, + whereAlpha3: whereAlpha3, + whereNumeric: whereNumeric +}; \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..4afbaaf --- /dev/null +++ b/package.json @@ -0,0 +1,30 @@ +{ + "name": "plex-ttp", + "version": "1.0.1", + "description": "Add Faces tags from TagThatPhoto to Plex", + "main": "plex-ttp.js", + "scripts": { + "eslint": "eslint --ignore-path .eslintignore ./*.js", + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [ + "Plex", + "TagThatPhoto", + "EXIF", + "Faces" + ], + "author": "devbab", + "license": "ISC", + "dependencies": { + "better-sqlite3": "^6.0.1", + "csvtojson": "^2.0.10", + "etl": "^0.6.12", + "ev": "0.0.7", + "exiftool-vendored": "^10.0.0", + "minimist": "^1.2.0", + "os": "^0.1.1", + "sqlite3": "^4.1.1", + "superagent": "^5.2.2", + "unzipper": "^0.10.10" + } +} diff --git a/plex-place.js b/plex-place.js new file mode 100644 index 0000000..7c79c3c --- /dev/null +++ b/plex-place.js @@ -0,0 +1,364 @@ +const API_KEY = process.env.API_KEY || ""; // put your API_KEY from developer.here.com here + + +// where to put temporary files +const OUTDIR = "c:/temp"; + +const fs = require("fs"); +const path = require("path"); +const argv = require("minimist")(process.argv.slice(2)); +const request = require("superagent"); +const events = require("events"); +const ev = new events.EventEmitter(); +const unzipper = require("unzipper"); +const etl = require("etl"); +const csv = require("csvtojson"); +const plex = require("./plex.js"); +const exif = require("./exif.js"); +const iso = require("./iso3166.js"); + +const usage = ` + usage node plex-place.js [-h] [-l] [-g [-f]] [-s jobId] + -h : show this help + -l : list places + -g : get reverse geocoding on all files with GPS coord if not already updated in Plex. + -f : force the reverse geocoding for all images + -n XX : process on X reverse geocode + -c jobId: check that job is completed + -s jobId: set places into Plex from jobId + -d delete all Places from library + --debug : to see various traces + `; + +let statProcessing = 0, + exifProcessing = 0; // processing EXIF ongoing +let TheRGC = []; // liste des rgc a calculer + +// Need to track when to end EXIF process and close DB connection +// emitted when change in exifProcessing or statProcessing +function evHandler() { + if (argv.debug) + console.log(`exifProcessing ${exifProcessing} `); // eslint-disable-line no-console + + + if (exifProcessing <= 0 && statProcessing <= 0) { + exif.end(); + //console.log("TheRGC", TheRGC); + //console.log("TheRGC length", TheRGC.length); + runBatchGC(); + } +} + + +function findXmlTag(res, tag) { + let regex = new RegExp(`(<${tag}>)([A-z0-9]+)()`); + let id = res.match(regex); + if (id) return id[2]; + else return null; +} + + +function gid2Filename(gid) { + return path.join(OUTDIR, "bgc_" + gid + ".txt"); +} +/** + * prend le ficher TheRGC et lance le batch reversege geocoding + */ + +function runBatchGC() { + + if (TheRGC.length == 0) { + console.log("nothing to batch geocode"); // eslint-disable-line no-console + return; + } + + let url = ["https://batch.geocoder.ls.hereapi.com/6.2/jobs?", + "apiKey=", API_KEY, + "&mode=retrieveAddresses", + "&action=run", + "&header=true", + "&inDelim=|", + "&outDelim=|&outCols=city,county,district,country", + "&outputcombined=true", + "&language=en" + ].join(""); + + let i = 0; + let body = "recId|prox\n" + TheRGC.map(elt => { + return `${i++}|${elt.latlng}`; + }).join("\n"); + + + + request.post(url) + .send(body) + .set("Content-Type", "text/plain") + // .set('Accept', 'application/xml') + .then(res => { + let result = res.body.toString(); + + //console.log(result); + // extrait ReqestId + const gid = findXmlTag(result, "RequestId"); + if (!gid) { + console.error("No RequestId found"); + return; + } + console.log(); // eslint-disable-line no-console + console.log(`${TheRGC.length} reverse geocodes sent`); // eslint-disable-line no-console + console.log(`to check status: node plex-place.js -c ${gid} `); // eslint-disable-line no-console + + // write temp file with the request to batch geocoder + const matching = TheRGC.map(elt => elt.ids).join("\n"); + const fileOut = gid2Filename(gid); + fs.writeFile(fileOut, matching, (err) => { + if (err) throw err; + }); + }) + .catch(err => { + console.error("Error requesting batch geocode", err.message); + }); + +} + +// get result of batch geocoding, match to correspondance file and add EXIF +// tag_valye : 10:country 20:region 30:city 40: 50: road +function listPlaces() { + plex.init(); + let places = plex.scanPlacesTags(); + console.log("Places ", places); // eslint-disable-line no-console + console.log("Number of Countries (10)", places.filter(place => place.tag_value == 10).length); // eslint-disable-line no-console + console.log("Number of Region (20)", places.filter(place => place.tag_value == 20).length); // eslint-disable-line no-console + console.log("Number of City (30)", places.filter(place => place.tag_value == 30).length); // eslint-disable-line no-console + console.log("Number of Urban Area (40)", places.filter(place => place.tag_value == 40).length); // eslint-disable-line no-console + console.log("Number of Streets/POI (50)", places.filter(place => place.tag_value == 50).length); // eslint-disable-line no-console + + plex.end(); +} + +// get result of batch geocoding, match to correspondance file and add EXIF +function addAddresses(gid) { + plex.init(); + plex.scanPlacesTags(); + + // now get result of batch geocoding + let url = ["https://batch.geocoder.ls.hereapi.com/6.2/jobs/", + gid, + "/result?", + "apiKey=", API_KEY + ].join(""); + + // read matching file + const fileOut = gid2Filename(gid); + + let data = null; + try { + data = fs.readFileSync(fileOut, "utf8"); + } catch (err) { + console.error(err.message); + } + + if (!data) + return; + + const matching = data.split("\n"); + + // lit et dezippe la réponse + // une seulle entrée ou plusieurs ? not clear in HERE batch geoding dcoument + request.get(url) + .pipe(unzipper.Parse()) + .pipe(etl.map(async entry => { + const content = await entry.buffer(); + const txt = content.toString(); + + let result = [], + empty = []; + csv({ + noheader: false, + delimiter: "|" + }) + .fromString(txt) + .subscribe((json) => { + if (json.SeqNumber == "1") + result.push(json); + if (json.seqLength == "0") // no result for this entry + empty.push(json); + //console.log("json ",json); + //jsonObj: {"person.number":1234,"person":{"comment":"hello"}} + }) + .on("done", () => { + //if (result.length > 0) console.log("reverse geocoding result", result); + //if (empty.length > 0) console.log("reverse geocoding empty results", empty); + + result.forEach(rec => { + let country = iso.whereAlpha3(rec.country).country; + + let addr = { + city: rec.city, + county: rec.county, + district: rec.district, + country: country + }; + let mids = matching[rec.recId].split(","); // get list of mids + console.log(mids, addr); // eslint-disable-line no-console + mids.forEach(mid => { + plex.deletePlaceTags(mid); // delete existing tags for this image + plex.addPlaceTags(mid, addr); // add new tags + //console.log(mid, addr); + }); + + }); + + //mark empty answers as processed with timestamp into Plex + empty.forEach(rec => { + let mids = matching[rec.recId].split(","); // get list of mids + mids.forEach(mid => { + plex.updatePlaceImageTimestamp(mid); // delete existing tags for this image + //console.log(mid, addr); + }); + }); + + // clean Place tags not referenced anywhere + plex.cleanLonePlaceTags(); + }); + + })) + .catch(err => { + console.error("Error getting batch result", err.message); + }); +} + + +// check if result is available +function checkResultAvailable(gid) { + + // now get result of batch geocoding + let url = ["https://batch.geocoder.ls.hereapi.com/6.2/jobs/", + gid, + "?action=status", + "&apiKey=", API_KEY + ].join(""); + + // lit et dezippe la réponse + // une seulle entrée ou plusieurs ? not clear in HERE batch geoding dcoument + request.get(url) + .then(status => { + const result = status.body.toString(); + //console.log("status ", result); + + console.error("Status ", findXmlTag(result, "Status")); // eslint-disable-line no-console + console.error("TotalCount ", findXmlTag(result, "TotalCount")); // eslint-disable-line no-console + console.error("ValidCount ", findXmlTag(result, "ValidCount")); // eslint-disable-line no-console + console.error("InvalidCount ", findXmlTag(result, "InvalidCount")); // eslint-disable-line no-console + console.log(`\nWhen status completed: node plex-place.js -s ${gid} `); // eslint-disable-line no-console + + }) + .catch(err => { + console.error("Error checking batch job",err.message); + }); +} + + +function DoMainScan() { + + plex.init(); + plex.addColumnPlaceUpdate(); + + let recs = plex.scanPhotos(); + //console.log("Photos ", recs); + + function doTheUpdate(rec) { + + //console.log("doTheUpdate ", rec.file,count,exifProcessing); + //let ptags = plex.getPlaceTags(rec.mid); + exifProcessing++; + exif.getFromImage(rec.file).then(tags => { + + if (tags.pos) { + count++; + + const latlng = tags.pos.lat + "," + tags.pos.lng; + let elt = TheRGC.find(n => n.latlng == latlng); + if (elt) + elt.ids.push(rec.mid); + else + TheRGC.push({ + latlng: latlng, + ids: [rec.mid], + file: rec.file + }); + + } else + plex.updatePlaceImageTimestamp(rec.mid); // updated with no position + + exifProcessing--; + ev.emit("exif"); + + }).catch(err => { + console.log(err.message); // eslint-disable-line no-console + exifProcessing--; + ev.emit("exif"); + }); + } + + console.log(`${recs.length} photos under review`); // eslint-disable-line no-console + let count = 0; + recs.forEach(rec => { + //console.log(rec); + if (argv.n && count >= argv.n) + return; + count++; + + if (!rec.PlaceUpdateTime) rec.PlaceUpdateTime = 0; + let datePlaceUpdate = Date.parse(rec.PlaceUpdateTime); + if (argv.f) // force the update + doTheUpdate(rec); + else { + statProcessing++; + fs.stat(rec.file, (err, stat) => { + statProcessing--; + if (stat && stat.mtimeMs > datePlaceUpdate) + doTheUpdate(rec); + ev.emit("stat"); + + }); + } + }); + + //ready to track stat and exif messages + ev.on("stat", evHandler); + ev.on("exif", evHandler); +} + + +/******************** So what do we do with all that ?********* */ +if (!API_KEY) { + console.log("Missing credentials !"); // eslint-disable-line no-console + console.log("1/ create credentials from https://developer.here.com"); // eslint-disable-line no-console + console.log("2/ add API_KEY as environment variable or put it into file plex-place.js"); // eslint-disable-line no-console + process.exit(0); +} + + +if (argv.h) { + console.log(usage); // eslint-disable-line no-console + process.exit(0); +} + +if (argv.c) + checkResultAvailable(argv.c); + +if (argv.l) + listPlaces(); + +if (argv.d) { + plex.init(); + plex.deleteAllPlaceTags(); + plex.end(); +} + +if (argv.g) + DoMainScan(); + +if (argv.s) + addAddresses(argv.s); \ No newline at end of file diff --git a/plex-ttp.js b/plex-ttp.js new file mode 100644 index 0000000..9a5836e --- /dev/null +++ b/plex-ttp.js @@ -0,0 +1,136 @@ +const fs = require("fs"); +const argv = require("minimist")(process.argv.slice(2)); +const events = require("events"); +const ev = new events.EventEmitter(); +const plex = require("./plex.js"); +const exif = require("./exif.js"); + +const usage = ` + usage node plex-ttp.js [-h] [-c] [-l tag] [-d tag] [-s] + -h : show this help + -s : scan images and put face tags into Plex + -c : clean Lone Tags + -l [tag] : list matching tags + -d tag: delete the tag + `; + +let exifProcessing = 0, // processing EXIF ongoing + statProcessing = 0; // stat file ongoing + + +// Need to track when to end EXIF process and close DB connection +// emitted when change in exifProcessing or statProcessing +const evHandler = function () { + // console.log(`exifProcessing ${exifProcessing} statProcessing ${statProcessing}`); + + if (exifProcessing <= 0 && statProcessing <= 0) { + plex.cleanLoneTTPTags(); + exif.end(); + plex.end(); + } +}; + + +function DoMainScan() { + plex.init(); + + // read list des tags TTP existants + plex.scanTTPTags(); + + // add a colum to bear datetime of TTP tag update + plex.addColumnTTPUpdate(); + + let recs = plex.scanPhotos(); + // eslint-disable-next-line no-console + console.log("Total photos", recs.length, "\n"); + + + function doTheUpdate(rec) { + //console.log("doTheUpdate", rec.file); + exifProcessing++; + + exif.getFromImage(rec.file) + .then(data => { + + plex.deleteTTPTags(rec.mid); // delete any existing tags of the photo + plex.addTTPTags(rec.mid, data.faces); // add new tags + // eslint-disable-next-line no-console + console.log(`${rec.file}:`, data.faces); + // console.log("full ", data.tags); + exifProcessing--; + ev.emit("exif"); + }) + .catch(() => { + exifProcessing--; + ev.emit("exif"); + }); + } + + + recs.forEach(rec => { + //console.log(rec.file); + if (!rec.FaceUpdateTime) rec.FaceUpdateTime = 0; + let dateTTPUpdate = Date.parse(rec.FaceUpdateTime); + statProcessing++; + fs.stat(rec.file, (err, stat) => { + statProcessing--; + if (stat && stat.mtimeMs > dateTTPUpdate) + doTheUpdate(rec); + ev.emit("stat"); + + }); + + }); + //Assign the event handler to an event: + ev.on("stat", evHandler); + ev.on("exif", evHandler); +} + + +/******************** So what do we do with all that ?********* */ + +if (argv.h) + console.log(usage); // eslint-disable-line no-console + + + +if (argv.c) { + plex.init(); + // eslint-disable-next-line no-console + console.log("cleaning Lone tags"); + plex.cleanLoneTTPTags(); + plex.end(); +} + +if (argv.l) { + if (argv.l === true) + argv.l = ""; + + plex.init(); + + let res = plex.listTag(argv.l); + res = res.sort((a, b) => a.tag < b.tag ? -1 : a.tag > b.tag ? 1 : 0); + // eslint-disable-next-line no-console + console.log(res); + // eslint-disable-next-line no-console + console.log(`${res.length} entries`); + plex.end(); +} + +// delete all matching tags +if (argv.d) { + plex.init(); + const res = plex.listTag(argv.d); + // eslint-disable-next-line no-console + console.log("deleting", res); + + const ids = res.map(elt => elt.id); + ids.forEach(id => plex.deleteTTPTags(id)); + + plex.cleanLoneTTPTags(); + plex.end(); +} + + +if (argv.s) + DoMainScan(); \ No newline at end of file diff --git a/plex.js b/plex.js new file mode 100644 index 0000000..539f081 --- /dev/null +++ b/plex.js @@ -0,0 +1,442 @@ +let PLEXLIB = null; +//"C:/Users/chamaide/AppData/Local/Plex Media Server/Plug-in Support/Databases/com.plexapp.plugins.library.db"; + +const Database = require("better-sqlite3"); +const fs = require("fs"); +const path = require("path"); + +let TheTTPTags = null; // list des tags TTP existants +let ThePlaceTags = null; // list des tags Places existants + +let db = null; + +function init() { + //console.log("plex.init"); + // if not specified above, database should be there... + if (!PLEXLIB) + PLEXLIB = path.join(process.env.LOCALAPPDATA, "Plex Media Server/Plug-in Support/Databases/com.plexapp.plugins.library.db"); + + if (!fs.existsSync(PLEXLIB)) { + console.error(`${PLEXLIB} does not EXIST`); + process.exit(1); + } + + // open the database + db = new Database(PLEXLIB, { + // verbose: console.log, + fileMustExist: true + }); +} + +/** + * close connection to db + */ +function end() { + db.close(); + //console.log("plex.end"); +} + + +// add a column to table media_items, to store datetime of TTP update +// catch error if column alreadu exists +function addColumnTTPUpdate() { + let sql = "ALTER TABLE media_items ADD COLUMN \"TTP_updated_at\" datetime"; + try { + let stmt = db.prepare(sql); + stmt.run(); + } catch (err) {} // if already exist, no pb +} + + +// add a column to table media_items, to store datetime of Place update +// catch error if column alreadu exists +function addColumnPlaceUpdate() { + let sql = "ALTER TABLE media_items ADD COLUMN \"Place_updated_at\" datetime"; + try { + let stmt = db.prepare(sql); + stmt.run(); + } catch (err) {} // if already exist, no pb +} + +/** + * look for photo library. used to find items in this library + * returns id of photo library + */ +function getPhotoLibraryId() { + + let sql = "SELECT id as id,name as name, scanner as scanner FROM library_sections WHERE scanner = ?"; + let para = "Plex Photo Scanner"; + + let stmt = db.prepare(sql); + let rec = stmt.all(para); + //console.log(rec); + + return rec[0].id; +} + +/** + * returns list of tags for a media_item_id + * @param {*} file + * @returns [{tid,tag}] taggings_id, tag + */ +function getTTPTags(mid) { + + let sql = `SELECT A.id as tid,B.tag as tag FROM taggings as A, tags as B + WHERE A.metadata_item_id = ? + AND A.tag_id = B.id + AND B.tag_type = 0 + AND B.extra_data='TTP'`; + + let stmt = db.prepare(sql); + let recs = stmt.all(mid); + //console.log("taggins ", recs); + + return recs; +} + + +/** + * returns list of tags for a media_item_id + * @param {} mid mid is metadata_item_id of taggings + * @returns [{tid,tag}] taggings_id, tag + */ +function getPlaceTags(mid) { + + let sql = `SELECT A.id as tid,B.tag as tag,B.tag_value as tag_value FROM taggings as A, tags as B + WHERE A.metadata_item_id = ? + AND A.tag_id = B.id + AND B.tag_type = 400`; + + let stmt = db.prepare(sql); + let recs = stmt.all(mid); + //console.log("taggins ", recs); + + return recs; +} + + + +/** + * remove all TTP tags for a specific mid + * mid is the metadata_item_id of an image + * @param {*} mid + */ +function deleteTTPTags(mid) { + + let tags = getTTPTags(mid); + if (tags.length == 0) // no tags + return; + + let ids = tags.map(tag => tag.tid).join(","); + //console.log("ids ", ids); + + let sql = `DELETE FROM taggings WHERE id IN (${ids})`; + let stmt = db.prepare(sql); + stmt.run(); +} + + +/** + * remove all Places tags for a specific mid + * mid is the metadata_item_id of an image + * remove the tagging link as other images may reference same tag + * @param {*} mid + */ +function deletePlaceTags(mid) { + + let tags = getPlaceTags(mid); + if (tags.length == 0) // no tags + return; + + let ids = tags.map(tag => tag.tid).join(","); + //console.log("ids ", ids); + + let sql = `DELETE FROM taggings WHERE id IN (${ids})`; + let stmt = db.prepare(sql); + stmt.run(); +} + +// delete all tags related to a place +// tag with tag_type = 400 +// and taggings related to them +function deleteAllPlaceTags() { + + let sql = `DELETE FROM taggings WHERE tag_id in + (select id from tags WHERE tag_type = 400) `; + let stmt = db.prepare(sql); + stmt.run(); + + sql = "DELETE FROM tags WHERE tag_type = 400 "; + stmt = db.prepare(sql); + stmt.run(); +} + + + +/** + * remove all TTP Tags not referenced with taggings table + */ +function cleanLoneTTPTags() { + + // search tags not referenced + let sql = `DELETE FROM tags + WHERE tag_type = 0 + AND extra_data = 'TTP' + AND id NOT IN (select tag_id from taggings WHERE "index" = 0)`; + + let stmt = db.prepare(sql); + stmt.run(); +} + + +/** + * remove all Place Tags not referenced with taggings table + */ +function cleanLonePlaceTags() { + // search tags not referenced + let sql = `DELETE FROM tags + WHERE tag_type = 400 + AND id NOT IN (select tag_id from taggings WHERE "index" IN (0,1,2,3,4) )`; + + let stmt = db.prepare(sql); + stmt.run(); +} + + + +/** + * returns list of TTP tags already created + * so as not to check each and every time + * @return array of {id,tag} where id is from table tags + */ +function scanTTPTags() { + if (TheTTPTags) + return; + + // search if tag exists in tags table + let sql = `SELECT id,tag FROM tags + WHERE tag_type = 0 + AND extra_data = 'TTP'`; + let stmt = db.prepare(sql); + TheTTPTags = stmt.all(); +} + + +/** + * returns list of TTP tags already created + * so as not to check each and every time + * tag_valye : 10:country 20:region 30:city 40: 50: road + * @return array of {id,tag,tag_value} where id is from table tags + */ +function scanPlacesTags() { + if (ThePlaceTags) + return; + + // search if tag exists in tags table + let sql = `SELECT id,tag,tag_value FROM tags + WHERE tag_type = 400`; + let stmt = db.prepare(sql); + ThePlaceTags = stmt.all(); + return ThePlaceTags; +} + +/** + * add tags to an image referenced by mid + * assuming the tag is not already associated + * uses global variable TheTTPTags to check if tag exists, and modifies it when a tag is created + * mid is metadata_item_id of image + * @param {*} mid + * @param {*} tags + */ +function addTTPTags(mid, tags) { + // console.log("add tags for meta_item_id ", mid, tags); + + for (let i = 0; i < tags.length; i++) { + let tag = tags[i]; + + let tid = null; + let found = TheTTPTags.find(elt => elt.tag == tag); + if (found) { + //console.log (`${tag} already exists`,found); + tid = found.id; + } else { + // if not exists + const sql = "INSERT INTO tags (tag, tag_type,extra_data) VALUES (?, 0,'TTP')"; + const stmt = db.prepare(sql); + const info = stmt.run(tag); + tid = info.lastInsertRowid; + TheTTPTags.push({ + id: tid, + tag: tag + }); // add a new entry in TheTTPTags + //console.log("created ",tag, " as ",rid) + } + //console.log("tag created ", rid); + + let sql = "INSERT INTO taggings (metadata_item_id, tag_id, \"index\") VALUES (?, ?, '0')"; + let stmt = db.prepare(sql); + stmt.run(mid, tid); + //const rid = info.lastInsertRowid; + //console.log("tagging created ", rid); + + } + + // update field TTP_updated_at + const nowIso = new Date().toISOString(); + const sql = `UPDATE media_items SET TTP_updated_at = '${nowIso}' WHERE metadata_item_id = ${mid}`; + const stmt = db.prepare(sql); + stmt.run(); +} + +/** + * add tags to an image referenced by mid + * assuming the tag is not already associated + * uses global variable ThePlaceTags to check if tag exists, and modifies it when a tag is created + * mid is metadata_item_id of image + * 10:country 20:region 30:city 40: 50: road + * @param {*} mid + * @param {object} {city,county,district,country} + */ +function addPlaceTags(mid, places) { + //console.log("addPlaceTags for meta_item_id ", mid, places); + + const fields = [{ + name: "city", + taggings: 3, + tags: 40 + }, + { + name: "county", + taggings: 2, + tags: 30 + }, + { + name: "district", + taggings: 1, + tags: 20 + }, + { + name: "country", + taggings: 0, + tags: 10 + } + ]; + + if (places.county == places.city && places.district ) + places.county = ""; + + + for (let i = 0; i < fields.length; i++) { + let field = fields[i]; + let name = places[field.name]; + if (!name || name == "") + continue; + + let tid = null; + let found = ThePlaceTags.find(elt => { + return (elt.tag == name && elt.tag_value == field.tags); + }); + if (found) { + tid = found.id; + } else { + // if not exists + // eslint-disable-next-line no-console + console.log("Adding new place ",field.tags); + + const sql = "INSERT INTO tags (tag, tag_type,tag_value, extra_data) VALUES (?, 400,?,'PLACE')"; + const stmt = db.prepare(sql); + const info = stmt.run(name, field.tags); + tid = info.lastInsertRowid; + ThePlaceTags.push({ + id: tid, + tag: name, + tag_value: field.tags + }); + } + //console.log("tag created ", rid); + + let sql = "INSERT INTO taggings (metadata_item_id, tag_id, \"index\") VALUES (?, ?, ?)"; + //console.log("taggings sql ",sql) + let stmt = db.prepare(sql); + stmt.run(mid, tid, field.taggings); + //const rid = info.lastInsertRowid; + //console.log("tagging created ", rid); + + } + + // update field Place_updated_at + const nowIso = new Date().toISOString(); + const sql = `UPDATE media_items SET Place_updated_at = '${nowIso}' WHERE metadata_item_id = ${mid}`; + const stmt = db.prepare(sql); + stmt.run(); +} + + +// met le timestamp de l'image à la date de l'instant +function updatePlaceImageTimestamp(mid) { + // update field Place_updated_at + const nowIso = new Date().toISOString(); + const sql = `UPDATE media_items SET Place_updated_at = '${nowIso}' WHERE metadata_item_id = ${mid}`; + const stmt = db.prepare(sql); + stmt.run(); +} + +// scan all media form library Photo +/** + * + * @param {*} max max files to return + * returns {file,mid,uat} filename, media_item_id, updated_time as date + */ +function scanPhotos(max = 0) { + + let id = getPhotoLibraryId(); + + let sql = `SELECT B.metadata_item_id as mid,A.file as file, + B.TTP_updated_at as FaceUpdateTime, B.Place_updated_at as PlaceUpdateTime + FROM media_parts as A, media_items as B + WHERE A.media_item_id = B.id AND B.library_section_id = ${id} + AND B.container = 'jpeg' `; + if (max != 0) sql += ` LIMIT ${max}`; + let stmt = db.prepare(sql); + let req = stmt.all(); + return req; +} + + +// list tags matching a regex +function listTag(tag) { + + scanTTPTags(); + + if (tag == "") + return TheTTPTags; + + //console.log("TTP tags ", TheTTPTags); + const regex = new RegExp(tag); + + const res = TheTTPTags.filter(elt => elt.tag.match(regex)); + //console.log(res); + return res; +} + +module.exports = { + init: init, + end: end, + listTag: listTag, + scanPhotos: scanPhotos, + + addColumnTTPUpdate: addColumnTTPUpdate, + cleanLoneTTPTags: cleanLoneTTPTags, + addTTPTags: addTTPTags, + deleteTTPTags: deleteTTPTags, + scanTTPTags: scanTTPTags, + + addColumnPlaceUpdate: addColumnPlaceUpdate, + scanPlacesTags: scanPlacesTags, + cleanLonePlaceTags: cleanLonePlaceTags, + getPlaceTags: getPlaceTags, + deletePlaceTags: deletePlaceTags, + deleteAllPlaceTags: deleteAllPlaceTags, + addPlaceTags: addPlaceTags, + updatePlaceImageTimestamp:updatePlaceImageTimestamp +}; \ No newline at end of file diff --git a/plex_screenshot.jpg b/plex_screenshot.jpg new file mode 100644 index 0000000000000000000000000000000000000000..3b2786c5a55d8c906eec003e80b4b9f430e16dbe GIT binary patch literal 17647 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