Add initial implementation of Audible Series Checker with API connectors and configuration

This commit is contained in:
Yunn Xairou 2025-08-23 14:57:12 +02:00
commit 223bfbf6bc
10 changed files with 630 additions and 0 deletions

3
.env.example Normal file
View file

@ -0,0 +1,3 @@
AUDIBLE_AUTH_FILE=".auth"
ABS_API_URL="your_abs_api_url"
ABS_API_TOKEN="your_abs_api_token"

207
.gitignore vendored Normal file
View file

@ -0,0 +1,207 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[codz]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py.cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# UV
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
#uv.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
#poetry.toml
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
# https://pdm-project.org/en/latest/usage/project/#working-with-version-control
#pdm.lock
#pdm.toml
.pdm-python
.pdm-build/
# pixi
# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
#pixi.lock
# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
# in the .venv directory. It is recommended not to include this directory in version control.
.pixi
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.envrc
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
# Abstra
# Abstra is an AI-powered process automation framework.
# Ignore directories containing user credentials, local state, and settings.
# Learn more at https://abstra.io/docs
.abstra/
# Visual Studio Code
# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
# and can be added to the global gitignore or merged into this file. However, if you prefer,
# you could uncomment the following to ignore the entire vscode folder
.vscode/
# Ruff stuff:
.ruff_cache/
# PyPI configuration file
.pypirc
# Marimo
marimo/_static/
marimo/_lsp/
__marimo__/
# Streamlit
.streamlit/secrets.toml
.auth
dumps/
log

9
config.py Normal file
View file

@ -0,0 +1,9 @@
import os
from dotenv import load_dotenv
load_dotenv()
ABS_API_URL = os.environ.get("ABS_API_URL")
ABS_API_TOKEN = os.environ.get("ABS_API_TOKEN")
AUDIBLE_AUTH_FILE = os.environ.get("AUDIBLE_AUTH_FILE")

12
connectors/__init__.py Normal file
View file

@ -0,0 +1,12 @@
from .abs_connector import ABSConnector, ABSConnectorMock
from .audible_connector import AudibleConnector, AudibleConnectorMock
from .audnexus_connector import AudNexusConnector, AudNexusConnectorMock
__all__ = [
ABSConnector,
ABSConnectorMock,
AudibleConnector,
AudibleConnectorMock,
AudNexusConnector,
AudNexusConnectorMock,
]

View file

@ -0,0 +1,61 @@
import requests
import json
class ABSConnector:
def __init__(self, abs_url, token=None):
self.abs_url = abs_url
self.requests = requests.Session()
self.requests.headers = {"Authorization": f"Bearer {token}"}
def get_library_ids(self):
endpoint = f"{self.abs_url}/api/libraries"
response = self.requests.get(endpoint)
response.raise_for_status()
data = response.json()
return data["libraries"]
def get_series_by_library_id(self, library_id, page_size=100):
endpoint = f"{self.abs_url}/api/libraries/{library_id}/series"
page = 0
while True:
response = self.requests.get(
endpoint,
params={
"limit": page_size,
"page": page,
"minified": 1,
"sort": "name",
},
)
response.raise_for_status()
data = response.json()
yield from data["results"]
page += 1
if data["total"] < page_size * page: # Stop if no more data
break
class ABSConnectorMock(ABSConnector):
def get_library_ids(self):
with open("dumps/libraries.json", "r") as f:
data = json.load(f)
return data["libraries"]
def get_series_by_library_id(self, library_id, page_size=100):
page = 0
while True:
with open(f"dumps/library_{library_id}.page{page}.json", "r") as f:
data = json.load(f)
yield from data["results"]
page += 1
if data["total"] < page_size * page: # Stop if no more data
break

View file

