Skip to content
Open
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
15 changes: 14 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,17 @@ HARDCOVER_TOKEN=
GOODREADS_API_KEY=
# Deezer API Access Token (User OAuth token)
DEEZER_ACCESS_TOKEN=

# Big Finish
# Big Finish Series Mapping. This is used to map the series names Big Finish
# uses to a more appropriate name for working with ABS and to try and merge seroes together where needed it can be customized to your own needs.
# The default mapping is below. If you want to use your own mapping, create a file called series_mapping.json in the config
# folder with your own mapping.
#
# Doctor stories are mapped to D[1-13]
# Friends of the Doctor stories are mapped to F[1-8]
# Torchwood stories are mapped to T[0-3]
#
# seriesmapping=true
#
# Big Finish Character's. This flag will save the character's as tags in ABS allowing you to search for stories by character.
# characters=true
Comment on lines +8 to +21

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This would be redundant then

3 changes: 3 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ RUN npm ci --omit=dev && \

COPY --from=builder /app/dist ./dist

# Copy the JSON into the runtime image
COPY series-mapping.json /app/data/series-mapping.json

RUN mkdir -p /app/data && chown -R node:node /app

ENV NODE_ENV=production
Expand Down
2 changes: 2 additions & 0 deletions Providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,8 @@ Under "Auth" use `abs`
| Name | Type | Validation | Description |
|------|------|------------|-------------|
| `limit` | int | 1-5 | Maximum number of results to return (default: 3, max: 5) |
| 'characters' | boolean | true/false | allows characters to be returned as tags |
| 'seriesmapping' | boolean | true/false | series mapping to standard fileds |
Comment on lines +148 to +149

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Exactly like this then. These are for the config.json and are generated from the script, defined in package.json


### Returned Fields

Expand Down
3 changes: 3 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ services:
- HARDCOVER_TOKEN=${HARDCOVER_TOKEN}
# See https://lazylibrarian.gitlab.io/config_importing/
- GOODREADS_API_KEY=${GOODREADS_API_KEY}
# Big Finish Env Variables
- characters=false
- seriesmapping=false
Comment on lines +14 to +16

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same

