Skip to content

feat(audible): bidirectional Whispersync progress sync#3893

Draft
scootaash wants to merge 18 commits into
music-assistant:devfrom
scootaash:dev
Draft

feat(audible): bidirectional Whispersync progress sync#3893
scootaash wants to merge 18 commits into
music-assistant:devfrom
scootaash:dev

Conversation

@scootaash
Copy link
Copy Markdown

Summary

Implements full bidirectional progress sync between Music Assistant and the
Audible app via Whispersync. My first PR so feedback appreciated

Before: MA always resumed from 0:00 after listening on the Audible phone app
or any other Whispersync-connected device. Write-back (MA → Audible) existed but
was unreliable due to ACR cache lifecycle and a feedback loop that fired redundant
PUT calls every 30 s.

After: Position is shared correctly in both directions.

Read direction (Audible app → MA) — was completely missing

  • sync_progress_from_audible() batch-fetches last-heard positions via
    GET /1.0/annotations/lastpositions for all library audiobooks and writes
    them into MA's internal playlog via mark_item_played().
  • Fires once at cold-start and then hourly via TaskSchedule.hourly(every=1).
  • Registered/unregistered in handle_async_init / unload.
  • get_resume_position() does a live single-ASIN fetch immediately before
    playback so MA always starts from the freshest position; raises
    NotImplementedError on failure so MA falls back to its own playlog.

Write direction (MA → Audible) — reliability fixes

  • Timestamp comparison in _sync_progress_chunk: parses Audible's
    last_updated and compares against MA's playlog row before writing —
    skips the write when MA is already as current or newer.
  • Per-ASIN suppression set (_sync_suppressed_asins): prevents the
    mark_item_playedon_playedset_last_position feedback loop
    without a global flag or asyncio.sleep(0) race.
  • Audible API chunking: enforces both the 500-char query-string limit
    (_MAX_ASINS_PARAM_LEN = 480) and the 25-ASIN per-request limit
    (_MAX_ASINS_PER_REQUEST = 25).
  • get_audible_resume_position() parses Audible's actual last_updated
    timestamp so MA's playlog tiebreaker picks the fresher source correctly.
    Raises NotImplementedError on transport failure for graceful fallback.

Notes

  • Pattern mirrors the Audiobookshelf provider's _update_playlog_book /
    on_played approach.
  • fully_played is always False in sync — the lastpositions endpoint
    does not expose finished-state.
  • _get_user_for_provider is not called; userid=None lets mark_item_played
    resolve the user via its own existing fallback.

Test plan [all completed by scootaash]

  • Listen on Audible phone app, restart MA — verify resume position is correct
  • Play in MA, open Audible app — verify position updated
  • Library of 200+ books — verify sync completes without 400 errors
  • Provider reload — verify no duplicate scheduled tasks in logs

scootaash and others added 18 commits May 14, 2026 09:00
Adds sync_progress_from_audible() to AudibleHelper which batch-reads
last-heard positions from GET /1.0/annotations/lastpositions and pushes
them into MA's internal progress database via mark_item_played(). This
closes the read direction gap confirmed in live testing: MA now resumes
at the correct position even when the previous session happened in the
Audible phone app or another device.

Called from handle_async_init() as a non-blocking background task so
provider initialisation is not delayed. Positions are processed in
chunks of 50 ASINs to keep query strings within safe limits.
…SspT4

feat(audible): sync Audible positions into MA on startup (read direction fix)
Audible enforces a 500-char limit on the comma-joined asins query
parameter value. The previous chunk_size=50 produced ~550 chars per
chunk (50 ASINs × ~11 chars each), causing a 400 Bad Request on every
request in a 272-book library.

Replace count-based chunking with length-based chunking that accumulates
ASINs until the next one would exceed 480 chars (20 char headroom),
then flushes the batch. Also downgrades the per-chunk log from ERROR to
WARNING since a chunk failure is recoverable — other chunks still run.
…ble lastpositions batch calls; fix literal backslash-quotes syntax error in get_audiobooks_by_narrator
…kslash-quote syntax error in get_audiobooks_by_narrator
…t_updated timestamp in get_resume_position; raise NotImplementedError on transport failure; drop dead try/except in unload
…rd; pass user_initiated=False; drop unused UTC import
- guard sync_progress_from_audible against re-entry (cold-start vs scheduled
  first-run race when persisted last_run is in the past)
