Repository: jacobwgillespie/plex-sync Branch: master Commit: 24e4f5c63edb Files: 14 Total size: 19.8 KB Directory structure: gitextract_q_7pllf5/ ├── .babelrc ├── .eslintrc ├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── bin/ │ └── plex-sync ├── package.json ├── resources/ │ └── travis_after_all.py └── src/ ├── env.js ├── index.js ├── plex.js ├── rateLimit.js └── ui.js ================================================ FILE CONTENTS ================================================ ================================================ FILE: .babelrc ================================================ { "presets": ["es2015", "stage-0"], "plugins": ["transform-runtime"] } ================================================ FILE: .eslintrc ================================================ { "parser": "babel-eslint", "extends": "airbnb-base", "rules": { "no-restricted-syntax": [ "error", "ForInStatement", "LabeledStatement", "WithStatement" ] } } ================================================ FILE: .gitignore ================================================ node_modules/ lib/ .env *.log ================================================ FILE: .travis.yml ================================================ language: node_js node_js: - node - '4' env: global: - CXX=g++-4.8 addons: apt: sources: - ubuntu-toolchain-r-test packages: - g++-4.8 cache: directories: - node_modules - $HOME/.yarn-cache before_install: - npm config set spin false --global script: - npm run lint && npm run test after_failure: - "(cd resources; python travis_after_all.py)" after_success: - "(cd resources; python travis_after_all.py)" - export $(cat resources/.to_export_back) deploy: provider: npm email: jacobwgillespie@gmail.com api_key: secure: HdjDzyh/M6SN4wM8macctTUTr438wOGq6S0mkNEgwBQ0aPM1AqQ3uuI8C6AkS+0YiDS9DD8429n+c55UrPMFlmx5JNe56FTOrwTvWHzG53GB66Tm10Zv+mGVciKcQK5CEBUVLffznpyTS07sb7RFJN8BBAIACp5G18G0+Gd3JxKvsYlXD7/hDIqR72tqSYkzlX1rNXJLKG3X39t05ss9V10JYcfR3joXgIryIUS1/qbiKCckZ+61odgpwEm222HEdfOERbRz4I8VP+WuT29idfpEjVv5D++7GaYlUOjno3QiPi1QxRQkJEnBnAl9/6l+LHm4po01Wmaa8MTMsSJT9HW3Atw06VHcY5qzaGk2zZ1BXLGjmKHSGRj7QgzK5vY00DWM0/N+GJJM8COKCtNGoWVv2ogclRSuYumDl1JL+3gwkz5kqkUMOY9n1JDZJqXMkWfAe10QSWpYK7LG1+6yVLpfjcQB2q+DtiU91pB7gz5b7JTUZHNkr7WYkCqkPotoQ9XMQXSgBBFnX/rg2FcEZO4WKsMUuxzeRylhMgm9NVvDKZx7wHhZy+GrneYBbjNkSDC+jqK4ZSU/MLufU0ISZ3l7GjFRiWL0c99QScYEHlo7DINRmrDTV1n4mih15iBJqp9Z+DnF+WbHUZ29usc+BYHhvOZcwyZGHVQAXnceRZY= on: tags: true repo: jacobwgillespie/plex-sync branch: master condition: "$BUILD_LEADER$BUILD_AGGREGATE_STATUS = YESothers_succeeded" ================================================ FILE: LICENSE ================================================ Copyright (c) 2016 Jacob Gillespie Permission is hereby granted, free of charge, to any person ob- taining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restric- tion, including without limitation the rights to use, copy, modi- fy, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is fur- nished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONIN- FRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # plex-sync A simple command-line utility to synchronize watched / seen status between different [Plex Media Servers](https://plex.tv). [![npm](https://img.shields.io/npm/v/plex-sync.svg?maxAge=2592000)](https://www.npmjs.com/package/plex-sync) [![Travis](https://img.shields.io/travis/jacobwgillespie/plex-sync.svg?maxAge=2592000)](https://travis-ci.org/jacobwgillespie/plex-sync) [![Dependencies](https://david-dm.org/jacobwgillespie/plex-sync.svg)](https://david-dm.org/jacobwgillespie/plex-sync) [![Greenkeeper badge](https://badges.greenkeeper.io/jacobwgillespie/plex-sync.svg)](https://greenkeeper.io/) ![MIT license](https://img.shields.io/badge/license-MIT-blue.svg?maxAge=2592000) [![asciicast](https://asciinema.org/a/9j3oyj46vugcc039l7tbxecw4.png)](https://asciinema.org/a/9j3oyj46vugcc039l7tbxecw4) ## Features * Syncs watch status between different Plex servers. ## Requirements * NodeJS 4+ ## Installation `plex-sync` is installed via NPM: ```shell $ npm install -g plex-sync ``` ## Usage There are several available configuration environment variables: Variable | Description -------- | ----------- `PLEX_TOKEN` | The API token used to access your Plex server. To locate this token, follow the [instructions here](https://support.plex.tv/hc/en-us/articles/204059436-Finding-your-account-token-X-Plex-Token), or use [this bookmarklet](https://jacobwgillespie.github.io/plex-token-bookmarklet/). **The Plex token must be set on the arguments or via the environment variable.** `DRY_RUN` | Set this environment variable to make `plex-sync` print out what it was planning to do rather than actually perform the synchronization. `MATCH_TYPE` | Can be either `fuzzy` (default) or `precise`. When the matching is fuzzy, the script will match items by their year and title. When the matching is precise, the script matches items by their internal Plex GUID, which is usually the IMDb or TMDb ID. This requires an individual API request to be performed for each item (each movie, each TV episode, etc.) and thus is very slow and can potentially overwhelm and crash the Plex server. Use at your own risk. `RATE_LIMIT` | Default `5`. If the `MATCH_TYPE` is set to `precise`, this is the maximum number of concurrent API requests `plex-sync` will make to your server to fetch GUIDs. Use this to (potentially) alleviate performance issues with precise matching. First, find the IDs for the libraries on each server you would like to sync. These IDs can be found at the end of the URL when viewing the library in your browser (like `.../section/ID`). Next, use the CLI as follows: ```shell $ plex-sync [https://][token@]IP[:PORT]/SECTION[,rw] [https://][token@]IP[:PORT]/SECTION[,rw] [[https://][token@]IP[:PORT]/SECTION[,rw]...] ``` ### Examples Sync watched status between two servers, using the default port (`32400`), using library ID `1` for the first server and library `3` for the second: ```shell $ plex-sync 10.0.1.5/1 10.0.1.10/3 ``` Sync three servers, with different ports: ```shell $ plex-sync 10.0.1.5:32401/1 10.0.1.5:32402/1 10.0.1.10/3 ``` Sync with a server via HTTPS: ```shell $ plex-sync 10.0.1.2/2 https://server-domain/3 ``` Dry run, to see what the script will do: ```shell $ DRY_RUN=1 plex-sync 10.0.1.5/1 10.0.1.5/1 ``` Precise matching (slow and may crash the Plex server): ```shell $ MATCH_TYPE=precise plex-sync 10.0.1.5/1 10.0.1.5/1 ``` Syncing between multiple Plex users (different access tokens): ```shell $ plex-sync xxxxxx@10.0.1.5/1 zzzzzz@10.0.1.10/3 ``` Unidirectional sync (read from one server, write to the other): ```shell $ plex-sync 10.0.1.5/1,r 10.0.1.10/3,w ``` Complex use case: ```shell $ plex-sync xxxx@10.0.1.5:32401/1,r https://yyyy@10.0.1.10/3,w zzzz@10.0.1.15/2,rw ``` For more complex strategies, like syncing between multiple different library mappings, just run the tool multiple times. If you need to run the synchronization on a schedule, use another scheduling tool like cron. These more advanced features may be added in the future, but currently `plex-sync` is very simple. ## Contributing Contributions are welcome. Open a pull request or issue to contribute. ## License MIT license. See `LICENSE` for more information. ================================================ FILE: bin/plex-sync ================================================ #!/usr/bin/env node require('../lib/index'); ================================================ FILE: package.json ================================================ { "name": "plex-sync", "version": "0.6.1", "description": "Sync watched status between Plex servers", "main": "lib/index.js", "bin": { "plex-sync": "bin/plex-sync" }, "files": [ "bin", "lib", "src", "LICENSE", "README.md" ], "scripts": { "build": "babel src --out-dir lib/", "lint": "eslint --ignore-path .gitignore .", "preversion": "npm test", "prepublish": "npm run build", "sync": "node lib/index.js", "test": "npm run build", "watch": "npm run build -- --watch" }, "author": "Jacob Gillespie ", "license": "MIT", "dependencies": { "babel-core": "^6.24.1", "babel-runtime": "^6.23.0", "dotenv": "^4.0.0", "isomorphic-fetch": "^2.2.1", "ts-progress": "^0.1.2", "update-notifier": "^2.1.0", "xml2js": "^0.4.17" }, "devDependencies": { "babel-cli": "^6.24.1", "babel-eslint": "^7.2.2", "babel-plugin-transform-runtime": "^6.23.0", "babel-preset-es2015": "^6.24.1", "babel-preset-stage-0": "^6.24.1", "eslint": "^3.19.0", "eslint-config-airbnb-base": "^11.1.3", "eslint-plugin-import": "^2.2.0" }, "repository": { "type": "git", "url": "https://github.com/jacobwgillespie/plex-sync.git" }, "bugs": { "url": "https://github.com/jacobwgillespie/plex-sync/issues" }, "homepage": "https://github.com/jacobwgillespie/plex-sync#readme" } ================================================ FILE: resources/travis_after_all.py ================================================ """ https://github.com/dmakhno/travis_after_all/blob/master/travis_after_all.py """ import os import json import time import logging try: import urllib.request as urllib2 except ImportError: import urllib2 log = logging.getLogger("travis.leader") log.addHandler(logging.StreamHandler()) log.setLevel(logging.INFO) TRAVIS_JOB_NUMBER = 'TRAVIS_JOB_NUMBER' TRAVIS_BUILD_ID = 'TRAVIS_BUILD_ID' POLLING_INTERVAL = 'LEADER_POLLING_INTERVAL' build_id = os.getenv(TRAVIS_BUILD_ID) polling_interval = int(os.getenv(POLLING_INTERVAL, '5')) #assume, first job is the leader is_leader = lambda job_number: job_number.endswith('.1') if not os.getenv(TRAVIS_JOB_NUMBER): # seems even for builds with only one job, this won't get here log.fatal("Don't use defining leader for build without matrix") exit(1) elif is_leader(os.getenv(TRAVIS_JOB_NUMBER)): log.info("This is a leader") else: #since python is subprocess, env variables are exported back via file with open(".to_export_back", "w") as export_var: export_var.write("BUILD_MINION=YES") log.info("This is a minion") exit(0) class MatrixElement(object): def __init__(self, json_raw): self.is_finished = json_raw['finished_at'] is not None self.is_succeeded = json_raw['result'] == 0 self.number = json_raw['number'] self.is_leader = is_leader(self.number) def matrix_snapshot(): """ :return: Matrix List """ response = urllib2.build_opener().open("https://api.travis-ci.org/builds/{0}".format(build_id)).read() raw_json = json.loads(response) matrix_without_leader = [MatrixElement(element) for element in raw_json["matrix"]] return matrix_without_leader def wait_others_to_finish(): def others_finished(): """ Dumps others to finish Leader cannot finish, it is working now :return: tuple(True or False, List of not finished jobs) """ snapshot = matrix_snapshot() finished = [el.is_finished for el in snapshot if not el.is_leader] return reduce(lambda a, b: a and b, finished), [el.number for el in snapshot if not el.is_leader and not el.is_finished] while True: finished, waiting_list = others_finished() if finished: break log.info("Leader waits for minions {0}...".format(waiting_list)) # just in case do not get "silence timeout" time.sleep(polling_interval) try: wait_others_to_finish() final_snapshot = matrix_snapshot() log.info("Final Results: {0}".format([(e.number, e.is_succeeded) for e in final_snapshot])) BUILD_AGGREGATE_STATUS = 'BUILD_AGGREGATE_STATUS' others_snapshot = [el for el in final_snapshot if not el.is_leader] if reduce(lambda a, b: a and b, [e.is_succeeded for e in others_snapshot]): os.environ[BUILD_AGGREGATE_STATUS] = "others_succeeded" elif reduce(lambda a, b: a and b, [not e.is_succeeded for e in others_snapshot]): log.error("Others Failed") os.environ[BUILD_AGGREGATE_STATUS] = "others_failed" else: log.warn("Others Unknown") os.environ[BUILD_AGGREGATE_STATUS] = "unknown" #since python is subprocess, env variables are exported back via file with open(".to_export_back", "w") as export_var: export_var.write("BUILD_LEADER=YES {0}={1}".format(BUILD_AGGREGATE_STATUS, os.environ[BUILD_AGGREGATE_STATUS])) except Exception as e: log.fatal(e) ================================================ FILE: src/env.js ================================================ require('dotenv').config(); ================================================ FILE: src/index.js ================================================ import updateNotifier from 'update-notifier'; import { exitUsage, log, parseCLIArg, progressMap } from './ui'; import { fetchMovies, markWatched } from './plex'; import pkg from '../package.json'; import './env'; updateNotifier({ pkg }).notify(); const DRY_RUN = !!process.env.DRY_RUN; const FUZZY = (process.env.MATCH_TYPE || 'fuzzy') === 'fuzzy'; if (process.argv.length < 4) { exitUsage(); } const servers = process.argv.slice(2).map(parseCLIArg); (async () => { try { log(`Reading data from ${servers.map(server => server.host).join(', ')}...`); const movies = await progressMap( servers, server => fetchMovies(server, FUZZY), ); const watched = new Set(); for (const [idx, serverMovies] of movies.entries()) { const server = servers[idx]; if (server.mode.read) { serverMovies.forEach((movie) => { if (movie.watched) watched.add(movie.guid); }); } } log('Syncing any unsynced media...'); for (const [idx, serverMovies] of movies.entries()) { const server = servers[idx]; if (server.mode.write) { const needsSync = serverMovies.filter( movie => !movie.watched && watched.has(movie.guid), ); // Note: the await here is intentional - we want to process servers one at a time await progressMap( // eslint-disable-line no-await-in-loop needsSync, (media) => { if (DRY_RUN) { log(`Dry run: marking ${media.title} watched on ${server.host}`); return; } markWatched(server, media); }, DRY_RUN, ); } } log('Sync completed!'); } catch (e) { log(e.stack); } })(); ================================================ FILE: src/plex.js ================================================ import { parseString } from 'xml2js'; import fetch from 'isomorphic-fetch'; import { concurrent } from './rateLimit'; import { progressMap } from './ui'; import './env'; const PAGE_SIZE = 32; const parseXML = xml => new Promise((resolve, reject) => { parseString(xml, (err, data) => { if (err) reject(err); else resolve(data); }); }); const fetchText = url => fetch(url) .then(res => res.text()); const fetchXML = url => fetchText(url) .then(res => parseXML(res)); const rateLimitFetchXML = url => concurrent(fetchXML, url); const flatten = list => list.reduce( (a, b) => a.concat(Array.isArray(b) ? flatten(b) : b), [], ); const fetchMediaContainer = async (server, page = 1) => { const start = (page - 1) * PAGE_SIZE; const url = `${server.protocol}://${server.host}/library/sections/${server.section}/allLeaves?X-Plex-Token=${server.token}&X-Plex-Container-Start=${start}&X-Plex-Container-Size=${PAGE_SIZE}`; return rateLimitFetchXML(url); }; export const fetchMedia = async (server) => { // Determine total collection size const totalSize = parseInt(( await fetchMediaContainer(server) ).MediaContainer.$.totalSize, 10); const totalPages = Math.ceil(totalSize / PAGE_SIZE); // Fetch all videos const promises = []; for (let i = 1; i <= totalPages; i += 1) { promises.push(fetchMediaContainer(server, i)); } // Unpack video results const videos = []; await Promise.all(promises).then( (results) => { results.forEach( (res) => { res.MediaContainer.Video.forEach( (video) => { videos.push(video); }, ); }, ); }, ); // Map videos into entries return videos .map( media => media.$, ) .map(({ grandparentTitle = '', index = '', key, parentIndex = '', title, viewCount, year, }) => ({ guid: `${grandparentTitle} - ${parentIndex} - ${index} - ${year} - ${title}`, key, title, watched: parseInt(viewCount || '0', 10) > 0, year, })); }; export const fetchMediaGUID = async (server, media) => { const url = `${server.protocol}://${server.host}${media.key}?X-Plex-Token=${server.token}`; return rateLimitFetchXML(url) .then(res => res.MediaContainer.Video[0].$) .then(({ guid }) => ({ ...media, guid, })); }; export const fetchMovies = async (server, fuzzy = true) => { const media = await fetchMedia(server); return fuzzy ? media : progressMap( media, movie => fetchMediaGUID(server, movie), ); }; const extractID = key => key.match(/\/library\/metadata\/(\d+)/)[1]; export const markWatched = async (server, movie) => fetchText( `${server.protocol}://${server.host}/:/scrobble?identifier=com.plexapp.plugins.library&key=${extractID(movie.key)}&X-Plex-Token=${server.token}`, ); export const markUnatched = async (server, movie) => fetchText( `${server.protocol}://${server.host}/:/unscrobble?identifier=com.plexapp.plugins.library&key=${extractID(movie.key)}&X-Plex-Token=${server.token}`, ); ================================================ FILE: src/rateLimit.js ================================================ const current = []; const backlog = []; export const limit = parseInt(process.env.RATE_LIMIT || '5', 10); export const concurrent = (fn, ...args) => { const enqueue = ([promise, resolve, fn2, ...args2]) => { current.push(promise); resolve(fn2(...args2)); promise.then( (res) => { current.splice(current.indexOf(promise), 1); if (current.length < limit && backlog.length > 0) { enqueue(backlog.pop()); } return res; }, ); return promise; }; let resolve; const promise = new Promise((res) => { resolve = res; }); if (current.length < limit) { enqueue([promise, resolve, fn, ...args]); } else { backlog.push([promise, resolve, fn, ...args]); } return promise; }; ================================================ FILE: src/ui.js ================================================ /* eslint-disable no-console */ import Progress from 'ts-progress'; export const log = (...args) => console.error(...args); export const TOKEN = process.env.PLEX_TOKEN; export const exitUsage = () => { console.error(` Usage: plex-sync [https://][token@]IP[:PORT]/SECTION[,rw] [https://][token@]IP[:PORT]/SECTION[,rw] [[https://][token@]IP[:PORT]/SECTION[,rw]...] Example: Sync section 1 on a server with the default port with section 2 on another server: $ plex-sync 10.0.1.2/1 10.0.1.3:32401/2 Sync three servers: $ plex-sync 10.0.1.2/1 10.0.1.3/1 10.0.1.4/1 Sync with a server via HTTPS $ plex-sync 10.0.1.2/2 https://server-domain/3 Dry run, to see what the script will do: $ DRY_RUN=1 plex-sync 10.0.1.5/1 10.0.1.5/1 Precise matching (slow and may crash the Plex server): $ MATCH_TYPE=precise plex-sync 10.0.1.5/1 10.0.1.5/1 Syncing between multiple Plex users (different access tokens): $ plex-sync xxxxxx@10.0.1.5/1 zzzzzz@10.0.1.10/3 Unidirectional sync (read from one server, write to the other): $ plex-sync 10.0.1.5/1,r 10.0.1.10/3,w Complex use case: $ plex-sync xxxx@10.0.1.5:32401/1,r https://yyyy@10.0.1.10/3,w zzzz@10.0.1.15/2,rw `.trim()); process.exit(1); }; export const exitToken = () => { console.error(` Error: missing Plex authentication token for one or more of the specified servers Please either set your Plex authentication token via the PLEX_TOKEN environment variable or pass the Plex token in the server definition (TOKEN@0.0.0.0...). See 'plex-sync help' for more information. If you need help locating your Plex authentication token, feel free to use the bookmarklet located at https://jacobwgillespie.github.io/plex-token-bookmarklet/ `.trim()); process.exit(1); }; export const progressMap = (items, fn, disable = false) => { const progress = disable ? null : Progress.create({ total: items.length }); return Promise.all(items.map((...args) => { if (!disable) progress.update(); return fn(...args); })); }; export const parseCLIArg = (arg) => { const matches = arg.match(/^((https?):\/\/)?(([^@]+)@)?(([^:]+)(:\d+)?)\/(\d+)(,[rw][rw]?)?$/); if (!matches) exitUsage(); const protocol = matches[2] === 'https' ? 'https' : 'http'; const token = matches[4] || TOKEN; const host = `${matches[6]}${matches[7] || ':32400'}`; const section = matches[8] || '1'; const modeString = matches[10] || 'rw'; const mode = { read: modeString.includes('r'), write: modeString.includes('w'), }; if (!token) exitToken(); return { protocol, token, host, section, mode }; };