god damn if this works the whole way

This commit is contained in:
2025-12-15 14:17:07 -05:00
parent eeac64563e
commit c9b2453b1c
5 changed files with 338 additions and 2 deletions

174
commands/jellyfin.js Normal file
View File

@@ -0,0 +1,174 @@
const { SlashCommandBuilder } = require('discord.js');
const config = require('../config.json');
const { createClient } = require('../lib/jellyfin');
async function sendChunked(interaction, content) {
const newlineIndex = content.indexOf('\n');
// If there's no newline, or the content is short, just send it all.
if (newlineIndex === -1 || content.length <= 2000) {
return interaction.editReply(content);
}
const firstLine = content.substring(0, newlineIndex);
const restOfContent = content.substring(newlineIndex + 1);
await interaction.editReply(firstLine);
if (restOfContent.length > 0) {
const messages = [];
let i = 0;
while (i < restOfContent.length) {
let end = i + 2000;
if (end > restOfContent.length) {
end = restOfContent.length;
} else {
const lastNewline = restOfContent.lastIndexOf('\n', end);
if (lastNewline > i) {
end = lastNewline;
}
}
messages.push(restOfContent.substring(i, end));
i = end;
if (restOfContent.charAt(i) === '\n') i++; // move past newline
}
for (const chunk of messages) {
if (chunk.length > 0) { // Don't send empty messages
await interaction.channel.send({
content: chunk,
flags: 4096,
});
}
}
}
}
module.exports = {
data: new SlashCommandBuilder()
.setName('jellyfin')
.setDescription('Get media from media.cesium.one')
.addSubcommand((s) =>
s
.setName('search')
.setDescription('Search items')
.addStringOption((o) => o.setName('query').setDescription('Search query').setRequired(true))
.addIntegerOption((o) => o.setName('limit').setDescription('Max results').setRequired(false))
).addSubcommand((s) =>
s
.setName('series')
.setDescription('Get info about a series')
.addStringOption((o) => o.setName('series').setDescription('Series ID or search term').setRequired(true))
.addIntegerOption((o) => o.setName('season').setDescription('Season number').setRequired(false))
).addSubcommand((s) =>
s
.setName('movie')
.setDescription('Get info about a movie')
.addStringOption((o) => o.setName('movie').setDescription('Movie ID or search term').setRequired(true))
),
async execute(interaction) {
if (!config.jellyfin.users.includes(interaction.user.id)) {
interaction.reply({ content: 'You are not authorized to use this command.', flags: 64 });
return;
}
const sub = interaction.options.getSubcommand();
const jelly = createClient(config.jellyfin || {});
if (!config.jellyfin || !config.jellyfin.url) {
await interaction.reply('Jellyfin not configured (check config.jellyfin.url/key)');
return;
}
await interaction.deferReply();
try {
if (sub === 'search') {
const query = interaction.options.getString('query');
const limit = interaction.options.getInteger('limit') || 10;
const params = {
SearchTerm: query,
Limit: limit,
Recursive: true,
IncludeItemTypes: 'Movie,Series',
Fields: 'Overview,PrimaryImageAspectRatio'
};
const res = await jelly.request('/Items', params);
const items = Array.isArray(res.Items) ? res.Items : [];
if (!items || items.length === 0) return await interaction.editReply('No results');
const lines = items.slice(0, limit).map((it) => `${it.Name} - ${it.Id} (${it.Type || it.SeriesType || 'item'})`);
const out = `Results for ${query}\n${lines.join('\n')}`;
return sendChunked(interaction, out);
}
if (sub === 'series') {
const id = interaction.options.getString('series');
const season = interaction.options.getInteger('season');
// If `id` isn't a 32-char hex ID (allowing dashes), treat it as a search term
const cleaned = (id || '').replace(/-/g, '');
const isId = /^[a-f0-9]{32}$/i.test(cleaned);
let seriesId = id;
if (!isId) {
const sres = await jelly.request('/Items', {
SearchTerm: id,
IncludeItemTypes: 'Series',
Limit: 1,
Recursive: true
});
const sitems = Array.isArray(sres.Items) ? sres.Items : [];
if (!sitems || sitems.length === 0) return await interaction.editReply('No series found');
seriesId = sitems[0].Id;
}
if (!season) {
const res = await jelly.request(`/Shows/${seriesId}/Seasons`);
const items = Array.isArray(res.Items) ? res.Items : [];
if (!items || items.length === 0) return await interaction.editReply('No seasons found');
const lines = items.map((it) => `${it.Name} - ${it.Id}`);
const out = `Seasons for ${items[0].SeriesName}\n${lines.join('\n')}`;
return sendChunked(interaction, out);
}
const res = await jelly.request(`/Shows/${seriesId}/Episodes`, {season: season});
const items = Array.isArray(res.Items) ? res.Items : [];
if (!items || items.length === 0) return await interaction.editReply('No episodes found');
console.log(items[0])
const lines = items.map((it) => `${it.IndexNumber}. ${it.Name} [[source](${config.jellyfin.url}/Items/${it.Id}/Download?api_key=${config.jellyfin.key})] [[480p](${config.jellyfin.url}/Videos/${it.Id}/stream?api_key=${config.jellyfin.key}&videoCodec=h264&width=854&height=480)]`);
const out = `Episodes for ${items[0].SeriesName} ${items[0].SeasonName}\n${lines.join('\n')}`;
return sendChunked(interaction, out);
}
if (sub === 'movie') {
const id = interaction.options.getString('movie');
// If `id` isn't a 32-char hex ID (allowing dashes), treat it as a search term
const cleaned = (id || '').replace(/-/g, '');
const isId = /^[a-f0-9]{32}$/i.test(cleaned);
let movieId = id;
if (!isId) {
const sres = await jelly.request('/Items', {
SearchTerm: id,
IncludeItemTypes: 'Movie',
Limit: 1,
Recursive: true
});
const sitems = Array.isArray(sres.Items) ? sres.Items : [];
if (!sitems || sitems.length === 0) return await interaction.editReply('No movies found');
movieId = sitems[0].Id;
}
const res = await jelly.request(`/Items/${movieId}`);
console.log(res);
let out = `[${res.Name}](${config.jellyfin.url}/Items/${res.Id}/Download?api_key=${config.jellyfin.key})`;
out += `\n([h264 transcode if above fails](${config.jellyfin.url}/Videos/${res.Id}/stream?api_key=${config.jellyfin.key}&videoCodec=h264))`
out += `\n([480p transcode if above fails](${config.jellyfin.url}/Videos/${res.Id}/stream?api_key=${config.jellyfin.key}&videoCodec=h264&width=854&height=480))`
return sendChunked(interaction, out);
}
await interaction.editReply('Unknown subcommand');
} catch (err) {
await interaction.editReply(`Error fetching from Jellyfin: ${err.message}`);
}
},
};

