On this page
A film club server, a group of friends, a city community: a /showtimes command that answers with what's on today saves a lot of tab switching. This tutorial builds that Discord bot in Node.js with discord.js, in three small files.
/showtimes city:Lyon version:VOSTThe bot answers with one card listing the films showing today, the most programmed first, each with its cinemas and times.
What you need
- Node.js 20 or later.
- An API Cinéma key: create a free account, confirm your e-mail address, then create a key from the dashboard. The free plan gives 150 requests a week for trials and non-commercial projects, one per command.
- A Discord application: in the Discord developer portal, create an application, then under Bot copy its token. Under OAuth2 › URL Generator, tick
botandapplications.commands, then open the generated URL to add the bot to your server.
Set up the project
mkdir showtimes-bot && cd showtimes-bot
npm init -y
npm pkg set type=module
npm install discord.jsThe three secrets go in environment variables, never in the code:
export DISCORD_TOKEN="…" # Bot › Token
export DISCORD_APP_ID="…" # General Information › Application ID
export API_CINEMA_KEY="ac_live_…"Read the showtimes: showtimes.js
Two functions: one calls the API, the other turns the showtimes into embed fields, one per film.
const API = 'https://api.api-cinema.com/v1'
/** Today's next showtimes in a city (from now, in each cinema's local time). */
export async function fetchShowtimes(city, version) {
const params = new URLSearchParams({ city, limit: '500' })
if (version)
params.set('version', version)
const res = await fetch(`${API}/showtimes?${params}`, {
headers: { Authorization: `Bearer ${process.env.API_CINEMA_KEY}` },
})
if (!res.ok)
throw new Error(`API Cinéma answered ${res.status}`)
const { data } = await res.json()
// keep the first day only: the API gives upcoming showtimes, today's first
const day = data[0]?.startsAt.slice(0, 10)
return data.filter(s => s.startsAt.startsWith(day))
}
/** One embed field per film (the most programmed first): each cinema on a line with its times. */
export function toFields(showtimes, maxFilms = 10) {
const films = new Map()
for (const s of showtimes) {
const film = films.get(s.movie.id) ?? { title: s.movie.title, cinemas: new Map(), count: 0 }
const times = film.cinemas.get(s.cinema.name) ?? []
times.push(s.startsAt.slice(11, 16) + (s.version === 'VF' || s.version === 'VO' ? '' : ` ${s.version}`))
film.cinemas.set(s.cinema.name, times)
film.count++
films.set(s.movie.id, film)
}
return [...films.values()]
.sort((a, b) => b.count - a.count)
.slice(0, maxFilms)
.map((film) => {
const lines = [...film.cinemas].map(([cinema, times]) => `**${cinema}** · ${times.join(', ')}`)
let value = lines.join('\n')
// a field value holds 1,024 characters at most
if (value.length > 1024)
value = `${value.slice(0, 1020).replace(/\n[^\n]*$/, '')}\n…`
return { name: film.title.slice(0, 256), value }
})
}A few details:
- Without
dateorfrom, the API returns showtimes from now, in each cinema's local time, sorted by time. Keeping the rows of the first date gives "the rest of today". startsAtis the cinema's wall-clock time (2026-10-24T20:30:00), soslice(11, 16)gives20:30directly, whatever the bot's server time zone.- Discord embeds allow 25 fields and 1,024 characters per field: ten films and a cut at the last full line keep within bounds.
Declare the command: commands.js
import { SlashCommandBuilder } from 'discord.js'
export const showtimesCommand = new SlashCommandBuilder()
.setName('showtimes')
.setDescription('Today\'s showtimes in a city')
.addStringOption(option => option
.setName('city')
.setDescription('City, e.g. Lyon or Chicago')
.setRequired(true))
.addStringOption(option => option
.setName('version')
.setDescription('Only one version')
.addChoices(
{ name: 'Original with subtitles (VOST)', value: 'VOST' },
{ name: 'Original version (VO)', value: 'VO' },
{ name: 'Dubbed in French (VF)', value: 'VF' },
))Register it once with Discord, in register.js:
import { REST, Routes } from 'discord.js'
import { showtimesCommand } from './commands.js'
const rest = new REST().setToken(process.env.DISCORD_TOKEN)
await rest.put(Routes.applicationCommands(process.env.DISCORD_APP_ID), { body: [showtimesCommand.toJSON()] })
console.log('/showtimes registered')node register.jsThe bot: bot.js
import { Client, EmbedBuilder, Events, GatewayIntentBits } from 'discord.js'
import { fetchShowtimes, toFields } from './showtimes.js'
const client = new Client({ intents: [GatewayIntentBits.Guilds] })
client.once(Events.ClientReady, c => console.log(`Logged in as ${c.user.tag}`))
client.on(Events.InteractionCreate, async (interaction) => {
if (!interaction.isChatInputCommand() || interaction.commandName !== 'showtimes')
return
const city = interaction.options.getString('city', true)
const version = interaction.options.getString('version')
// the API answers in well under a second, but Discord only waits 3 s for a first reply
await interaction.deferReply()
try {
const showtimes = await fetchShowtimes(city, version)
if (showtimes.length === 0) {
await interaction.editReply(`No upcoming showtimes found in ${city}.`)
return
}
const embed = new EmbedBuilder()
.setTitle(`Showtimes in ${city}, ${showtimes[0].startsAt.slice(0, 10)}`)
.setColor(0xB8302F)
.addFields(toFields(showtimes))
.setFooter({ text: 'Data: API Cinéma' })
await interaction.editReply({ embeds: [embed] })
}
catch (error) {
console.error(error)
await interaction.editReply('The showtimes are unavailable right now, try again later.')
}
})
client.login(process.env.DISCORD_TOKEN)node bot.jsType /showtimes city:Lyon in your server. In Lyon, a weekday afternoon, the first card reads like this:
Les Misérables
Ugc Cine Cite Confluence · 13:30, 15:30, 17:00, 19:00, 20:30
Ugc Cine Cite Part-Dieu · 13:30, 15:30, 17:00, 19:00, 20:30
Pathe Bellecour · 14:05, 17:40, 20:00It works the same with a US city: /showtimes city:Chicago.
Going further
- City names.
cityis an exact match on the town name: New York leaves out Brooklyn, Paris leaves out its suburbs. For a whole metro area, addlatandlngoptions and call the API withnear=lat,lng&radius_km=5instead. - A daily post. Instead of a command, post tomorrow's programme in a channel every evening: call
fetchShowtimesfrom asetIntervalor a cron job, thenchannel.send({ embeds: [embed] }). - A cache. Every command costs one request. On a busy server, keep each city's answer in a
Mapfor 15 minutes. - Posters and links.
s.movie.posterUrlgives the poster (embed.setThumbnail),s.bookingUrlthe booking page of each showtime.
Every parameter and field is in the documentation; the guide for France and the guide for the United States show the other searches.