volumes:
- ./data:/app/data
# This container runs by default as 1000:1000 user.
Expand Down
100 changes: 100 additions & 0 deletions series-mapping.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
{
"Class": "Class (CL)",
"Counter-Measures": "Counter Measures (CM)",
"Cyberman": "Cyberman (CY)",
"Doctor Who - The Monthly Adventures": "D0. Dr Who - Main Range (MR)",
"Doctor Who - The First Doctor Adventures": "D1. The First Doctor Adventures (1DA)",
"Doctor Who - The Tenth Doctor Adventures": "D10. The Tenth Doctor Adventures (10DA)",
"Doctor Who - The Second Doctor Adventures": "D2. The Second Doctor Adventures (2DA)",
"Doctor Who - The Third Doctor Adventures": "D3. The Third Doctor Adventures (3DA)",
"Doctor Who - The Fourth Doctor Adventures": "D4. The Fourth Doctor Adventures (4DA)",
"Doctor Who - The Fifth Doctor Adventures": "D5. The Fifth Doctor Adventures (5DA)",
"Doctor Who - The Sixth Doctor Adventures": "D6. The Sixth Doctor Adventures (6DA)",
"Doctor Who - The Seventh Doctor Adventures": "D7. The Seventh Doctor Adventures (7DA)",
"Doctor Who - The Eighth Doctor Adventures": "D8. The Eighth Doctor Adventures (8DA)",
"Doctor Who - The Ninth Doctor Adventures": "D9. The Ninth Doctor Adventures (9DA)",
"Doctor Who: The First Doctor Adventures": "D1. The First Doctor Adventures (1DA)",
"Doctor Who: The Tenth Doctor Adventures": "D10. The Tenth Doctor Adventures (10DA)",
"Doctor Who: The Eleventh Doctor Adventures": "D11. The Eleventh Doctor Adventures (11DA)",
"Doctor Who: The Twelfth Doctor Adventures": "D12. The Twelfth Doctor Adventures (12DA)",
"Doctor Who: The Thirteenth Doctor Adventures": "D13. The Thirteenth Doctor Adventures (13DA)",
"Doctor Who: The Second Doctor Adventures": "D2. The Second Doctor Adventures (2DA)",
"Doctor Who: The Third Doctor Adventures": "D3. The Third Doctor Adventures (3DA)",
"Doctor Who: The Fourth Doctor Adventures": "D4. The Fourth Doctor Adventures (4DA)",
"Doctor Who: The Fifth Doctor Adventures": "D5. The Fifth Doctor Adventures (5DA)",
"Doctor Who: The Sixth Doctor Adventures": "D6. The Sixth Doctor Adventures (6DA)",
"Doctor Who: The Seventh Doctor Adventures": "D7. The Seventh Doctor Adventures (7DA)",
"Doctor Who: The Eighth Doctor Adventures": "D8. The Eighth Doctor Adventures (8DA)",
"Doctor Who: The Ninth Doctor Adventures": "D9. The Ninth Doctor Adventures (9DA)",
"Dalek Empire": "Dalek Empire (DE)",
"Dark Gallifrey": "Dark Gallifrey (DG)",
"Doctor Who - Destiny of the Doctor": "Destiny of the Doctor",
"Doom's Day": "Doom's Day (DD)",
"Bernice Summerfield": "F1. Bernice Summerfield (BS)",
"Bernice Summerfield - Books & Audiobooks": "F2. Bernice Summerfield Audiobooks (BSAB)",
"Bernice Summerfield: Books & Audiobooks": "F2. Bernice Summerfield Audiobooks (BSAB)",
"Doctor Who - The New Adventures of Bernice Summerfield": "F3. The New Adventures of Bernice Summerfield (NABS)",
"Doctor Who: The New Adventures of Bernice Summerfield": "F3. The New Adventures of Bernice Summerfield (NABS)",
"Gallifrey": "Gallifrey (GAL)",
"I, Davros": "I, DAVROS",
"Jago & Litefoot": "Jago & Litefoot (J&L)",
"Missy": "Missy (MIS)",
"Doctor Who - Once and Future": "Once and Future (O&F)",
"Doctor Who - Philip Hinchcliffe Presents": "Philip Hincliffe Presents (PHP)",
"Doctor Who - Short Trips Rarities": "Rarities & Subcriber Short Trips (SST)",
"Doctor Who: Once and Future": "Once and Future (O&F)",
"Doctor Who: Philip Hinchcliffe Presents": "Philip Hincliffe Presents (PHP)",
"Doctor Who: Short Trips Rarities": "Rarities & Subcriber Short Trips (SST)",
"Rose Tyler": "Rose Tyler The Dimension Cannon (RT)",
"Sarah Jane Smith": "Sarah Jane Smith (SJS)",
"Doctor Who - Short Trips": "Short Trips (ST)",
"The Worlds of Doctor Who - Special Releases": "Special Releases (SP)",
"Doctor Who: Short Trips": "Short Trips (ST)",
"The Worlds of Doctor Who: Special Releases": "Special Releases (SP)",
"Doctor Who: The Classic Series: Special Releases": "Special Releases (SP)",
"Torchwood - Monthly Range": "T0. Torchwood Main Range (TMR)",
"Torchwood - Special Releases": "T1. Torchwood - Specials (TWsp)",
"Torchwood: Monthly Range": "T0. Torchwood Main Range (TMR)",
"Torchwood: Special Releases": "T1. Torchwood - Specials (TWsp)",
"Torchwood One": "T2. Torchwood One (TW1)",
"Torchwood - The Story Continues": "T3. Torchwood - The Story Continues",
"Torchwood: The Story Continues": "T3. Torchwood - The Story Continues",
"Torchwood Soho": "T4. Torchwood Soho (TWS)",
"Doctor Who - The Audio Novels": "The Audio Novels",
"Doctor Who - The Companion Chronicles": "The Companion Chronicles (CC)",
"Doctor Who: The Audio Novels": "The Audio Novels",
"Doctor Who: The Companion Chronicles": "The Companion Chronicles (CC)",
"River Song": "The Diary of River Song (RS)",
"Doctor Who - The Doctor Chronicles": "The Doctor Chronicles (TDC)",
"Doctor Who - The Early Adventures": "The Early Adventures (EA)",
"Doctor Who: The Doctor Chronicles": "The Doctor Chronicles (TDC)",
"Doctor Who: The Early Adventures": "The Early Adventures (EA)",
"The Lives of Captain Jack": "The Lives of Captain Jack (LCJ)",
"Doctor Who - The Lost Stories": "The Lost Stories (LS)",
"Doctor Who: The Lost Stories": "The Lost Stories (LS)",
"The Paternoster Gang": "The Paternoster Gang (PAT)",
"The Robots": "The Robots (ROB)",
"Doctor Who - The Stageplays": "The Stageplays (STG)",
"Doctor Who - The War Doctor": "The War Doctor (WD)",
"Doctor Who: The Stageplays": "The Stageplays (STG)",
"Doctor Who: The War Doctor": "The War Doctor (WD)",
"The War Master": "The War Master (WM)",
"Doctor Who - Time Lord Victorious": "Time Lord Victorious (TLV)",
"Doctor Who: Time Lord Victorious": "Time Lord Victorious (TLV)",
"UNIT": "UNIT (UNIT)",
"UNIT - The New Series": "UNIT - The New Series (UNITNS)",
"UNIT: The New Series": "UNIT - The New Series (UNITNS)",
"Iris Wildthyme": "F4. Iris Wildthyme (IW)",
"Iris Wildthyme and Friends":"F5. Iris Wildthyme & Friends (IWF)",
"Graceless": "F6. Graceless",
"Doctor Who - Unbound": "Unbound (UN)",
"Vienna": "F7. Vienna",
"Charlotte Pollard": "F8. Charlotte Pollard",
"Doctor Who - The Fugitive Doctor": "The Fugitive Doctor Adventures (FDA)",
"Doctor Who: The Fugitive Doctor": "The Fugitive Doctor Adventures (FDA)",
"Call Me Master": "Call Me Master (CMM)",
"Susan's War": "Susan's War (SW)",
"V UK": "V - UK",
"Planet Krynoid": "Planet Krynoid (PG)",
"Doctor Who: Circuit Breaker": "Circuit Breaker (CB)"
}
6 changes: 5 additions & 1 deletion src/providers/bigfinish/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,5 +29,9 @@
"publisher",
"duration"
],
"comments": []
"comments": [],
"compilerOptions": {
"resolveJsonModule": true,
"esModuleInterop": true
}
}
83 changes: 78 additions & 5 deletions src/providers/bigfinish/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ const config: ProviderConfig = JSON.parse(fs.readFileSync(configPath, 'utf-8'))
const BASE_URL = 'https://www.bigfinish.com'
const SEARCH_URL = `${BASE_URL}/api/search`

