From c9b2453b1c8ba1521235d6db10382a80e9014d2c Mon Sep 17 00:00:00 2001 From: Kira Date: Mon, 15 Dec 2025 14:17:07 -0500 Subject: [PATCH] god damn if this works the whole way --- commands/jellyfin.js | 174 +++++++++++++++++++++++++++++++++++++++++++ config.json.default | 9 ++- deploy-commands.js | 44 +++++++++++ index.js | 38 +++++++++- lib/jellyfin.js | 75 +++++++++++++++++++ 5 files changed, 338 insertions(+), 2 deletions(-) create mode 100644 commands/jellyfin.js create mode 100644 deploy-commands.js create mode 100644 lib/jellyfin.js diff --git a/commands/jellyfin.js b/commands/jellyfin.js new file mode 100644 index 0000000..348f728 --- /dev/null +++ b/commands/jellyfin.js @@ -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}`); + } + }, +}; \ No newline at end of file diff --git a/config.json.default b/config.json.default index 5fd00f0..85cef1d 100644 --- a/config.json.default +++ b/config.json.default @@ -4,5 +4,12 @@ "parentsAndOrGuardians": [ "230659159450845195", "297983197990354944" - ] + ], + "jellyfin": { + "url": "https://media.example.com", + "key": "jellyfin user key (not generated)", + "users": [ + "000000000000000000" + ] + } } \ No newline at end of file diff --git a/deploy-commands.js b/deploy-commands.js new file mode 100644 index 0000000..eacab57 --- /dev/null +++ b/deploy-commands.js @@ -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); + } +})(); \ No newline at end of file diff --git a/index.js b/index.js index bef52bf..9616947 100644 --- a/index.js +++ b/index.js @@ -1,6 +1,6 @@ const config = require('./config.json'); // 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({ intents: [ 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 }); + } + } +}); diff --git a/lib/jellyfin.js b/lib/jellyfin.js new file mode 100644 index 0000000..204b76b --- /dev/null +++ b/lib/jellyfin.js @@ -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 };