Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

- Detect FFmpeg failures before reporting playback as started, skip failed queued tracks instead of wedging the player, and retain bounded failure details in the container log.
- Log failed Discord interactions with command and guild context so playback incidents can be diagnosed after the fact.

## [2.11.6] - 2026-07-12

- Add optional age-verified YouTube cookie-file support for age-restricted playback.
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@
"parse-duration": "1.0.2",
"patch-package": "^8.0.0",
"postinstall-postinstall": "^2.1.0",
"prism-media": "^1.3.5",
"read-pkg": "7.1.0",
"reflect-metadata": "^0.2.2",
"sponsorblock-api": "^0.2.4",
Expand Down
32 changes: 29 additions & 3 deletions src/bot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,24 @@ import {REST} from '@discordjs/rest';
import {Routes} from 'discord-api-types/v10';
import registerCommandsOnGuild from './utils/register-commands-on-guild.js';

const sanitizeErrorDetail = (error: unknown) => {
const message = error instanceof Error ? error.message : String(error);
return message
.replace(/https?:\/\/\S+/gi, '[URL]')
.replace(/(["']?\b(?:api[-_]?key|key|token|authorization|cookie)["']?\s*[:=]\s*)(?:["'][^"']*["']|Bearer\s+[^,;\s]+|[^,;\s}\]]+)/gi, '$1[redacted]')
.replace(/\b(authorization|cookie)\s*[:=]\s*[^\r\n]*/gi, '$1: [redacted]')
.replace(/\s+/g, ' ')
.trim()
.slice(0, 500);
};

const sanitizeErrorForLog = (error: unknown) => {
const name = error instanceof Error ? error.name : 'Error';
const detail = sanitizeErrorDetail(error);

return `${name}: ${detail || 'unknown error'}`;
};

@injectable()
export default class {
private readonly client: Client;
Expand Down Expand Up @@ -100,14 +118,22 @@ export default class {
}
}
} catch (error: unknown) {
debug(error);
const sanitizedError = sanitizeErrorForLog(error);
debug(sanitizedError);
const interactionName = interaction.isCommand() || interaction.isAutocomplete()
? `/${interaction.commandName}`
: interaction.isButton()
? `button:${interaction.customId}`
: interaction.type.toString();
console.error(`Discord interaction failed (${interactionName}, guild=${interaction.guildId ?? 'dm'}, channel=${interaction.channelId ?? 'unknown'}, user=${interaction.user.id}): ${sanitizedError}`);
const userSafeError = new Error(sanitizeErrorDetail(error));

// This can fail if the message was deleted, and we don't want to crash the whole bot
try {
if ((interaction.isCommand() || interaction.isButton()) && (interaction.replied || interaction.deferred)) {
await interaction.editReply(errorMsg(error as Error));
await interaction.editReply(errorMsg(userSafeError));
} else if (interaction.isCommand() || interaction.isButton()) {
await interaction.reply({content: errorMsg(error as Error), ephemeral: true});
await interaction.reply({content: errorMsg(userSafeError), ephemeral: true});
}
} catch {}
}
Expand Down
4 changes: 3 additions & 1 deletion src/commands/resume.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,15 +34,17 @@ export default class implements Command {
throw new Error('nothing to play');
}

await interaction.deferReply({ephemeral: true});
await player.connect(targetVoiceChannel);
await player.play();
if (!player.getCurrent()) {
throw new Error('no playable songs found');
}

await interaction.reply({
await interaction.followUp({
content: 'the stop-and-go light is now green',
embeds: [buildPlayingMessageEmbed(player)],
});
await interaction.deleteReply().catch(() => undefined);
}
}
12 changes: 9 additions & 3 deletions src/commands/skip.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,15 +32,21 @@ export default class implements Command {
}

const player = this.playerManager.get(interaction.guild!.id);
await interaction.deferReply({ephemeral: true});

try {
await player.forward(numToSkip);
await interaction.reply({
await interaction.followUp({
content: 'keep \'er movin\'',
embeds: player.getCurrent() ? [buildPlayingMessageEmbed(player)] : [],
});
} catch (_: unknown) {
throw new Error('no song to skip to');
await interaction.deleteReply().catch(() => undefined);
} catch (error: unknown) {
if (error instanceof Error && error.message === 'No songs in queue to forward to.') {
throw new Error('no song to skip to');
}

throw error;
}
}
}
12 changes: 9 additions & 3 deletions src/commands/unskip.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,21 @@ export default class implements Command {

public async execute(interaction: ChatInputCommandInteraction): Promise<void> {
const player = this.playerManager.get(interaction.guild!.id);
await interaction.deferReply({ephemeral: true});

try {
await player.back();
await interaction.reply({
await interaction.followUp({
content: 'back \'er up\'',
embeds: player.getCurrent() ? [buildPlayingMessageEmbed(player)] : [],
});
} catch (_: unknown) {
throw new Error('no song to go back to');
await interaction.deleteReply().catch(() => undefined);
} catch (error: unknown) {
if (error instanceof Error && error.message === 'No songs in queue to go back to.') {
throw new Error('no song to go back to');
}

throw error;
}
}
}
10 changes: 8 additions & 2 deletions src/services/add-query-to-queue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@ const isSameQueueEntry = (capturedId: number | null, currentId: number | null) =
capturedId !== null && capturedId === currentId
);

const normalizeSkipError = (error: unknown) => (
error instanceof Error && error.message === 'No songs in queue to forward to.'
? new Error('no song to skip to')
: error
);

@injectable()
export default class AddQueryToQueue {
private readonly sponsorBlock?: SponsorBlock;
Expand Down Expand Up @@ -124,8 +130,8 @@ export default class AddQueryToQueue {
try {
await player.forward(1);
didSkipCurrentTrack = true;
} catch (_: unknown) {
throw new Error('no song to skip to');
} catch (error: unknown) {
throw normalizeSkipError(error);
}
}

Expand Down
108 changes: 82 additions & 26 deletions src/services/file-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@ import debug from '../utils/debug.js';
import {prisma} from '../utils/db.js';
import {FileCache} from '@prisma/client';

export type FileCacheEntry = {
generation: string;
path: string;
};

@injectable()
export default class FileCacheProvider {
private static readonly mutationQueue = new PQueue({concurrency: 1});
Expand All @@ -24,40 +29,50 @@ export default class FileCacheProvider {
* @param hash lookup key
*/
async getPathFor(hash: string): Promise<string | null> {
const model = await prisma.fileCache.findUnique({
where: {
hash,
},
});

if (!model) {
return null;
}

const resolvedPath = path.join(this.config.CACHE_DIR, hash);
return (await this.getEntryFor(hash))?.path ?? null;
}

try {
await fs.access(resolvedPath);
} catch (_: unknown) {
await prisma.fileCache.delete({
async getEntryFor(hash: string): Promise<FileCacheEntry | null> {
return (await FileCacheProvider.mutationQueue.add(async () => {
const model = await prisma.fileCache.findUnique({
where: {
hash,
},
});

return null;
}
if (!model) {
return null;
}

await prisma.fileCache.update({
where: {
hash,
},
data: {
accessedAt: new Date(),
},
});
const resolvedPath = path.join(this.config.CACHE_DIR, hash);
let stats;

try {
stats = await fs.stat(resolvedPath, {bigint: true});
} catch (_: unknown) {
await prisma.fileCache.delete({
where: {
hash,
},
});

return null;
}

await prisma.fileCache.update({
where: {
hash,
},
data: {
accessedAt: new Date(),
},
});

return resolvedPath;
return {
generation: this.getFileGeneration(stats),
path: resolvedPath,
};
})) ?? null;
}

/**
Expand Down Expand Up @@ -112,6 +127,47 @@ export default class FileCacheProvider {
});
}

async invalidate(hash: string, expectedGeneration: string) {
await FileCacheProvider.mutationQueue.add(async () => {
const resolvedPath = path.join(this.config.CACHE_DIR, hash);
try {
const stats = await fs.stat(resolvedPath, {bigint: true});
if (this.getFileGeneration(stats) !== expectedGeneration) {
return;
}
} catch (error: unknown) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return;
}

throw error;
}

const model = await prisma.fileCache.findUnique({where: {hash}});
if (model) {
try {
await prisma.fileCache.delete({where: {hash}});
} catch (error: unknown) {
if ((error as {code?: string}).code !== 'P2025') {
throw error;
}
}
}

try {
await fs.unlink(resolvedPath);
} catch (error: unknown) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
throw error;
}
}
});
}

private getFileGeneration(stats: {dev: bigint; ino: bigint; mtimeNs: bigint; size: bigint}) {
return `${stats.dev}:${stats.ino}:${stats.size}:${stats.mtimeNs}`;
}

private async finalizeWrite(hash: string, tmpPath: string, finalPath: string) {
try {
const temporaryStats = await fs.stat(tmpPath);
Expand Down
Loading