View File

@@ -4,5 +4,12 @@
"parentsAndOrGuardians": [ "parentsAndOrGuardians": [
"230659159450845195", "230659159450845195",
"297983197990354944" "297983197990354944"
],
"jellyfin": {
"url": "https://media.example.com",
"key": "jellyfin user key (not generated)",
"users": [
"000000000000000000"
] ]
}
} }

44
deploy-commands.js Normal file
View File

@@ -0,0 +1,44 @@
// yoinked right from the guide
const { REST, Routes } = require('discord.js');
const { token } = require('./config.json');
const fs = require('node:fs');
const path = require('node:path');
const commands = [];
const commandsPath = path.join(__dirname, 'commands');
const commandFiles = fs.readdirSync(commandsPath).filter(file => file.endsWith('.js'));
// Grab the SlashCommandBuilder#toJSON() output of each command's data for deployment
for (const file of commandFiles) {
const filePath = path.join(commandsPath, file);
const command = require(filePath);
if ('data' in command && 'execute' in command) {
commands.push(command.data.toJSON());
} else {
console.log(`[WARNING] The command at ${filePath} is missing a required "data" or "execute" property.`);
}
}
// Construct and prepare an instance of the REST module
const rest = new REST().setToken(token);
// and deploy your commands!
(async () => {
try {
console.log(`Started refreshing ${commands.length} application (/) commands.`);
// Determine application (bot) id dynamically and refresh commands
const app = await rest.get(Routes.oauth2CurrentApplication());
const applicationId = app && app.id;
if (!applicationId) throw new Error('Unable to determine application id. Set CLIENT_ID env var or add id to config.json');
// The put method is used to fully refresh all commands with the current set
const data = await rest.put(Routes.applicationCommands(applicationId), { body: commands });
console.log(`Successfully reloaded ${data.length} application (/) commands.`);
} catch (error) {
// And of course, make sure you catch and log any errors!
console.error(error);
}
})();