- pass user_initiated=False to mark_item_played so background sync doesn't
  pose as a user action in the playlog
- narrow _sync_progress_chunk's broad Exception to the same set used by
  set_last_position so real bugs surface
- rename get_resume_position's parameter to item_id to match the base
  MusicProvider signature and every other provider override
- drop unused UTC import from __init__.py
- extract _parse_audible_timestamp to handle ISO-8601, numeric-string, and
  epoch (seconds and ms) timestamp variants observed across marketplaces
- add :param: docstrings to get_resume_position and get_audible_resume_position
Combines the in-flight feasibility-branch fix (00b8de4: schedule interval
hourly(every=1), concurrent-run guard, user_initiated=False, drop unused
UTC import) with the audit corrections branch (3f711f4), which additionally
narrows _sync_progress_chunk's exception handler, renames
get_resume_position's parameter to item_id to match the base
MusicProvress signature and every other provider override, extracts
_parse_audible_timestamp to handle ISO-8601, numeric-string, and epoch
(seconds and milliseconds) timestamp variants, and adds :param: docstrings.

Re-entrancy guard wording resolved to the more explanatory variant from
the audit branch.
…vention, multi-user scoping

- Replace _syncing_from_audible boolean in on_played() with per-ASIN
  _sync_suppressed_asins set. on_played() discards the ASIN on first hit
  and returns; finally block in sync_progress_from_audible() clears any
  remainder. Eliminates the asyncio.sleep(0) race and makes suppression
  per-item rather than global.

- In _sync_progress_chunk(), parse Audible's last_updated timestamp and
  compare it against the existing MA playlog row before calling
  mark_item_played(). Skips the write when MA already has an equal or
  newer timestamp, preventing stale Audible positions from overwriting
  more recent in-session progress.

- Resolve the MA user that owns this provider instance via
  _get_user_for_provider() and thread userid through to mark_item_played()
  and the DB lookup, so background syncs write only to the correct user's
  playlog instead of fanning out to all users on a multi-user server.

- Add DB_TABLE_PLAYLOG import from music_assistant.constants (needed for
  direct playlog timestamp comparison).
…SspT4

Audible bidirectional progress sync (Whispersync read + write)
- Add missing await to _get_user_for_provider call and extract
  .user_id from returned User object (was returning a coroutine,
  silently crashing every sync run and writing nothing)
- Raise NotImplementedError in get_audible_resume_position when
  position_ms == 0 so MA uses its own playlog rather than clobbering
  valid in-session position with a zero from Audible
- Remove bulk _sync_suppressed_asins.clear() from finally block;
  individual discard in on_played() handles per-ASIN cleanup when
  each create_task-scheduled callback fires

These fixes were pushed to claude/audible-sync-feasibility-SspT4
after PR #3 was already merged, so did not land in dev.
…zone handling

- Stale-overwrite check was dead code: get_row used provider_instance
  as the provider key but mark_item_played writes rows with
  media_item.provider which resolves to 'library' for library items.
  Changed to mass_audiobook.provider to match the actual row key.
  Without this fix the timestamp comparison was always skipped and
  every hourly sync overwrote MA's playlog unconditionally.

- Coerce timezone-naive Audible timestamps to UTC before calling
  .timestamp() in both _sync_progress_chunk and
  get_audible_resume_position. Audible uses 'Z' today but this
  prevents incorrect comparisons on non-UTC servers if that changes.

- Add comment on _sync_suppressed_asins.add explaining the narrow
  user-playback race and why it is acceptable.

- Add comment on get_audible_resume_position explaining why
  fully_played is always False (lastpositions payload does not
  carry finished-state).
…rovider call

- Move _syncing_from_audible = True inside the try block so any
  exception during sync setup cannot permanently disable future runs
  by leaving the flag stuck True.

- Remove _get_user_for_provider call entirely. mark_item_played already
  resolves the user via its own internal fallback when userid=None is
  passed, so the audible provider calling the private API added nothing.
  Also avoids the accidental string-iteration behaviour identified in
  audit (passing a bare instance_id str iterates its characters).
…ently played

When the MA web UI is opened via Home Assistant ingress, the WebSocket
session authenticates as the dedicated `homeassistant_system` service
user. Playback initiated through HA-owned players therefore scrobbles
under that shared system identity, not the viewing human user. The
playlog unique key `(item_id, provider, media_type, userid)` pins the
row to `homeassistant_system`, so `in_progress_items()` and
`recently_played()` — which scoped strictly to the current user — never
surface those rows for the human viewer.

