372 lines
10 KiB
JavaScript
372 lines
10 KiB
JavaScript
// 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) // 1–50
|
||
: 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}`);
|
||
});
|