commit 223bfbf6bc58d8557ee3a0c4fbda98a95ddeec0f Author: Yunn Xairou Date: Sat Aug 23 14:57:12 2025 +0200 Add initial implementation of Audible Series Checker with API connectors and configuration diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..b15ecc5 --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +AUDIBLE_AUTH_FILE=".auth" +ABS_API_URL="your_abs_api_url" +ABS_API_TOKEN="your_abs_api_token" \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2d743ed --- /dev/null +++ b/.gitignore @@ -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 \ No newline at end of file diff --git a/config.py b/config.py new file mode 100644 index 0000000..0b9eb42 --- /dev/null +++ b/config.py @@ -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") diff --git a/connectors/__init__.py b/connectors/__init__.py new file mode 100644 index 0000000..8a13a9e --- /dev/null +++ b/connectors/__init__.py @@ -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, +] diff --git a/connectors/abs_connector.py b/connectors/abs_connector.py new file mode 100644 index 0000000..ce8f5bd --- /dev/null +++ b/connectors/abs_connector.py @@ -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 diff --git a/connectors/audible_connector.py b/connectors/audible_connector.py new file mode 100644 index 0000000..9f148fa --- /dev/null +++ b/connectors/audible_connector.py @@ -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 diff --git a/connectors/audnexus_connector.py b/connectors/audnexus_connector.py new file mode 100644 index 0000000..b48c89f --- /dev/null +++ b/connectors/audnexus_connector.py @@ -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 diff --git a/main.py b/main.py new file mode 100644 index 0000000..64cede8 --- /dev/null +++ b/main.py @@ -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() diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..dcc3aa2 --- /dev/null +++ b/readme.md @@ -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) \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..d6f640e Binary files /dev/null and b/requirements.txt differ