Files
ripster/backend/src/services/audnexService.js
T
2026-03-16 10:02:41 +00:00

197 lines
5.5 KiB
JavaScript

const fs = require('fs');
const logger = require('./logger').child('AUDNEX');
const AUDNEX_BASE_URL = 'https://api.audnex.us';
const AUDNEX_TIMEOUT_MS = 10000;
const ASIN_PATTERN = /B0[0-9A-Z]{8}/u;
function normalizeAsin(value) {
const raw = String(value || '').trim().toUpperCase();
return ASIN_PATTERN.test(raw) ? raw : null;
}
async function extractAsinFromAaxFile(filePath) {
const sourcePath = String(filePath || '').trim();
if (!sourcePath) {
return null;
}
return new Promise((resolve, reject) => {
let printableWindow = '';
let settled = false;
const stream = fs.createReadStream(sourcePath, { highWaterMark: 64 * 1024 });
const finish = (value) => {
if (settled) {
return;
}
settled = true;
resolve(value);
};
stream.on('data', (chunk) => {
if (settled) {
return;
}
for (const byte of chunk) {
if (byte >= 32 && byte <= 126) {
printableWindow = `${printableWindow}${String.fromCharCode(byte)}`.slice(-48);
const match = printableWindow.match(/B0[0-9A-Z]{8}/u);
if (match?.[0]) {
const asin = normalizeAsin(match[0]);
if (asin) {
logger.info('asin:detected', { filePath: sourcePath, asin });
stream.destroy();
finish(asin);
return;
}
}
} else {
printableWindow = '';
}
}
});
stream.on('error', (error) => {
if (settled) {
return;
}
settled = true;
reject(error);
});
stream.on('close', () => {
if (!settled) {
finish(null);
}
});
});
}
async function audnexFetch(url) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), AUDNEX_TIMEOUT_MS);
try {
const response = await fetch(url, {
headers: {
'Accept': 'application/json',
'User-Agent': 'Ripster/1.0'
},
signal: controller.signal
});
clearTimeout(timer);
if (!response.ok) {
throw new Error(`Audnex Anfrage fehlgeschlagen (${response.status})`);
}
return response.json();
} catch (error) {
clearTimeout(timer);
throw error;
}
}
function extractChapterArray(payload) {
if (Array.isArray(payload)) {
return payload;
}
const candidates = [
payload?.chapters,
payload?.data?.chapters,
payload?.content?.chapters,
payload?.results?.chapters
];
return candidates.find((entry) => Array.isArray(entry)) || [];
}
function normalizeAudnexChapter(entry, index) {
const startOffsetMs = Number(
entry?.startOffsetMs
?? entry?.startMs
?? entry?.offsetMs
?? 0
);
const lengthMs = Number(
entry?.lengthMs
?? entry?.durationMs
?? entry?.length
?? 0
);
const title = String(entry?.title || entry?.chapterTitle || `Kapitel ${index + 1}`).trim() || `Kapitel ${index + 1}`;
const safeStartMs = Number.isFinite(startOffsetMs) && startOffsetMs >= 0 ? Math.round(startOffsetMs) : 0;
const safeLengthMs = Number.isFinite(lengthMs) && lengthMs > 0 ? Math.round(lengthMs) : 0;
return {
index: index + 1,
title,
startMs: safeStartMs,
endMs: safeStartMs + safeLengthMs,
startSeconds: Math.round(safeStartMs / 1000),
endSeconds: Math.round((safeStartMs + safeLengthMs) / 1000)
};
}
function normalizeNameList(items) {
if (!Array.isArray(items) || items.length === 0) {
return null;
}
const names = items
.map((item) => String(item?.name || '').trim())
.filter(Boolean);
return names.length > 0 ? names.join(', ') : null;
}
async function fetchBookByAsin(asin, region = 'de') {
const normalizedAsin = normalizeAsin(asin);
if (!normalizedAsin) {
return null;
}
const url = new URL(`${AUDNEX_BASE_URL}/books/${normalizedAsin}`);
url.searchParams.set('region', String(region || 'de').trim() || 'de');
logger.info('book:fetch:start', { asin: normalizedAsin, url: url.toString() });
const payload = await audnexFetch(url.toString());
if (!payload || typeof payload !== 'object') {
return null;
}
const narrator = normalizeNameList(payload.narrators);
const author = normalizeNameList(payload.authors);
const title = String(payload.title || '').trim() || null;
const series = String(payload.seriesName || payload.series || '').trim() || null;
const part = String(payload.seriesPart || payload.part || '').trim() || null;
const description = String(payload.summary || payload.description || '').trim() || null;
const year = payload.releaseDate
? String(payload.releaseDate).slice(0, 4)
: null;
logger.info('book:fetch:done', { asin: normalizedAsin, narrator, author, title });
return { narrator, author, title, series, part, description, year };
}
async function fetchChaptersByAsin(asin, region = 'de') {
const normalizedAsin = normalizeAsin(asin);
if (!normalizedAsin) {
return [];
}
const url = new URL(`${AUDNEX_BASE_URL}/books/${normalizedAsin}/chapters`);
url.searchParams.set('region', String(region || 'de').trim() || 'de');
logger.info('chapters:fetch:start', { asin: normalizedAsin, url: url.toString() });
const payload = await audnexFetch(url.toString());
const chapters = extractChapterArray(payload)
.map((entry, index) => normalizeAudnexChapter(entry, index))
.filter((chapter) => chapter.endMs > chapter.startMs && chapter.title);
logger.info('chapters:fetch:done', { asin: normalizedAsin, count: chapters.length });
return chapters;
}
module.exports = {
extractAsinFromAaxFile,
fetchBookByAsin,
fetchChaptersByAsin
};