Treat `homeassistant_system` as a shared household identity: OR it into
the userid filter alongside the viewing user. No schema changes, no
attribution refactor; the per-user scoping for any non-system user
remains intact.
…elf-8R6P4

fix(shelves): include HA system-user rows in Continue Listening / Recently played
@scootaash scootaash marked this pull request as draft May 15, 2026 17:17
@scootaash scootaash marked this pull request as ready for review May 15, 2026 17:20
@OzGav
Copy link
Copy Markdown
Contributor

OzGav commented May 16, 2026

@ztripez

@OzGav
Copy link
Copy Markdown
Contributor

OzGav commented May 17, 2026

Why are there changes to the music controller in this PR? https://github.com/music-assistant/.github/blob/main/AI_POLICY.md


try:
response = await self._call_api("annotations/lastpositions", asins=asin)
except Exception as exc:
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Broad exception handling should be avoided and instead more narrow exceptions should be used for the errors you'd like to catch. This occurs in various places in this PR

Copy link
Copy Markdown

@MaZZly MaZZly May 18, 2026

Choose a reason for hiding this comment

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

@OzGav I think this could be automated with ruff/lint checks? :)

Either one of these? 🤔
https://docs.astral.sh/ruff/rules/bare-except/
https://docs.astral.sh/ruff/rules/blind-except/

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks I'll pass the suggestion onto the lint gurus!

# correctly. The field is "last_updated" in the Audible annotation payload;
# additional names are tried as a fallback against future API variation.
timestamp: datetime | None = None
for field in ("last_updated", "reported_time", "last_updated_time", "timestamp"):
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This is duplicated in sync_progress_chunk. Please extract to a helper

# mark_item_played writes with media_item.provider which resolves to
# "library" for library items — use mass_audiobook.provider to match.
if audible_ts is not None:
existing = await self.mass.music.database.get_row(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

A provider must not access the database directly mass.music.database is the music controller's internal concern. The controller already handles playlog timestamp comparison in get_resume_position

in progress) are skipped silently.
"""
# Prevent concurrent runs (cold-start task + first scheduled fire can overlap).
if self._syncing_from_audible:
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Consider asyncio.Lock instead

# calling a private API and is equivalent for all supported setups.
userid: str | None = None
asins: list[str] = []
async for item in self._fetch_library_items(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This looks like you are fetching every book just to get a list of ASINs? You can get this from mass.music.audiobooks.library_items() filtered by this provider instance

except Exception as exc:
self.logger.error(f"Unexpected error reporting position for ASIN {asin}: {exc}")

async def get_audible_resume_position(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Why was the music controller's get_last_postion not extended or replaced instead of adding a new method?

media_item is the full media item details of the played/playing track.
"""
if prov_item_id in self.helper._sync_suppressed_asins:
self.helper._sync_suppressed_asins.discard(prov_item_id)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

You are accessing a private attribute here?

async def get_resume_position(
self, prov_item_id: str, media_type: MediaType
) -> tuple[bool, int, datetime | None]:
"""Return resume position from Audible for the given item.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Suggested change
"""Return resume position from Audible for the given item.
"""
Return resume position from Audible for the given item.

For standardisation please ensure all multi line docstrings start on their own line

Comment on lines +603 to +607
Called by MA just before queuing an audiobook for playback.
MA compares the returned timestamp against its internal playlog and
uses whichever source is more recent. Raises NotImplementedError on
transport failure so MA falls back to its own playlog rather than
presenting a stale or missing position as authoritative.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Dosctrings may not contain implementation detail

@OzGav
Copy link
Copy Markdown
Contributor

OzGav commented May 17, 2026

Also lint is failing. Marking as draft so we can keep track of what needs attention. Please mark as ready for review again when you want another look.

@OzGav OzGav marked this pull request as draft May 17, 2026 23:17
@scootaash
Copy link
Copy Markdown
Author

Many thanks for the detailed comments. Will take a look. Cheers

@MarvinSchenkel
Copy link
Copy Markdown
Contributor

Marking this PR as draft so we can keep track of which PRs needs our attention. Please mark as 'Ready for review' when you want us to have another look 🙏 .

@OzGav OzGav added this to the 2.10.0 milestone May 22, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants