Add initial implementation of Audible Series Checker with API connectors and configuration
This commit is contained in:
commit
223bfbf6bc
10 changed files with 630 additions and 0 deletions
3
.env.example
Normal file
3
.env.example
Normal 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
207
.gitignore
vendored
Normal 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
9
config.py
Normal 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
12
connectors/__init__.py
Normal 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,
|
||||||
|
]
|
||||||
61
connectors/abs_connector.py
Normal file
61
connectors/abs_connector.py
Normal 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
|
||||||
56
connectors/audible_connector.py
Normal file
56
connectors/audible_connector.py
Normal 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
|
||||||
29
connectors/audnexus_connector.py
Normal file
29
connectors/audnexus_connector.py
Normal 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
207
main.py
Normal 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
46
readme.md
Normal 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
BIN
requirements.txt
Normal file
Binary file not shown.
Loading…
Add table
Add a link
Reference in a new issue