const isEnvEnabled = (value: string | undefined): boolean => value?.trim().toLowerCase() === 'true'
const ENABLE_SERIES_MAPPING = isEnvEnabled(process.env.seriesmapping)
const ENABLE_CHARACTERS = isEnvEnabled(process.env.characters)
Comment on lines +15 to +17

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that would better fit as an parameter for the provider via the config so that each user can decide for themselfs


const SEARCH_HEADERS = {
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
Expand All @@ -30,6 +34,23 @@ const RSC_HEADERS = {
rsc: '1'
}

const SERIES_MAPPING_PATH = path.join(
process.cwd(),
'data',
'series-mapping.json'
)

function getSeriesMapping(): Record<string, string> {
try {
return JSON.parse(
fs.readFileSync(SERIES_MAPPING_PATH, 'utf8')
) as Record<string, string>
} catch (error) {
console.error(`Failed to load ${SERIES_MAPPING_PATH}`, error)
return {}
}
}

interface BigFinishSearchResult {
id: number
release_slug: string
Expand Down Expand Up @@ -60,6 +81,7 @@ interface ParsedBookData {
duration: string | null
writtenBy: string | null
narratedBy: string | null
tags?: string[]
coverUrl: string | null
isbn: string | null
}
Expand Down Expand Up @@ -157,7 +179,9 @@ export default class BigFinishProvider extends BaseProvider {
if (!releaseData) return null

const titleParts = this.extractTitleParts(releaseData.title || hit.name)
const narrators = this.namesFrom(releaseData.cast?.filter((person) => person.label?.toLowerCase() === 'narrator'))
const narratorNames = this.namesFrom(releaseData.cast?.filter((person) => person.label?.toLowerCase() === 'narrator'))
const narrators = ENABLE_CHARACTERS ? this.formatNarrators(releaseData.cast) : narratorNames
const narratorTags = ENABLE_CHARACTERS ? this.extractNarratorTags(releaseData.cast) : []
const authors = this.namesFrom(releaseData.written_by)
const technicalDetails = releaseData.production_credits?.technical_details as Record<string, unknown> | undefined
const duration =
Expand All @@ -166,18 +190,20 @@ export default class BigFinishProvider extends BaseProvider {
hit.duration
const isbn = technicalDetails?.digital_retail_isbn || technicalDetails?.physical_retail_isbn
const description = this.resolveRscText(rsc, releaseData.about?.summary) || hit.description || null
const about = ENABLE_CHARACTERS ? this.appendContributors(description, releaseData) : description

return {
schemaVersion: 3,
url,
title: releaseData.title || hit.name || null,
series: this.formatSeries(releaseData.range || titleParts.series),
series: ENABLE_SERIES_MAPPING ? this.formatSeries(releaseData.range || titleParts.series) : releaseData.range || titleParts.series,
seriesTag: releaseData.release_number ? String(releaseData.release_number) : titleParts.seriesTag,
releaseDate: releaseData.release_date || null,
about: this.appendContributors(description, releaseData),
about,
duration: duration ? String(duration) : null,
writtenBy: authors.join(', ') || null,
narratedBy: narrators.join(', ') || null,
tags: narratorTags.length > 0 ? narratorTags : undefined,
coverUrl: releaseData.image || hit.image || null,
isbn: typeof isbn === 'string' ? isbn : null
}
Expand Down Expand Up @@ -235,6 +261,47 @@ export default class BigFinishProvider extends BaseProvider {
]
}

private formatNarrators(cast: NamedContributor[] | undefined): string[] {
const narrators = this.namesFrom(cast?.filter((person) => person.label?.toLowerCase() === 'narrator'))
const rolesByName = new Map<string, Set<string>>()

for (const person of cast || []) {
const name = person.name?.trim()
const label = person.label?.trim()
if (!name || !label || label.toLowerCase() === 'narrator') continue

for (const role of this.splitRoleLabels(label)) {
const roles = rolesByName.get(name) ?? new Set<string>()
roles.add(role)
rolesByName.set(name, roles)
}
}

return narrators.map((name) => {
const roles = Array.from(rolesByName.get(name) || []).sort()
return roles.length > 0 ? `${name} (${roles.join(', ')})` : name
})
}

private extractNarratorTags(cast: NamedContributor[] | undefined): string[] {
const tags = new Set<string>()

for (const person of cast || []) {
const label = person.label?.trim()
if (!label || label.toLowerCase() === 'narrator') continue

for (const role of this.splitRoleLabels(label)) {
tags.add(role)
}
}

return [...tags].sort()
}

private splitRoleLabels(label: string): string[] {
return [...new Set(label.split('/').map((role) => role.trim()).filter(Boolean))]
}

private appendContributors(description: string | null, releaseData: BigFinishReleaseData): string | null {
const entries: string[] = []
const add = (role: string, people: NamedContributor[] | undefined) => {
Expand All @@ -259,7 +326,12 @@ export default class BigFinishProvider extends BaseProvider {
}

private formatSeries(series: string | null | undefined): string | null {
return series ? series.replace(/\s*:\s*/g, ' - ') : null
if (!series) return null

const seriesMapping = getSeriesMapping()
const mappedSeries = seriesMapping[series.trim()] ?? series

return mappedSeries.replace(/\s*:\s*/g, ' - ')
}

private escapeHtml(value: string): string {
Expand Down Expand Up @@ -309,8 +381,9 @@ export default class BigFinishProvider extends BaseProvider {
description: data.about,
cover: data.coverUrl,
isbn: data.isbn,
tags: data.tags,
series,
language: 'en',
language: 'Eng',
publishedYear,
publisher: 'Big Finish',
duration
Expand Down