Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
@@ -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
3 changes: 2 additions & 1 deletion .env.sample
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
PLEX_TOKEN=
PLEX_IP=
USERNAME=
PLEX_USER=
LETTERBOXD_USER=
44 changes: 36 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
@@ -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

26 changes: 26 additions & 0 deletions com.slgoetz.plexletterboxdsync.plist
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/System/Cryptexes/App/usr/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
</dict>
<key>Label</key>
<string>com.slgoetz.plexletterboxdsync</string>
<key>Program</key>
<string>start.sh</string>
<key>StandardErrorPath</key>
<string>plex-letterboxd-sync-error.log</string>
<key>StandardOutPath</key>
<string>plex-letterboxd-sync.log</string>
<key>StartCalendarInterval</key>
<dict>
<key>Minute</key>
<integer>0</integer>
</dict>
<key>WorkingDirectory</key>
<string>/path/to/plex-letterboxd-sync</string>
</dict>
</plist>
212 changes: 83 additions & 129 deletions index.js
Original file line number Diff line number Diff line change
@@ -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
28 changes: 0 additions & 28 deletions launched.plexletterboxdsync.plist

This file was deleted.

Loading