god damn if this works the whole way
This commit is contained in:
174
commands/jellyfin.js
Normal file
174
commands/jellyfin.js
Normal 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}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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
44
deploy-commands.js
Normal 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);
|
||||||
|
}
|
||||||
|
})();
|
||||||
38
index.js
38
index.js
@@ -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
75
lib/jellyfin.js
Normal 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 };
|
||||||
Reference in New Issue
Block a user