This is a node.js script that will crawl all of the servers that are available to your Plex user and create an RSS feed of the 20 most recent additions. The RSS feed will include thumbnails from the movie or TV show, along with the title and description. The links will even open the Plex player app on your device!
To run this, copy the javascript file below, the package.json file, and the .env file. Add your Plex token to the .env, along the output path for your RSS feed. This will ultimately need to be publically accessible for RSS readers to consume it.
KEEP IN MIND that this will access all of the Plex servers available to your account, and it will crawl content of each entire server each time. This is not that different from what happens when you browse your friend's library, but it does make the remote servers do work so be respectful. I would not run this more than 3 or 4 times a day at most.
Also remember that some libraries are larger than others. Large libraries will take longer to crawl.
For the TRMNL owners out there, you can create a private plugin and use the RSS feed url as the data point. Use the template below to display a grid of the 6 most recent additions, along with the thumbnail and title:
{% if status == 'error' %}
ERROR: {{message}}
{% else %}
<div class="layout bg-black">
<div class="grid grid--cols-3 gap--xlarge">
{% for item in rss.channel.item limit:6 %}
<div>
<img src=http://anonymouse.org/cgi-bin/anon-www.cgi/http://benbrown.com/hacks/"{{item.content.url}}" class="image-dither" width="100%">
<p class="description clamp--1 text--white">{{item.title}}</p>
</div>
{% endfor %}
</div>
</div>
<div class="title_bar">
<span class="title">New on Plex</span>
</div>
{% endif %}
const PlexAPI = require('plex-api');
const fetch = require('node-fetch');
const xml2js = require('xml2js');
const fs = require('fs');
const RSS = require('rss');
require('dotenv').config();
// Timeout duration in milliseconds
const TIMEOUT = 10000; // 10 seconds
/**
* Downloads a thumbnail from Plex and converts it to a data URI
* @param {string} ratingKey - The rating key of the media item
* @param {PlexAPI} client - The Plex API client
* @returns {Promise<string>} The data URI of the image
*/
async function getThumbnailDataUri(ratingKey, client) {
try {
// Use the client's request method to get the raw response
const response = await client.query(`/library/metadata/${ratingKey}/thumb?width=396&height=396&minSize=1&upscale=1`, {
});
// Get the content type from the response
const contentType = 'image/jpeg';
// Convert to data URI
return `data:${contentType};base64,${response.toString('base64')}`;
} catch (error) {
console.error('Error fetching thumbnail:', error);
return null;
}
}
/**
* Fetches media items from a Plex server
* @param {PlexAPI} client - The Plex API client
* @param {Object} library - The library object
* @returns {Promise<Array>} Array of media items
*/
async function fetchMediaItems(client, library) {
const mediaItems = [];
try {
const items = await client.query(`/library/sections/${library.key}/all`);
const mediaContainer = items.MediaContainer;
for (const item of mediaContainer.Metadata) {
if (item.type === 'show') {
// For TV shows, we need to get all episodes
try {
// Get all seasons for the show
const seasons = await client.query(`/library/metadata/${item.ratingKey}/children`);
for (const season of seasons.MediaContainer.Metadata) {
// Get all episodes for this season
const episodes = await client.query(`/library/metadata/${season.ratingKey}/children`);
for (const episode of episodes.MediaContainer.Metadata) {
const mediaItem = {
title: episode.title,
type: 'show',
library: library.title,
addedAt: new Date(episode.addedAt * 1000).toISOString(),
year: item.year,
ratingKey: episode.ratingKey,
summary: episode.summary || '',
thumb: episode.thumb || '',
art: item.art || '',
server: library.serverName,
season: parseInt(season.index),
episode: parseInt(episode.index),
episodeTitle: episode.title,
showTitle: item.title,
guid: item.guid,
};
mediaItems.push(mediaItem);
}
}
} catch (error) {
console.error(`Error fetching episodes for show ${item.title}:`, error);
}
} else {
// For movies, process normally
const mediaItem = {
title: item.title,
type: 'movie',
library: library.title,
addedAt: new Date(item.addedAt * 1000).toISOString(),
year: item.year,
ratingKey: item.ratingKey,
summary: item.summary || '',
thumb: item.thumb || '',
art: item.art || '',
server: library.serverName,
guid: item.guid,
};
mediaItems.push(mediaItem);
}
}
} catch (error) {
console.error(`Error fetching media from library ${library.title}:`, error);
}
return mediaItems;
}
/**
* Loads media from all accessible Plex servers
* @returns {Promise<{media: Array, servers: Array}>} Object containing media items and server info
*/
async function loadAllMedia() {
console.log("\nLoading media from all servers...");
let allMedia = [];
let serverList = [];
try {
// Get all servers using Plex.tv API
const response = await fetch('https://plex.tv/api/servers', {
headers: {
'X-Plex-Token': process.env.PLEX_TOKEN
}
});
const serversXml = await response.text();
// Parse XML response
const parser = new xml2js.Parser();
const result = await parser.parseStringPromise(serversXml);
serverList = result.MediaContainer.Server.map(server => ({
name: server.$.name,
address: server.$.address,
port: server.$.port,
scheme: server.$.scheme,
accessToken: server.$.accessToken
}));
console.log(`Found ${serverList.length} servers`);
for (const server of serverList) {
console.log(`\nScanning server: ${server.name} -- ${server.address}:${server.port}`);
try {
const serverClient = new PlexAPI({
hostname: server.address,
port: server.port,
token: server.accessToken,
https: server.scheme === 'https',
timeout: TIMEOUT
});
const libraries = await serverClient.query('/library/sections');
for (const library of libraries.MediaContainer.Directory) {
if (library.type !== 'movie' && library.type !== 'show') continue;
console.log(` Scanning library: ${library.title}`);
library.serverName = server.name; // Add server name to library object
const mediaItems = await fetchMediaItems(serverClient, library);
allMedia = allMedia.concat(mediaItems);
}
} catch (error) {
console.error(`Error connecting to server ${server.name}:`, error);
continue;
}
}
} catch (error) {
console.error('Error loading media:', error);
}
return { media: allMedia, servers: serverList };
}
/**
* Generates an RSS feed from the most recently added media items
* @param {Array} mediaItems - Array of all media items
* @param {Array} servers - Array of server information
* @param {number} limit - Maximum number of items to include in the feed
* @returns {Promise<string>} RSS feed XML
*/
async function generateRSSFeed(mediaItems, servers, limit = 20) {
const feed = new RSS({
title: 'Plex Recent Media',
description: 'Most recently added media across Plex servers',
feed_url: 'http://localhost/plex-new-media.xml',
site_url: 'http://localhost',
language: 'en',
pubDate: new Date(),
custom_namespaces: {
'media': 'http://search.yahoo.com/mrss/',
}
});
// Sort items by addedAt date (newest first) and take the most recent ones
const recentItems = mediaItems
.sort((a, b) => new Date(b.addedAt) - new Date(a.addedAt))
.slice(0, limit);
// Create a map of server clients for thumbnail fetching
const serverClients = new Map();
for (const item of recentItems) {
if (!serverClients.has(item.server)) {
const server = servers.find(s => s.name === item.server);
if (server) {
serverClients.set(item.server, new PlexAPI({
hostname: server.address,
port: server.port,
token: server.accessToken,
https: server.scheme === 'https',
timeout: TIMEOUT
}));
}
}
}
for (const item of recentItems) {
const title = item.type === 'show'
? `${item.showTitle} - S${item.season}E${item.episode} - ${item.episodeTitle}`
: item.title;
// Use the item's guid for the Plex deep link
const plexUrl = item.guid || `plex://${item.type}/${item.ratingKey}`;
// Get thumbnail data URI if we have a client for this server
let thumbDataUri = null;
const client = serverClients.get(item.server);
if (client) {
thumbDataUri = await getThumbnailDataUri(item.ratingKey, client);
}
feed.item({
title: title,
description: item.summary,
url: plexUrl,
guid: item.ratingKey,
date: item.addedAt,
categories: [item.type, item.server, item.library],
custom_elements: [
thumbDataUri ? { 'media:content': { _attr: { url: thumbDataUri, type: 'image/jpeg' } } } : null,
].filter(Boolean)
});
}
// Generate XML with pretty printing
const xml = feed.xml({ indent: true });
return xml;
}
/**
* Main function to generate RSS feed of recent media
*/
async function generateRecentMediaFeed() {
try {
// Load all media
const { media, servers } = await loadAllMedia();
// Generate and save RSS feed
const rssFeed = await generateRSSFeed(media, servers);
fs.writeFileSync(process.env.RSS_FEED_PATH || 'plex-new-media.xml', rssFeed);
console.log('\nRSS feed has been updated with the most recent media items.');
} catch (error) {
console.error('Error:', error);
}
}
// Run the script
generateRecentMediaFeed();
# Plex server configuration
PLEX_TOKEN=[VALUE GOES HERE]
RSS_FEED_PATH=[VALUE GOES HERE]
{
"name": "plex-new-media-tracker",
"version": "1.0.0",
"description": "Track new media additions across Plex servers and generate RSS feed",
"main": "plex-new-media.js",
"scripts": {
"start": "node plex-new-media.js"
},
"dependencies": {
"plex-api": "^5.3.1",
"node-fetch": "^2.6.7",
"xml2js": "^0.6.0",
"rss": "^1.2.2",
"dotenv": "^16.0.3"
}
}