Files
Podchaser-Metadata/server.js
2025-11-26 13:42:07 +01:00

372 lines
10 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// server.js
// Einfacher Custom Metadata Provider für Audiobookshelf,
// der Podcast-Metadaten von Podchaser als "Book"-Metadaten ausliefert.
import express from 'express';
import dotenv from 'dotenv';
dotenv.config();
const app = express();
app.use(express.json());
// ========= Konfiguration über Umgebungsvariablen =========
//
// PODCHASER_CLIENT_ID -> Dein Podchaser API Key
// PODCHASER_CLIENT_SECRET -> Dein Podchaser API Secret
// ABS_AUTH_TOKEN -> Shared Secret, das du in Audiobookshelf
// als "Authorization Bearer" einträgst
// PORT -> Port des Webservers (Standard: 3000)
// PODCHASER_GRAPHQL_URL -> optional, Standard: https://api.podchaser.com/graphql
//
const {
PODCHASER_CLIENT_ID,
PODCHASER_CLIENT_SECRET,
ABS_AUTH_TOKEN,
PORT = 3000,
PODCHASER_GRAPHQL_URL = 'https://api.podchaser.com/graphql'
} = process.env;
// Einfache in-Memory Token-Cache-Struktur
let podchaserToken = {
accessToken: null,
// Unix-Timestamp in Millisekunden
expiresAt: 0
};
// ======== Hilfsfunktion: ABS-Authorization prüfen =========
function checkAbsAuth(req, res) {
if (!ABS_AUTH_TOKEN) {
res.status(500).json({ error: 'ABS_AUTH_TOKEN not configured on server' });
return false;
}
const header = req.header('authorization') || req.header('Authorization');
if (!header || !header.startsWith('Bearer ')) {
res.status(401).json({ error: 'Missing or invalid Authorization header' });
return false;
}
const token = header.substring('Bearer '.length).trim();
if (token !== ABS_AUTH_TOKEN) {
res.status(401).json({ error: 'Invalid bearer token' });
return false;
}
return true;
}
// ======== Hilfsfunktion: Podchaser Access Token holen/cachen =========
//
// Podchaser verwendet eine GraphQL-Mutation `requestAccessToken` mit
// grant_type CLIENT_CREDENTIALS, client_id und client_secret. :contentReference[oaicite:3]{index=3}
async function getPodchaserAccessToken() {
if (!PODCHASER_CLIENT_ID || !PODCHASER_CLIENT_SECRET) {
throw new Error('Podchaser client_id/client_secret not configured');
}
const now = Date.now();
// Wenn Token noch gültig ist, einfach wiederverwenden
if (podchaserToken.accessToken && podchaserToken.expiresAt > now + 60_000) {
return podchaserToken.accessToken;
}
const query = `
mutation {
requestAccessToken(
input: {
grant_type: CLIENT_CREDENTIALS
client_id: "${PODCHASER_CLIENT_ID}"
client_secret: "${PODCHASER_CLIENT_SECRET}"
}
) {
access_token
token_type
expires_in
}
}
`;
const response = await fetch(PODCHASER_GRAPHQL_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ query })
});
if (!response.ok) {
const text = await response.text().catch(() => '');
throw new Error(
`Failed to get Podchaser access token: HTTP ${response.status} ${text}`
);
}
const json = await response.json();
if (json.errors) {
throw new Error(
`Podchaser token error: ${JSON.stringify(json.errors)}`
);
}
const tokenData = json?.data?.requestAccessToken;
if (!tokenData?.access_token) {
throw new Error('Podchaser token response missing access_token');
}
const expiresInSeconds = tokenData.expires_in || 31536000; // laut Doku ~1 Jahr :contentReference[oaicite:4]{index=4}
podchaserToken.accessToken = tokenData.access_token;
podchaserToken.expiresAt = Date.now() + expiresInSeconds * 1000;
return podchaserToken.accessToken;
}
// ======== Hilfsfunktion: GraphQL-Query gegen Podchaser =========
async function podchaserGraphQL(query, variables = {}) {
const accessToken = await getPodchaserAccessToken();
const response = await fetch(PODCHASER_GRAPHQL_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${accessToken}`
},
body: JSON.stringify({ query, variables })
});
if (!response.ok) {
const text = await response.text().catch(() => '');
throw new Error(`Podchaser GraphQL HTTP ${response.status}: ${text}`);
}
const json = await response.json();
if (json.errors) {
throw new Error(`Podchaser GraphQL errors: ${JSON.stringify(json.errors)}`);
}
return json.data;
}
// ======== Mapping: Podchaser Podcast -> Audiobookshelf BookMetadata ========
//
// ABS erwartet das BookMetadata-Schema (siehe Custom Metadata Provider Spec). :contentReference[oaicite:5]{index=5}
function mapPodcastToBookMetadata(podcast, episodesList) {
if (!podcast) return null;
const {
title,
description,
imageUrl,
language,
numberOfEpisodes,
avgEpisodeLength,
startDate,
categories,
author
} = podcast;
// Basisbeschreibung
let desc = description || '';
// Optionale Episodenliste in die Beschreibung einbetten,
// damit sie in ABS sichtbar ist (da BookMetadata selbst kein Episodenfeld hat).
if (episodesList && episodesList.data && episodesList.data.length) {
const lines = episodesList.data.map((ep, idx) => {
const date = ep.airDate ? ep.airDate.slice(0, 10) : '';
const lengthMin =
typeof ep.length === 'number'
? `${Math.round(ep.length / 60)} min`
: 'n/a';
return `${idx + 1}. ${ep.title} (${lengthMin}${
date ? `, ${date}` : ''
})`;
});
desc += `\n\nEpisodes (first ${episodesList.data.length} on Podchaser):\n`;
desc += lines.join('\n');
}
// Genres aus Podchaser-Categories
const genres =
categories?.map((c) => c?.name).filter((g) => !!g) ?? [];
// Tags frei wählbar hier ein paar sinnvolle Defaults
const tags = ['podcast', 'podchaser'];
if (numberOfEpisodes != null) {
tags.push(`episodes:${numberOfEpisodes}`);
}
// Duration: Podchaser gibt avgEpisodeLength in Sekunden an.
// Für einen Podcast als "Book" ist das nur eine grobe Info;
// die tatsächliche Laufzeit kommt in ABS aus den Mediendateien.
const duration = typeof avgEpisodeLength === 'number'
? avgEpisodeLength
: undefined;
// Published Year grob aus Startdatum
let publishedYear;
if (startDate) {
publishedYear = String(new Date(startDate).getUTCFullYear());
}
const authorName = author?.name || author?.email || undefined;
return {
// Pflichtfeld
title: title || 'Unknown Podcast',
// Optionale Felder im ABS-BookMetadata
subtitle: undefined,
author: authorName,
narrator: undefined,
publisher: undefined,
publishedYear,
description: desc,
cover: imageUrl || undefined,
isbn: undefined,
asin: undefined,
genres,
tags,
series: [],
language: language || undefined,
duration
};
}
// ========= Endpoint: /search (für Audiobookshelf Custom Metadata Provider) =========
//
// ABS ruft nach Spec GET /search?query=... auf und erwartet:
// { "matches": [ BookMetadata, ... ] } :contentReference[oaicite:6]{index=6}
//
// Hier interpretieren wir "query" als freien Suchstring auf Podchaser
// (podcasts(searchTerm: "...")) :contentReference[oaicite:7]{index=7}
app.get('/search', async (req, res) => {
try {
if (!checkAbsAuth(req, res)) return;
const queryParam = (req.query.query || '').toString().trim();
if (!queryParam) {
return res.status(400).json({ error: 'Missing required query parameter ?query=' });
}
const gql = `
query SearchPodcasts($term: String!) {
podcasts(searchTerm: $term, first: 10) {
data {
id
title
description
imageUrl
language
startDate
numberOfEpisodes
avgEpisodeLength
categories { name }
author { name email }
}
}
}
`;
const data = await podchaserGraphQL(gql, { term: queryParam });
const podcasts = data?.podcasts?.data || [];
const matches = podcasts
.map((p) => mapPodcastToBookMetadata(p, null))
.filter((m) => m !== null);
res.json({ matches });
} catch (err) {
console.error('Error in /search:', err);
res.status(500).json({ error: 'Internal server error' });
}
});
// ========= Endpoint: /podcast/:id (komfortabler Direktzugriff) =========
//
// Diesen Endpoint kannst du z.B. manuell testen oder von anderen Tools aus nutzen.
// Er liefert EIN BookMetadata-Objekt für einen Podcast inkl. Episodenliste
// in der Beschreibung.
//
// Standardmäßig interpretiert :id als PODCHASER-ID.
// Du kannst den Identifiertyp per ?type=PODCHASER|APPLE_PODCASTS|SPOTIFY|RSS
// überschreiben. :contentReference[oaicite:8]{index=8}
app.get('/podcast/:id', async (req, res) => {
try {
if (!checkAbsAuth(req, res)) return;
const id = req.params.id;
if (!id) {
return res.status(400).json({ error: 'Missing podcast id in route' });
}
const typeParam = (req.query.type || 'PODCHASER').toString().toUpperCase();
const episodesParam = parseInt(req.query.episodes || '20', 10);
const episodesFirst = Number.isFinite(episodesParam)
? Math.min(Math.max(episodesParam, 1), 50) // 150
: 20;
const gql = `
query PodcastWithEpisodes(
$id: String!,
$type: PodcastIdentifierType!,
$episodesFirst: Int!
) {
podcast(identifier: { id: $id, type: $type }) {
id
title
description
imageUrl
language
startDate
numberOfEpisodes
avgEpisodeLength
categories { name }
author { name email }
episodes(first: $episodesFirst) {
data {
id
title
length
airDate
}
}
}
}
`;
const data = await podchaserGraphQL(gql, {
id,
type: typeParam,
episodesFirst
});
const podcast = data?.podcast;
if (!podcast) {
return res.status(404).json({ error: 'Podcast not found on Podchaser' });
}
const episodesList = podcast.episodes || null;
const meta = mapPodcastToBookMetadata(podcast, episodesList);
res.json(meta);
} catch (err) {
console.error('Error in /podcast/:id:', err);
res.status(500).json({ error: 'Internal server error' });
}
});
// ========= Healthcheck =========
app.get('/', (req, res) => {
res.json({
status: 'ok',
message: 'Podchaser -> Audiobookshelf metadata bridge',
endpoints: ['/search', '/podcast/:id']
});
});
// ========= Serverstart =========
app.listen(PORT, () => {
console.log(`Podchaser metadata provider listening on port ${PORT}`);
});