A Discord bot for today’s movie showtimes

Build a /showtimes slash command in Node.js with discord.js: today’s films in any French or US city, with cinemas and times, in three small files.

3 min read

On this page
  1. What you need
  2. 1.Set up the project
  3. 2.Read the showtimes
  4. 3.Declare the command
  5. 4.The bot
  6. Going further

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:VOST

The 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 bot and applications.commands, then open the generated URL to add the bot to your server.

Set up the project

Terminal
mkdir showtimes-bot && cd showtimes-bot
npm init -y
npm pkg set type=module
npm install discord.js

The three secrets go in environment variables, never in the code:

Terminal
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.

showtimes.js
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 date or from, 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".
  • startsAt is the cinema's wall-clock time (2026-10-24T20:30:00), so slice(11, 16) gives 20:30 directly, 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

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:

JavaScript
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')
Terminal
node register.js

The bot: bot.js

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)
Terminal
node bot.js

Type /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:00

It works the same with a US city: /showtimes city:Chicago.

Going further

  • City names. city is an exact match on the town name: New York leaves out Brooklyn, Paris leaves out its suburbs. For a whole metro area, add lat and lng options and call the API with near=lat,lng&radius_km=5 instead.
  • A daily post. Instead of a command, post tomorrow's programme in a channel every evening: call fetchShowtimes from a setInterval or a cron job, then channel.send({ embeds: [embed] }).
  • A cache. Every command costs one request. On a busy server, keep each city's answer in a Map for 15 minutes.
  • Posters and links. s.movie.posterUrl gives the poster (embed.setThumbnail), s.bookingUrl the 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.