@ -0,0 +1,56 @@
import os
import audible
import json
from getpass import getpass
class AudibleConnector:
def __init__(self, authFile):
self.client: audible.Client = None
self._setup_auth(authFile)
def __del__(self):
if self.client:
self.client.close()
def _setup_auth(self, authFile=None):
try:
if authFile and os.path.exists(authFile):
self.auth = audible.Authenticator.from_file(authFile)
else:
self.auth = audible.Authenticator.from_login(
input("Username "),
getpass("Password "),
locale="us",
with_username=False,
)
if authFile:
self.auth.to_file(authFile)
except (
OSError,
audible.exceptions.AuthFlowError,
) as e:
print(f"Authentication failed: {e}")
raise ConnectionError(f"Failed to authenticate: {e}")
self.client = audible.Client(self.auth)
def get_produce_from_asin(self, asin):
endpoint = f"1.0/catalog/products/{asin}"
response = self.client.get(
endpoint, response_groups="series, relationships, product_attrs"
)
return response["product"]
class AudibleConnectorMock(AudibleConnector):
def get_produce_from_asin(self, asin):
try:
with open(f"dumps/products_{asin}.json", "r") as f:
data = json.load(f)
return data["product"]
except FileNotFoundError:
data = AudibleConnector.get_produce_from_asin(self, asin)
with open(f"dumps/products_{asin}.json", "w+") as f:
json.dump({"product": data}, f, indent=4)
return data

View file

@ -0,0 +1,29 @@
from ratelimit import limits
import requests
import json
class AudNexusConnector:
@limits(calls=100, period=60)
def request(self, url):
return requests.get(url, {"update": 0, "seedAuthors": 0})
def get_book_from_asin(self, book_asin):
endpoint = f"https://api.audnex.us/books/{book_asin}"
response = self.request(endpoint)
response.raise_for_status()
return response.json()
class AudNexusConnectorMock(AudNexusConnector):
def get_book_from_asin(self, book_asin):
try:
with open(f"dumps/book_{book_asin}.json", "r") as f:
data = json.load(f)
return data
except FileNotFoundError:
data = AudNexusConnector.get_book_from_asin(self, book_asin)
with open(f"dumps/book_{book_asin}.json", "w+") as f:
json.dump(data, f, indent=4)
return data

207
main.py Normal file
View file

