diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..8e3e66e
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,17 @@
+# EditorConfig is awesome: https://EditorConfig.org
+
+# top-most EditorConfig file
+root = true
+
+# Unix-style newlines with a newline ending every file
+[*]
+end_of_line = lf
+insert_final_newline = true
+charset = utf-8
+indent_style = space
+indent_size = 2
+
+# 3 space indentation
+[*.js]
+indent_style = space
+indent_size = 3
diff --git a/.env.sample b/.env.sample
index f64d1a1..fbfcbdb 100644
--- a/.env.sample
+++ b/.env.sample
@@ -1,3 +1,4 @@
PLEX_TOKEN=
PLEX_IP=
-USERNAME=
\ No newline at end of file
+PLEX_USER=
+LETTERBOXD_USER=
diff --git a/README.md b/README.md
index 34d5f7a..059196c 100644
--- a/README.md
+++ b/README.md
@@ -1,21 +1,49 @@
# plex-letterboxd
+
Syncs your letterboxd watchlist with movies on you plex
## Installation
-1. `git clone https://github.com/slgoetz/plex-letterboxd-sync.git`
-2. `cd plex-letterboxd-sync`
-3. `yarn install`
+```bash
+git clone https://github.com/slgoetz/plex-letterboxd-sync.git
+cd plex-letterboxd-sync
+yarn install
+```
-## Usage
-If you want this to run in the background and sync once a day you can add it as a launch script on your mac. To do so you will need to fill in the blanks (`{path-for-your-file}` and `{username}`) in `launched.plexletterboxdsync.plist`. Then check to see if you have a directory at `~/Library/LaunchAgents`. If not, create one and copy the launch file there. This should run at midnight every day and output a log file for synced movies as well as errors.
+## Setup
-*Make sure the `PATH` set the start.sh file is replaced with your `PATH`*
+Copy `.env.sample` to `.env` and fill in the environment variables.
+
+* `$PLEX_TOKEN` - See [this Plex support article](https://support.plex.tv/articles/204059436-finding-an-authentication-token-x-plex-token/)
+* `$PLEX_IP` - Can be an IP address (e.g., `192.168.1.2`) or a hostname (e.g., `plex.example.com`)
+* `$PLEX_USER` - Your username with Plex (e.g., how you log in to plex.tv)
+
+## Usage
### Run Once
-If you would like to run this once, you can run with `node ./index.js`
+
+If you would like to run this once:
+
+```bash
+cd plex-letterboxd-sync
+./start.sh
+```
+
+### Run On A Schedule
+
+If you want this to run in the background on a regular interval (e.g., once a day), you can add it as a launchd script on your Mac.
+
+1. Edit `com.slgoetz.plexletterboxdsync.plist` and replace `/path/to/plex-letterboxd-sync` with the absolute path to where you cloned this repository. E.g., `/Users/username/plex-letterboxd-sync`
+2. *Make sure the `PATH` set in `~/Library/LaunchAgents/com.slgoetz.plexletterboxdsync.plist` is valid for your user,* it should ensure that `node` is available to launchd to run the script.
+ * E.g., if `node` is at `/usr/local/bin` or `/opt/homebrew/bin` (the default locations for Homebrew-installed Node.js, depending on if you have an Intel or Apple Silicon Mac), you should be good with the default value.
+3. Check if you have a directory at `~/Library/LaunchAgents`. If not, create it.
+4. Copy the launchd plist file, `com.slgoetz.plexletterboxdsync.plist`, to `~/Library/LaunchAgents`
+
+This will enable the script to run at midnight every day and output a log file for synced movies as well as errors to the `WorkingDirectory` you set in step 1 above.
+
+If you’d like to run it on a different schedule, the `StartCalendarInterval` stanza can be modified. You can do this by hand, or using a utility application like [LaunchControl](https://www.soma-zone.com/LaunchControl/).
## Roadmap
+
- [ ] Sync ratings
- [ ] Sync Watched
-
diff --git a/com.slgoetz.plexletterboxdsync.plist b/com.slgoetz.plexletterboxdsync.plist
new file mode 100644
index 0000000..2af6661
--- /dev/null
+++ b/com.slgoetz.plexletterboxdsync.plist
@@ -0,0 +1,26 @@
+
+
+
+
+ EnvironmentVariables
+
+ PATH
+ /opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/System/Cryptexes/App/usr/bin:/usr/bin:/bin:/usr/sbin:/sbin
+
+ Label
+ com.slgoetz.plexletterboxdsync
+ Program
+ start.sh
+ StandardErrorPath
+ plex-letterboxd-sync-error.log
+ StandardOutPath
+ plex-letterboxd-sync.log
+ StartCalendarInterval
+
+ Minute
+ 0
+
+ WorkingDirectory
+ /path/to/plex-letterboxd-sync
+
+
diff --git a/index.js b/index.js
index 372146d..f7be6dc 100644
--- a/index.js
+++ b/index.js
@@ -1,167 +1,121 @@
-// TODO: ratings
-import xml2js from "xml2js";
import PlexAPI from "plex-api";
import fetch from "node-fetch";
import * as cheerio from "cheerio";
import dotenv from "dotenv";
-import _find from "lodash.find";
import logger from "log-to-file";
-// import schedule from 'node-schedule'
dotenv.config();
-const optionDefinitions = [
- { name: "ip", type: String },
- { name: "listPath", type: String, multiple: false, defaultOption: true }
-];
-
const client = new PlexAPI({
hostname: process.env.PLEX_IP,
- token: process.env.PLEX_TOKEN
+ token: process.env.PLEX_TOKEN,
});
-const call = {
- getAllLibraries: "/library/sections",
- getWatchlist: "/library/watchlist",
- getLllMovies: "/library/sections/{id}/all"
- // getWatchlist: "/library/sections/watchlist/all",
-};
-
-const getWatchList = async () => {
+const getAllLibraries = async () => {
return client
- .find(call.getWatchlist)
- .then((res) => {
- console.log(res);
- })
+ .find("/library/sections", { type: "movie" })
+ .then((directories) => directories.map((lib) => lib.key))
.catch((err) => {
- console.error("Could not connect to server", err);
+ throw new Error(`Could not fetch Plex libraries: ${err.message}`);
});
};
-const getAllLibraries = async () => {
- return client
- .find(call.getAllLibraries, { type: "movie" })
- .then((directories) => {
- const ids = directories.map((library) => {
- return library.key;
+const getAllMovies = async (libraries) => {
+ const getMoviesFromLib = (libID) =>
+ client
+ .find(`/library/sections/${libID}/all`)
+ .catch((err) => {
+ throw new Error(`Could not fetch movies from library ${libID}: ${err.message}`);
});
- return ids;
- })
- .catch((err) => {
- console.error("Could not connect to server", err);
- });
+
+ return Promise.all(libraries.map(getMoviesFromLib)).then((res) => res.flat());
};
-const letterboxd = async () => {
- return await fetch(`https://letterboxd.com/${process.env.USERNAME}/watchlist/`)
- .then((response) => response.text())
- .then((html) => {
- let $ = cheerio.load(html);
- var films = [];
- $(".poster-container").each((i, el) => {
- const filmData = $(el).children().data();
- return films.push(filmData);
- });
- return films;
- });
+const fetchWatchlistPage = async (user, page) => {
+ const url =
+ page === 1
+ ? `https://letterboxd.com/${user}/watchlist/`
+ : `https://letterboxd.com/${user}/watchlist/page/${page}/`;
+
+ const html = await fetch(url).then((r) => r.text());
+ const $ = cheerio.load(html);
+
+ const films = [];
+ $(".poster-container").each((_, el) => {
+ films.push($(el).children().first().data());
+ });
+
+ const lastPageLink = $(".paginate-pages a").last().attr("href") ?? "";
+ const lastPageMatch = lastPageLink.match(/page\/(\d+)/);
+ const lastPage = lastPageMatch ? parseInt(lastPageMatch[1], 10) : 1;
+
+ return { films, lastPage };
+};
+
+const getLetterboxdWatchlist = async () => {
+ const user = process.env.LETTERBOXD_USER;
+ const { films, lastPage } = await fetchWatchlistPage(user, 1);
+
+ if (lastPage === 1) return films;
+
+ const remainingPages = Array.from({ length: lastPage - 1 }, (_, i) => i + 2);
+ const rest = await Promise.all(
+ remainingPages.map((page) => fetchWatchlistPage(user, page).then((r) => r.films))
+ );
+
+ return [films, ...rest].flat();
};
+
const getLbMovieInfo = async (movies) => {
const getFilm = async ({ filmId, filmSlug }) => {
- return await fetch(`https://letterboxd.com${filmSlug}`)
- .then((response) => response.text())
- .then((html) => {
- let $ = cheerio.load(html);
- const title = $("h1.headline-1").text().trim();
- const year = $(".film-header-lockup .number a").text();
-
- return Object.assign({}, { filmId, filmSlug }, { title, year });
- });
+ const html = await fetch(`https://letterboxd.com/film/${filmSlug}/`).then((r) => r.text());
+ const $ = cheerio.load(html);
+ const title = $("h1.headline-1").text().trim();
+ const year = parseInt($(".film-header-lockup .number a").text().trim(), 10);
+ return { filmId, filmSlug, title, year };
};
- const data = movies.map(async (film) => await getFilm(film));
- return Promise.all(data);
-};
-const getWatchListMovies = async () => {
- await fetch(
- `https://metadata.provider.plex.tv/library/sections/watchlist/all?X-Plex-Token=${process.env.PLEX_TOKEN}`
- )
- .then((response) => response.text())
- .then((xmlString) => xmlToJSON(xmlString))
- .then((data) => console.log(data.MediaContainer.Video));
+ return Promise.all(movies.map(getFilm));
};
-const syncWatchListMovies = async (plexMovies, LBMovies) => {
- // get avilable Movies
- const availMovies = plexMovies.filter((movie) => {
- const match = _find(LBMovies, { title: movie.title });
- if (match) {
- return match;
- }
- });
+const syncWatchListMovies = async (plexMovies, lbMovies) => {
+ const availMovies = plexMovies.filter((movie) =>
+ lbMovies.some(
+ (lb) => lb.title === movie.title && lb.year === parseInt(movie.year, 10)
+ )
+ );
- // Sync movies to Plex
const syncMovie = async (movie) => {
- const uuid = movie.guid.split("/");
- const ratingKey = uuid[uuid.length - 1];
- return await fetch(
+ const ratingKey = movie.guid.split("/").at(-1);
+ const res = await fetch(
`https://metadata.provider.plex.tv/actions/addToWatchlist?X-Plex-Token=${process.env.PLEX_TOKEN}&ratingKey=${ratingKey}`,
- {
- method: "PUT"
- }
- ).then((res) => {
- if (res.status === 200) {
- const text = `SUCCESS - ${movie.title}`;
- logger(text);
- return text;
- } else {
- const text = `FAIL - ${movie.title}`;
- logger(text);
- return text;
- }
- });
+ { method: "PUT" }
+ );
+ const text =
+ res.status === 200
+ ? `SUCCESS - ${movie.title}`
+ : `FAIL - ${movie.title} (HTTP ${res.status})`;
+ logger(text);
+ return text;
};
- const data = availMovies.map(async (movie) => await syncMovie(movie));
- return Promise.all(data);
-};
-
-const getAllMovies = async (libraries) => {
- const getMoviesFromLib = async (libID) => {
- var url = call.getLllMovies.replace("{id}", libID);
- return client
- .find(url)
- .then((movies) => movies)
- .catch((err) => console.error("Could not connect to server", err));
- };
-
- var data = libraries.map(async (library) => {
- const libMovies = await getMoviesFromLib(library);
- return libMovies;
- });
- return Promise.all(data).then((res) => res.flat());
-};
-
-// TODO:Move to Utils
-const xmlToJSON = (str, options) => {
- return new Promise((resolve, reject) => {
- xml2js.parseString(str, options, (err, jsonObj) => {
- if (err) {
- return reject(err);
- }
- resolve(jsonObj);
- });
- });
+ return Promise.all(availMovies.map(syncMovie));
};
async function run() {
- const libraries = await getAllLibraries();
- const plexMovies = await getAllMovies(libraries);
-
- // Get Data from LetterBoxd
- const LBWatchList = await letterboxd();
- const LBMovies = await getLbMovieInfo(LBWatchList);
- const watchlist = await syncWatchListMovies(plexMovies, LBMovies);
+ try {
+ const libraries = await getAllLibraries();
+ const plexMovies = await getAllMovies(libraries);
+
+ const lbWatchList = await getLetterboxdWatchlist();
+ const lbMovies = await getLbMovieInfo(lbWatchList);
+
+ const results = await syncWatchListMovies(plexMovies, lbMovies);
+ console.log(`Sync complete: ${results.length} movie(s) processed.`);
+ } catch (err) {
+ console.error("Sync failed:", err.message);
+ process.exit(1);
+ }
}
run();
-// schedule.scheduleJob("0 0 * * *", run); // run everyday at midnight
diff --git a/launched.plexletterboxdsync.plist b/launched.plexletterboxdsync.plist
deleted file mode 100644
index 12963dc..0000000
--- a/launched.plexletterboxdsync.plist
+++ /dev/null
@@ -1,28 +0,0 @@
-
-
-
-
- Label
- launched.plexletterboxdsync
- ProgramArguments
-
- sh
- start.sh
-
- StandardErrorPath
- /{path-for-your-file}/plex-letterboxd-sync/logger-error.text
- StandardOutPath
- /{path-for-your-file}/plex-letterboxd-sync/logger.text
- StartCalendarInterval
-
- Minute
- 0
-
- StartInterval
- 0
- UserName
- {username}
- WorkingDirectory
- /{path-for-your-file}/plex-letterboxd-sync
-
-
diff --git a/package.json b/package.json
index e261f4c..f70e4de 100644
--- a/package.json
+++ b/package.json
@@ -4,14 +4,15 @@
"description": "Sync your Letterboxd watchlist with your Plex media server.",
"main": "index.js",
"scripts": {
- "test": "echo \"Error: no test specified\" && exit 1"
+ "test": "echo \"Error: no test specified\" && exit 1",
+ "sync": "node index.js"
},
"author": "",
"type": "module",
"license": "ISC",
"dependencies": {
"cheerio": "^1.0.0-rc.12",
- "dotenv": "^16.0.3",
+ "dotenv": "^17.4.2",
"forever-monitor": "^3.0.3",
"lodash.find": "^4.6.0",
"log-to-file": "^3.0.3",
@@ -19,6 +20,6 @@
"node-schedule": "^2.1.1",
"plex-api": "^5.3.2",
"url-join": "^5.0.0",
- "xml2js": "^0.4.23"
+ "xml2js": "^0.6.2"
}
}
diff --git a/plex-letterboxd-sync b/plex-letterboxd-sync
new file mode 100755
index 0000000..52b31e6
--- /dev/null
+++ b/plex-letterboxd-sync
@@ -0,0 +1,4 @@
+#!/usr/bin/env bash
+
+yarn install
+yarn sync
diff --git a/start.sh b/start.sh
deleted file mode 100644
index 2567310..0000000
--- a/start.sh
+++ /dev/null
@@ -1,4 +0,0 @@
-export PATH=/opt/homebrew/opt/node@14/bin:/opt/homebrew/opt/node@14/bin:/opt/homebrew/opt/node@16/bin:/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/System/Cryptexes/App/usr/bin:/usr/bin:/bin:/usr/sbin:/sbin:/Library/Apple/usr/bin
-
-yarn install
-node ./index.js
diff --git a/types/log-to-file/index.d.ts b/types/log-to-file/index.d.ts
new file mode 100644
index 0000000..a348cac
--- /dev/null
+++ b/types/log-to-file/index.d.ts
@@ -0,0 +1 @@
+declare module 'log-to-file'
diff --git a/yarn.lock b/yarn.lock
index bac314e..a532ef0 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -376,10 +376,10 @@ domutils@^3.0.1:
domelementtype "^2.3.0"
domhandler "^5.0.1"
-dotenv@^16.0.3:
- version "16.0.3"
- resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-16.0.3.tgz#115aec42bac5053db3c456db30cc243a5a836a07"
- integrity sha512-7GO6HghkA5fYG9TYnNxi14/7K9f5occMlp3zXAuSxn7CKCxt9xbNWG7yF8hTCSUchlfWSe3uLmlPfigevRItzQ==
+dotenv@^17.4.2:
+ version "17.4.2"
+ resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-17.4.2.tgz#c07e54a746e11eba021dd9e1047ced5afdc1c034"
+ integrity sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==
duplexer@~0.1.1:
version "0.1.2"
@@ -967,9 +967,9 @@ node-domexception@^1.0.0:
integrity sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==
node-fetch@^3.3.0:
- version "3.3.0"
- resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-3.3.0.tgz#37e71db4ecc257057af828d523a7243d651d91e4"
- integrity sha512-BKwRP/O0UvoMKp7GNdwPlObhYGB5DQqwhEDQlNKuoqwVYSxkSZCSbHjnFFmUEtwSKRPU4kNK8PbDYYitwaE3QA==
+ version "3.3.2"
+ resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-3.3.2.tgz#d1e889bacdf733b4ff3b2b243eb7a12866a0b78b"
+ integrity sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==
dependencies:
data-uri-to-buffer "^4.0.0"
fetch-blob "^3.1.4"
@@ -1252,9 +1252,9 @@ safer-buffer@^2.0.2, safer-buffer@^2.1.0, safer-buffer@~2.1.0:
integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==
sax@>=0.6.0:
- version "1.2.4"
- resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9"
- integrity sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==
+ version "1.5.0"
+ resolved "https://registry.yarnpkg.com/sax/-/sax-1.5.0.tgz#b5549b671069b7aa392df55ec7574cf411179eb8"
+ integrity sha512-21IYA3Q5cQf089Z6tgaUTr7lDAyzoTPx5HRtbhsME8Udispad8dC/+sziTNugOEx54ilvatQ9YCzl4KQLPcRHA==
set-value@^2.0.0, set-value@^2.0.1:
version "2.0.1"
@@ -1493,9 +1493,9 @@ verror@1.10.0:
extsprintf "^1.2.0"
web-streams-polyfill@^3.0.3:
- version "3.2.1"
- resolved "https://registry.yarnpkg.com/web-streams-polyfill/-/web-streams-polyfill-3.2.1.tgz#71c2718c52b45fd49dbeee88634b3a60ceab42a6"
- integrity sha512-e0MO3wdXWKrLbL0DgGnUV7WHVuw9OUvL4hjgnPkIeEvESk74gAITi5G606JtZPp39cd8HA9VQzCIvA49LpPN5Q==
+ version "3.3.3"
+ resolved "https://registry.yarnpkg.com/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz#2073b91a2fdb1fbfbd401e7de0ac9f8214cecb4b"
+ integrity sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==
xml2js@0.4.16:
version "0.4.16"
@@ -1513,10 +1513,10 @@ xml2js@0.4.19:
sax ">=0.6.0"
xmlbuilder "~9.0.1"
-xml2js@^0.4.23:
- version "0.4.23"
- resolved "https://registry.yarnpkg.com/xml2js/-/xml2js-0.4.23.tgz#a0c69516752421eb2ac758ee4d4ccf58843eac66"
- integrity sha512-ySPiMjM0+pLDftHgXY4By0uswI3SPKLDw/i3UXbnO8M/p28zqexCUoPmQFrYD+/1BzhGJSs2i1ERWKJAtiLrug==
+xml2js@^0.6.2:
+ version "0.6.2"
+ resolved "https://registry.yarnpkg.com/xml2js/-/xml2js-0.6.2.tgz#dd0b630083aa09c161e25a4d0901e2b2a929b499"
+ integrity sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==
dependencies:
sax ">=0.6.0"
xmlbuilder "~11.0.0"