[
  {
    "path": ".babelrc",
    "content": "{\n  \"presets\": [\"es2015\", \"stage-0\"],\n  \"plugins\": [\"transform-runtime\"]\n}\n"
  },
  {
    "path": ".eslintrc",
    "content": "{\n  \"parser\": \"babel-eslint\",\n  \"extends\": \"airbnb-base\",\n  \"rules\": {\n    \"no-restricted-syntax\": [\n      \"error\",\n      \"ForInStatement\",\n      \"LabeledStatement\",\n      \"WithStatement\"\n    ]\n  }\n}\n"
  },
  {
    "path": ".gitignore",
    "content": "node_modules/\nlib/\n.env\n*.log\n"
  },
  {
    "path": ".travis.yml",
    "content": "language: node_js\n\nnode_js:\n- node\n- '4'\n\nenv:\n  global:\n    - CXX=g++-4.8\n\naddons:\n  apt:\n    sources:\n      - ubuntu-toolchain-r-test\n    packages:\n      - g++-4.8\n\ncache:\n  directories:\n  - node_modules\n  - $HOME/.yarn-cache\n\nbefore_install:\n- npm config set spin false --global\n\nscript:\n- npm run lint && npm run test\n\nafter_failure:\n- \"(cd resources; python travis_after_all.py)\"\n\nafter_success:\n- \"(cd resources; python travis_after_all.py)\"\n- export $(cat resources/.to_export_back)\n\ndeploy:\n  provider: npm\n  email: jacobwgillespie@gmail.com\n  api_key:\n    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=\n  on:\n    tags: true\n    repo: jacobwgillespie/plex-sync\n    branch: master\n    condition: \"$BUILD_LEADER$BUILD_AGGREGATE_STATUS = YESothers_succeeded\"\n"
  },
  {
    "path": "LICENSE",
    "content": "Copyright (c) 2016 Jacob Gillespie <jacobwgillespie@gmail.com>\n\nPermission  is  hereby granted, free of charge, to any person ob-\ntaining a copy of  this  software  and  associated  documentation\nfiles  (the \"Software\"), to deal in the Software without restric-\ntion, including without limitation the rights to use, copy, modi-\nfy, merge, publish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software is  fur-\nnished to do so, subject to the following conditions:\n\nThe  above  copyright  notice and this permission notice shall be\nincluded in all copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF  ANY  KIND,\nEXPRESS  OR  IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES\nOF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE  AND  NONIN-\nFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE\nLIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER  IN  AN\nACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN\nCONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN  THE\nSOFTWARE.\n"
  },
  {
    "path": "README.md",
    "content": "# plex-sync\n\nA simple command-line utility to synchronize watched / seen status between different [Plex Media Servers](https://plex.tv).\n\n[![npm](https://img.shields.io/npm/v/plex-sync.svg?maxAge=2592000)](https://www.npmjs.com/package/plex-sync)\n[![Travis](https://img.shields.io/travis/jacobwgillespie/plex-sync.svg?maxAge=2592000)](https://travis-ci.org/jacobwgillespie/plex-sync)\n[![Dependencies](https://david-dm.org/jacobwgillespie/plex-sync.svg)](https://david-dm.org/jacobwgillespie/plex-sync)\n[![Greenkeeper badge](https://badges.greenkeeper.io/jacobwgillespie/plex-sync.svg)](https://greenkeeper.io/)\n![MIT license](https://img.shields.io/badge/license-MIT-blue.svg?maxAge=2592000)\n\n[![asciicast](https://asciinema.org/a/9j3oyj46vugcc039l7tbxecw4.png)](https://asciinema.org/a/9j3oyj46vugcc039l7tbxecw4)\n\n## Features\n\n* Syncs watch status between different Plex servers.\n\n## Requirements\n\n* NodeJS 4+\n\n## Installation\n\n`plex-sync` is installed via NPM:\n\n```shell\n$ npm install -g plex-sync\n```\n\n## Usage\n\nThere are several available configuration environment variables:\n\nVariable | Description\n-------- | -----------\n`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.**\n`DRY_RUN` | Set this environment variable to make `plex-sync` print out what it was planning to do rather than actually perform the synchronization.\n`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.\n`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.\n\nFirst, 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`).\n\nNext, use the CLI as follows:\n\n```shell\n$ plex-sync [https://][token@]IP[:PORT]/SECTION[,rw] [https://][token@]IP[:PORT]/SECTION[,rw]\n            [[https://][token@]IP[:PORT]/SECTION[,rw]...]\n```\n\n### Examples\n\nSync watched status between two servers, using the default port (`32400`), using library ID `1` for the first server and library `3` for the second:\n\n```shell\n$ plex-sync 10.0.1.5/1 10.0.1.10/3\n```\n\nSync three servers, with different ports:\n\n```shell\n$ plex-sync 10.0.1.5:32401/1 10.0.1.5:32402/1 10.0.1.10/3\n```\n\nSync with a server via HTTPS:\n\n```shell\n$ plex-sync 10.0.1.2/2 https://server-domain/3\n```\n\nDry run, to see what the script will do:\n\n```shell\n$ DRY_RUN=1 plex-sync 10.0.1.5/1 10.0.1.5/1\n```\n\nPrecise matching (slow and may crash the Plex server):\n\n```shell\n$ MATCH_TYPE=precise plex-sync 10.0.1.5/1 10.0.1.5/1\n```\n\nSyncing between multiple Plex users (different access tokens):\n\n```shell\n$ plex-sync xxxxxx@10.0.1.5/1 zzzzzz@10.0.1.10/3\n```\n\nUnidirectional sync (read from one server, write to the other):\n\n```shell\n$ plex-sync 10.0.1.5/1,r 10.0.1.10/3,w\n```\n\nComplex use case:\n\n```shell\n$ 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\n```\n\nFor 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.\n\n## Contributing\n\nContributions are welcome.  Open a pull request or issue to contribute.\n\n## License\n\nMIT license.  See `LICENSE` for more information.\n"
  },
  {
    "path": "bin/plex-sync",
    "content": "#!/usr/bin/env node\nrequire('../lib/index');\n"
  },
  {
    "path": "package.json",
    "content": "{\n  \"name\": \"plex-sync\",\n  \"version\": \"0.6.1\",\n  \"description\": \"Sync watched status between Plex servers\",\n  \"main\": \"lib/index.js\",\n  \"bin\": {\n    \"plex-sync\": \"bin/plex-sync\"\n  },\n  \"files\": [\n    \"bin\",\n    \"lib\",\n    \"src\",\n    \"LICENSE\",\n    \"README.md\"\n  ],\n  \"scripts\": {\n    \"build\": \"babel src --out-dir lib/\",\n    \"lint\": \"eslint --ignore-path .gitignore .\",\n    \"preversion\": \"npm test\",\n    \"prepublish\": \"npm run build\",\n    \"sync\": \"node lib/index.js\",\n    \"test\": \"npm run build\",\n    \"watch\": \"npm run build -- --watch\"\n  },\n  \"author\": \"Jacob Gillespie <jacobwgillespie@gmail.com>\",\n  \"license\": \"MIT\",\n  \"dependencies\": {\n    \"babel-core\": \"^6.24.1\",\n    \"babel-runtime\": \"^6.23.0\",\n    \"dotenv\": \"^4.0.0\",\n    \"isomorphic-fetch\": \"^2.2.1\",\n    \"ts-progress\": \"^0.1.2\",\n    \"update-notifier\": \"^2.1.0\",\n    \"xml2js\": \"^0.4.17\"\n  },\n  \"devDependencies\": {\n    \"babel-cli\": \"^6.24.1\",\n    \"babel-eslint\": \"^7.2.2\",\n    \"babel-plugin-transform-runtime\": \"^6.23.0\",\n    \"babel-preset-es2015\": \"^6.24.1\",\n    \"babel-preset-stage-0\": \"^6.24.1\",\n    \"eslint\": \"^3.19.0\",\n    \"eslint-config-airbnb-base\": \"^11.1.3\",\n    \"eslint-plugin-import\": \"^2.2.0\"\n  },\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"https://github.com/jacobwgillespie/plex-sync.git\"\n  },\n  \"bugs\": {\n    \"url\": \"https://github.com/jacobwgillespie/plex-sync/issues\"\n  },\n  \"homepage\": \"https://github.com/jacobwgillespie/plex-sync#readme\"\n}\n"
  },
  {
    "path": "resources/travis_after_all.py",
    "content": "\"\"\"\nhttps://github.com/dmakhno/travis_after_all/blob/master/travis_after_all.py\n\"\"\"\n\nimport os\nimport json\nimport time\nimport logging\n\ntry:\n    import urllib.request as urllib2\nexcept ImportError:\n    import urllib2\n\nlog = logging.getLogger(\"travis.leader\")\nlog.addHandler(logging.StreamHandler())\nlog.setLevel(logging.INFO)\n\nTRAVIS_JOB_NUMBER = 'TRAVIS_JOB_NUMBER'\nTRAVIS_BUILD_ID = 'TRAVIS_BUILD_ID'\nPOLLING_INTERVAL = 'LEADER_POLLING_INTERVAL'\n\nbuild_id = os.getenv(TRAVIS_BUILD_ID)\npolling_interval = int(os.getenv(POLLING_INTERVAL, '5'))\n\n#assume, first job is the leader\nis_leader = lambda job_number: job_number.endswith('.1')\n\nif not os.getenv(TRAVIS_JOB_NUMBER):\n    # seems even for builds with only one job, this won't get here\n    log.fatal(\"Don't use defining leader for build without matrix\")\n    exit(1)\nelif is_leader(os.getenv(TRAVIS_JOB_NUMBER)):\n    log.info(\"This is a leader\")\nelse:\n    #since python is subprocess, env variables are exported back via file\n    with open(\".to_export_back\", \"w\") as export_var:\n        export_var.write(\"BUILD_MINION=YES\")\n    log.info(\"This is a minion\")\n    exit(0)\n\n\nclass MatrixElement(object):\n    def __init__(self, json_raw):\n        self.is_finished = json_raw['finished_at'] is not None\n        self.is_succeeded = json_raw['result'] == 0\n        self.number = json_raw['number']\n        self.is_leader = is_leader(self.number)\n\n\ndef matrix_snapshot():\n    \"\"\"\n    :return: Matrix List\n    \"\"\"\n    response = urllib2.build_opener().open(\"https://api.travis-ci.org/builds/{0}\".format(build_id)).read()\n    raw_json = json.loads(response)\n    matrix_without_leader = [MatrixElement(element) for element in raw_json[\"matrix\"]]\n    return matrix_without_leader\n\n\ndef wait_others_to_finish():\n    def others_finished():\n        \"\"\"\n        Dumps others to finish\n        Leader cannot finish, it is working now\n        :return: tuple(True or False, List of not finished jobs)\n        \"\"\"\n        snapshot = matrix_snapshot()\n        finished = [el.is_finished for el in snapshot if not el.is_leader]\n        return reduce(lambda a, b: a and b, finished), [el.number for el in snapshot if\n                                                        not el.is_leader and not el.is_finished]\n\n    while True:\n        finished, waiting_list = others_finished()\n        if finished: break\n        log.info(\"Leader waits for minions {0}...\".format(waiting_list))  # just in case do not get \"silence timeout\"\n        time.sleep(polling_interval)\n\n\ntry:\n    wait_others_to_finish()\n\n    final_snapshot = matrix_snapshot()\n    log.info(\"Final Results: {0}\".format([(e.number, e.is_succeeded) for e in final_snapshot]))\n\n    BUILD_AGGREGATE_STATUS = 'BUILD_AGGREGATE_STATUS'\n    others_snapshot = [el for el in final_snapshot if not el.is_leader]\n    if reduce(lambda a, b: a and b, [e.is_succeeded for e in others_snapshot]):\n        os.environ[BUILD_AGGREGATE_STATUS] = \"others_succeeded\"\n    elif reduce(lambda a, b: a and b, [not e.is_succeeded for e in others_snapshot]):\n        log.error(\"Others Failed\")\n        os.environ[BUILD_AGGREGATE_STATUS] = \"others_failed\"\n    else:\n        log.warn(\"Others Unknown\")\n        os.environ[BUILD_AGGREGATE_STATUS] = \"unknown\"\n    #since python is subprocess, env variables are exported back via file\n    with open(\".to_export_back\", \"w\") as export_var:\n        export_var.write(\"BUILD_LEADER=YES {0}={1}\".format(BUILD_AGGREGATE_STATUS, os.environ[BUILD_AGGREGATE_STATUS]))\n\nexcept Exception as e:\n    log.fatal(e)\n"
  },
  {
    "path": "src/env.js",
    "content": "require('dotenv').config();\n"
  },
  {
    "path": "src/index.js",
    "content": "import updateNotifier from 'update-notifier';\n\nimport { exitUsage, log, parseCLIArg, progressMap } from './ui';\nimport { fetchMovies, markWatched } from './plex';\nimport pkg from '../package.json';\n\nimport './env';\n\nupdateNotifier({ pkg }).notify();\n\nconst DRY_RUN = !!process.env.DRY_RUN;\nconst FUZZY = (process.env.MATCH_TYPE || 'fuzzy') === 'fuzzy';\n\nif (process.argv.length < 4) {\n  exitUsage();\n}\n\nconst servers = process.argv.slice(2).map(parseCLIArg);\n\n(async () => {\n  try {\n    log(`Reading data from ${servers.map(server => server.host).join(', ')}...`);\n\n    const movies = await progressMap(\n      servers,\n      server => fetchMovies(server, FUZZY),\n    );\n\n    const watched = new Set();\n\n    for (const [idx, serverMovies] of movies.entries()) {\n      const server = servers[idx];\n\n      if (server.mode.read) {\n        serverMovies.forEach((movie) => {\n          if (movie.watched) watched.add(movie.guid);\n        });\n      }\n    }\n\n    log('Syncing any unsynced media...');\n\n    for (const [idx, serverMovies] of movies.entries()) {\n      const server = servers[idx];\n\n      if (server.mode.write) {\n        const needsSync = serverMovies.filter(\n          movie => !movie.watched && watched.has(movie.guid),\n        );\n\n        // Note: the await here is intentional - we want to process servers one at a time\n        await progressMap( // eslint-disable-line no-await-in-loop\n          needsSync,\n          (media) => {\n            if (DRY_RUN) {\n              log(`Dry run: marking ${media.title} watched on ${server.host}`);\n              return;\n            }\n\n            markWatched(server, media);\n          },\n          DRY_RUN,\n        );\n      }\n    }\n\n    log('Sync completed!');\n  } catch (e) {\n    log(e.stack);\n  }\n})();\n"
  },
  {
    "path": "src/plex.js",
    "content": "import { parseString } from 'xml2js';\nimport fetch from 'isomorphic-fetch';\n\nimport { concurrent } from './rateLimit';\nimport { progressMap } from './ui';\n\nimport './env';\n\nconst PAGE_SIZE = 32;\n\nconst parseXML = xml => new Promise((resolve, reject) => {\n  parseString(xml, (err, data) => {\n    if (err) reject(err);\n    else resolve(data);\n  });\n});\n\nconst fetchText = url =>\n  fetch(url)\n  .then(res => res.text());\n\nconst fetchXML = url =>\n  fetchText(url)\n  .then(res => parseXML(res));\n\nconst rateLimitFetchXML = url =>\n  concurrent(fetchXML, url);\n\nconst flatten = list => list.reduce(\n  (a, b) => a.concat(Array.isArray(b) ? flatten(b) : b), [],\n);\n\nconst fetchMediaContainer = async (server, page = 1) => {\n  const start = (page - 1) * PAGE_SIZE;\n  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}`;\n  return rateLimitFetchXML(url);\n};\n\nexport const fetchMedia = async (server) => {\n  // Determine total collection size\n  const totalSize = parseInt((\n    await fetchMediaContainer(server)\n  ).MediaContainer.$.totalSize, 10);\n  const totalPages = Math.ceil(totalSize / PAGE_SIZE);\n\n  // Fetch all videos\n  const promises = [];\n  for (let i = 1; i <= totalPages; i += 1) {\n    promises.push(fetchMediaContainer(server, i));\n  }\n\n  // Unpack video results\n  const videos = [];\n  await Promise.all(promises).then(\n    (results) => {\n      results.forEach(\n        (res) => {\n          res.MediaContainer.Video.forEach(\n            (video) => {\n              videos.push(video);\n            },\n          );\n        },\n      );\n    },\n  );\n\n  // Map videos into entries\n  return videos\n  .map(\n    media => media.$,\n  )\n  .map(({\n    grandparentTitle = '',\n    index = '',\n    key,\n    parentIndex = '',\n    title,\n    viewCount,\n    year,\n  }) => ({\n    guid: `${grandparentTitle} - ${parentIndex} - ${index} - ${year} - ${title}`,\n    key,\n    title,\n    watched: parseInt(viewCount || '0', 10) > 0,\n    year,\n  }));\n};\n\nexport const fetchMediaGUID = async (server, media) => {\n  const url = `${server.protocol}://${server.host}${media.key}?X-Plex-Token=${server.token}`;\n  return rateLimitFetchXML(url)\n  .then(res => res.MediaContainer.Video[0].$)\n  .then(({ guid }) => ({\n    ...media,\n    guid,\n  }));\n};\n\nexport const fetchMovies = async (server, fuzzy = true) => {\n  const media = await fetchMedia(server);\n  return fuzzy ? media : progressMap(\n    media,\n    movie => fetchMediaGUID(server, movie),\n  );\n};\n\nconst extractID = key => key.match(/\\/library\\/metadata\\/(\\d+)/)[1];\n\nexport const markWatched = async (server, movie) => fetchText(\n  `${server.protocol}://${server.host}/:/scrobble?identifier=com.plexapp.plugins.library&key=${extractID(movie.key)}&X-Plex-Token=${server.token}`,\n);\n\nexport const markUnatched = async (server, movie) => fetchText(\n  `${server.protocol}://${server.host}/:/unscrobble?identifier=com.plexapp.plugins.library&key=${extractID(movie.key)}&X-Plex-Token=${server.token}`,\n);\n"
  },
  {
    "path": "src/rateLimit.js",
    "content": "const current = [];\nconst backlog = [];\nexport const limit = parseInt(process.env.RATE_LIMIT || '5', 10);\nexport const concurrent = (fn, ...args) => {\n  const enqueue = ([promise, resolve, fn2, ...args2]) => {\n    current.push(promise);\n    resolve(fn2(...args2));\n    promise.then(\n      (res) => {\n        current.splice(current.indexOf(promise), 1);\n\n        if (current.length < limit && backlog.length > 0) {\n          enqueue(backlog.pop());\n        }\n        return res;\n      },\n    );\n    return promise;\n  };\n\n  let resolve;\n  const promise = new Promise((res) => {\n    resolve = res;\n  });\n\n  if (current.length < limit) {\n    enqueue([promise, resolve, fn, ...args]);\n  } else {\n    backlog.push([promise, resolve, fn, ...args]);\n  }\n\n  return promise;\n};\n"
  },
  {
    "path": "src/ui.js",
    "content": "/* eslint-disable no-console */\n\nimport Progress from 'ts-progress';\n\nexport const log = (...args) => console.error(...args);\n\nexport const TOKEN = process.env.PLEX_TOKEN;\n\nexport const exitUsage = () => {\n  console.error(`\nUsage: plex-sync [https://][token@]IP[:PORT]/SECTION[,rw] [https://][token@]IP[:PORT]/SECTION[,rw]\n                 [[https://][token@]IP[:PORT]/SECTION[,rw]...]\n\nExample:\n\n    Sync section 1 on a server with the default port with section 2 on another server:\n    $ plex-sync 10.0.1.2/1 10.0.1.3:32401/2\n\n    Sync three servers:\n    $ plex-sync 10.0.1.2/1 10.0.1.3/1 10.0.1.4/1\n\n    Sync with a server via HTTPS\n    $ plex-sync 10.0.1.2/2 https://server-domain/3\n\n    Dry run, to see what the script will do:\n    $ DRY_RUN=1 plex-sync 10.0.1.5/1 10.0.1.5/1\n\n    Precise matching (slow and may crash the Plex server):\n    $ MATCH_TYPE=precise plex-sync 10.0.1.5/1 10.0.1.5/1\n\n    Syncing between multiple Plex users (different access tokens):\n    $ plex-sync xxxxxx@10.0.1.5/1 zzzzzz@10.0.1.10/3\n\n    Unidirectional sync (read from one server, write to the other):\n    $ plex-sync 10.0.1.5/1,r 10.0.1.10/3,w\n\n    Complex use case:\n    $ 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\n`.trim());\n  process.exit(1);\n};\n\nexport const exitToken = () => {\n  console.error(`\nError: missing Plex authentication token for one or more of the specified servers\n\nPlease either set your Plex authentication token via the PLEX_TOKEN environment variable\nor pass the Plex token in the server definition (TOKEN@0.0.0.0...).  See 'plex-sync help'\nfor more information.\n\nIf you need help locating your Plex authentication token, feel free to use the bookmarklet\nlocated at https://jacobwgillespie.github.io/plex-token-bookmarklet/\n`.trim());\n  process.exit(1);\n};\n\nexport const progressMap = (items, fn, disable = false) => {\n  const progress = disable ? null : Progress.create({ total: items.length });\n\n  return Promise.all(items.map((...args) => {\n    if (!disable) progress.update();\n    return fn(...args);\n  }));\n};\n\nexport const parseCLIArg = (arg) => {\n  const matches = arg.match(/^((https?):\\/\\/)?(([^@]+)@)?(([^:]+)(:\\d+)?)\\/(\\d+)(,[rw][rw]?)?$/);\n  if (!matches) exitUsage();\n  const protocol = matches[2] === 'https' ? 'https' : 'http';\n  const token = matches[4] || TOKEN;\n  const host = `${matches[6]}${matches[7] || ':32400'}`;\n  const section = matches[8] || '1';\n  const modeString = matches[10] || 'rw';\n  const mode = {\n    read: modeString.includes('r'),\n    write: modeString.includes('w'),\n  };\n\n  if (!token) exitToken();\n\n  return { protocol, token, host, section, mode };\n};\n"
  }
]