@ -0,0 +1,207 @@
import connectors
import logging
import config
logging.basicConfig(
filename="log",
filemode="w",
level=logging.INFO,
format="%(levelname)s - %(message)s",
)
class Book(dict):
def __init__(self, asin=""):
self.asin = asin
self.title = ""
self.series = dict()
def __bool__(self):
return self.asin != ""
def __repr__(self):
return f"Book(asin='{self.asin}', series='{self.series}')"
class BookCollection(dict):
def __init__(self, series_name, books: list[Book] = []):
self.__name__ = series_name
for book in books:
self.add(book)
def add(self, book: Book):
sequence = book.series[self.__name__]
keys = expand_range(sequence)
for key in keys:
self.setdefault(key, [])
self[key].append(book.asin)
def get_first_book(self):
firt_key = list(self.keys())[0]
return self[firt_key][0]
def __bool__(self):
return len(self.keys()) > 0 and self.get_first_book() != None
def expand_range(part):
"""Expands a range string (e.g., "1-10") or float sequence into a list of numbers."""
try:
if "-" in part and not part.startswith("-"):
start, end = map(int, part.split("-"))
if start >= end:
return [] # Handle invalid ranges (start >= end)
return list(range(start, end + 1))
else:
float_val = float(part)
return [float_val]
except ValueError:
return [] # Handle non-numeric input or invalid format
def process_sequence(books):
"""Groups books by ASIN, handling sequence ranges (including floats)."""
books_sequence = {}
for book in books:
asin = book["asin"]
sequence = book.get("sequence", "")
if sequence:
keys = expand_range(sequence.split(", ")[0])
else:
keys = [float(book.get("sort", "1")) * -1]
for key in keys:
if key not in books_sequence:
books_sequence[key] = []
books_sequence[key].append(asin)
keys = sorted(books_sequence.keys(), key=lambda x: float(x))
ordered_sequence = {}
for key in keys:
ordered_sequence[key] = books_sequence[key]
return ordered_sequence
def process_audible_serie(books, serie_name):
processed_books = BookCollection(serie_name)
for json in books:
if book["relationship_type"] == "series":
book = Book(json["asin"])
book.series.setdefault(serie_name, json["sequence"])
book.series.setdefault(serie_name, f"-{json['sort']}")
processed_books.add(book)
return processed_books
def process_abs_serie(books, series_name):
processed_books = BookCollection(series_name)
for index, json in enumerate(books):
meta = json["media"]["metadata"]
if meta["asin"] == None:
logger.debug(
"ASIN missing: %s (%s by %s)",
meta["title"],
series_name,
meta["authorName"],
)
book = Book(meta["asin"])
for name in meta["seriesName"].split(", "):
try:
[name, sequence] = name.split(" #")
except ValueError:
logger.debug("Serie missing sequence: %s (%s)", meta["title"], name)
sequence = f"-{index + 1}"
book.series[name] = sequence
processed_books.add(book)
return processed_books
def get_serie_asin(first_book_asin, series_name):
audnexus_first_book = audnexus.get_book_from_asin(first_book_asin)
primary = audnexus_first_book.get("seriesPrimary", {"name": ""})
secondary = audnexus_first_book.get("seriesSecondary", {"name": ""})
if primary["name"].casefold() == series_name.casefold():
return primary["asin"]
elif secondary["name"].casefold() == series_name.casefold():
return secondary["asin"]
else:
audible_first_book = audible.get_produce_from_asin(first_book_asin)
if "series" not in audible_first_book:
return None
series = audible_first_book.get("series", [])
series_matching_sequence = [x for x in series if x["sequence"] == "1"]
if len(series_matching_sequence) == 1:
return series_matching_sequence[0]["asin"]
# TODO: search by name
return series[0]["asin"]
def main():
libraries = abs.get_library_ids()
for library in libraries:
series = abs.get_series_by_library_id(library["id"])
for serie in series:
series_name = serie["name"]
abs_book_sequence = process_abs_serie(serie["books"], series_name)
if not abs_book_sequence:
logger.debug("No ASINs found for series: %s", series_name)
continue
first_book_asin = abs_book_sequence.get_first_book()
series_asin = get_serie_asin(first_book_asin, series_name)
if not series_asin:
logger.debug("Serie does not exist: %s", series_name)
continue
audible_serie = audible.get_produce_from_asin(series_asin)
audible_book_sequence = process_sequence(audible_serie["relationships"])
if len(abs_book_sequence) >= len(audible_book_sequence):
continue
logger.info(
"%s - %d out of %d",
series_name,
len(abs_book_sequence),
len(audible_book_sequence),
)
# TODO: list missing tomes and show their delivery date if not yet out
# TODO: add input to choose which library is to be scaned
break
if __name__ == "__main__":
logging.getLogger("httpx").setLevel(logging.WARNING)
logging.getLogger("audible").setLevel(logging.WARNING)
logging.getLogger("urllib3").setLevel(logging.WARNING)
logging.getLogger("httpcore").setLevel(logging.WARNING)
logger = logging.getLogger(__name__)
abs = connectors.ABSConnector(config.ABS_API_URL, config.ABS_API_TOKEN)
audible = connectors.AudibleConnector(config.AUDIBLE_AUTH_FILE)
audnexus = connectors.AudNexusConnector()
main()

46
readme.md Normal file
View file

@ -0,0 +1,46 @@
# Audible Series Checker
Audible Series Checker is a Python tool for comparing audiobook series between your ABS library and Audible, helping you track missing tomes and release dates.
## Features
- Connects to ABS and Audible APIs
- Compares series and book sequences
- Identifies missing books in your library
- Supports rate-limited requests and authentication
- Logs results and errors
## Requirements
See [requirements.txt](requirements.txt) for dependencies. Install with:
```sh
pip install -r requirements.txt
```
## Configuration
Copy [.env.example](.env.example) to `.env` and fill in your credentials:
## Usage
Run the main script:
```sh
python main.py
```
Logs are written to the `log` file.
## Project Structure
- [main.py](main.py): Entry point and main logic
- [config.py](config.py): Loads environment variables
- [connectors/](connectors/): API connectors for ABS, Audible, and AudNexus
- [requirements.txt](requirements.txt): Python dependencies
## Development
- Mock connectors are available for testing (see `*Mock` classes in [connectors/](connectors/))
- Logging is configured in [main.py](main.py)
- Environment variables are loaded via [config.py](config.py)

BIN
requirements.txt Normal file

Binary file not shown.