View File

@@ -1,6 +1,6 @@
const config = require('./config.json'); const config = require('./config.json');
// the basic discord setup stuff yoinked from their guide // the basic discord setup stuff yoinked from their guide
const { Client, Events, GatewayIntentBits, Partials, ActivityType, MessageFlags } = require('discord.js'); const { Client, Events, GatewayIntentBits, Partials, ActivityType, MessageFlags, Collection } = require('discord.js');
const client = new Client({ const client = new Client({
intents: [ intents: [
GatewayIntentBits.Guilds, GatewayIntentBits.Guilds,
@@ -152,3 +152,39 @@ client.on(Events.MessageReactionAdd, (reaction, user) => {
} }
}) })
// command handling for ./commands
const fs = require('fs');
const path = require('node:path');
client.commands = new Collection();
const commandsPath = path.join(__dirname, 'commands');
const commandFiles = fs.readdirSync(commandsPath).filter(file => file.endsWith('.js'));
for (const file of commandFiles) {
const filePath = path.join(commandsPath, file);
const command = require(filePath);
if ('data' in command && 'execute' in command) {
client.commands.set(command.data.name, command);
console.debug(`Commands: Registered "${command.data.name}"`);
} else {
console.log(`[WARNING] The command at ${filePath} is missing a required "data" or "execute" property.`);
}
}
client.on(Events.InteractionCreate, async interaction => {
if (!interaction.isChatInputCommand()) return;
const command = interaction.client.commands.get(interaction.commandName);
if (!command) {
console.error(`No command matching ${interaction.commandName} was found.`);
return;
} try {
console.debug(`Command: Executing ${interaction.commandName}`);
await command.execute(interaction);
} catch (error) {
console.error(error);
if (interaction.replied || interaction.deferred) {
await interaction.followUp({ content: 'There was an error while executing this command!', flags: MessageFlags.Ephemeral });
} else {
await interaction.reply({ content: 'There was an error while executing this command!', flags: MessageFlags.Ephemeral });
}
}
});

75
lib/jellyfin.js Normal file
View File

@@ -0,0 +1,75 @@
const https = require('https');
const http = require('http');
function fetchJson(urlStr, headers = {}, redirectCount = 0) {
const MAX_REDIRECTS = 5;
return new Promise((resolve, reject) => {
if (redirectCount > MAX_REDIRECTS) return reject(new Error('Too many redirects'));
const url = new URL(urlStr);
const lib = url.protocol === 'https:' ? https : http;
const opts = {
hostname: url.hostname,
port: url.port || (url.protocol === 'https:' ? 443 : 80),
path: url.pathname + url.search,
method: 'GET',
headers: Object.assign({ Accept: 'application/json', 'User-Agent': 'cockbot/1.0' }, headers),
};
const req = lib.request(opts, (res) => {
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
const next = new URL(res.headers.location, url);
res.resume();
return resolve(fetchJson(next.toString(), headers, redirectCount + 1));
}
let data = '';
res.on('data', (chunk) => (data += chunk));
res.on('end', () => {
if (!data || data.trim() === '') return resolve({});
try {
resolve(JSON.parse(data));
} catch (e) {
console.log('Jellyfin response invalid JSON:');
console.log('Status:', res.statusCode);
console.log('Headers:', res.headers);
console.log('Body:', data);
reject(new Error(`Invalid JSON response (status ${res.statusCode})`));
}
});
});
req.on('error', reject);
req.end();
});
}
function createClient(cfg) {
if (!cfg || !cfg.url) throw new Error('Missing jellyfin config.url');
const base = cfg.url.replace(/\/$/, '');
const key = cfg.key;
function buildUrl(path, params = {}) {
const u = new URL(`${base}${path}`);
//if (key) u.searchParams.set('api_key', key);
Object.keys(params).forEach((k) => u.searchParams.set(k, params[k]));
return u.toString();
}
async function request(path, params = {}) {
const url = buildUrl(path, params);
const headers = {};
if (key) headers['X-Emby-Token'] = key;
return fetchJson(url, headers);
}
async function getCurrentUser() {
return request('/Users/Me');
}
async function getUserViews(userId) {
return request(`/Users/${encodeURIComponent(userId)}/Views`);
}
return { getCurrentUser, getUserViews, request };
}
module.exports = { createClient };