fix: abs - discarded progress (#2598)
authorFabian Munkes <105975993+fmunkes@users.noreply.github.com>
Tue, 4 Nov 2025 22:07:39 +0000 (23:07 +0100)
committerGitHub <noreply@github.com>
Tue, 4 Nov 2025 22:07:39 +0000 (23:07 +0100)
music_assistant/controllers/music.py
music_assistant/providers/audiobookshelf/__init__.py

index 29493bc05686c857c8b0b5b6bf3bdcd4c6aa36f1..8084501b7cccb825dabc0c0504b728fc50fcf653 100644 (file)
@@ -517,6 +517,40 @@ class MusicController(CoreController):
             )
         return result
 
+    async def get_playlog_provider_item_ids(
+        self, provider_instance_id: str, limit: int = 0
+    ) -> list[tuple[MediaType, str]]:
+        """Return a list of MediaType and provider_item_id of items in playlog of provider."""
+        query = (
+            f"SELECT * FROM {DB_TABLE_PLAYLOG} "
+            "WHERE media_type in ('audiobook', 'podcast_episode') "
+            f"AND provider in ('library','{provider_instance_id}')"
+        )
+        assert self.mass.music.database is not None  # for type checking
+        db_rows = await self.mass.music.database.get_rows_from_query(query, limit=limit)
+
+        result: list[tuple[MediaType, str]] = []
+        for db_row in db_rows:
+            if db_row["provider"] == "library":
+                # If the provider is library, we need to make sure that the item
+                # is part of the passed provider_instance_id.
+                # A podcast_episode cannot be in the provider_mappings
+                # so these entries must be audiobooks.
+                subquery = (
+                    f"SELECT * FROM {DB_TABLE_PROVIDER_MAPPINGS} "
+                    f"WHERE media_type = 'audiobook' AND item_id = {db_row['item_id']} "
+                    f"AND provider_instance = '{provider_instance_id}'"
+                )
+                subrow = await self.mass.music.database.get_rows_from_query(subquery)
+                if len(subrow) != 1:
+                    continue
+                result.append((MediaType.AUDIOBOOK, subrow[0]["provider_item_id"]))
+                continue
+            # non library - item id is provider_item_id
+            result.append((MediaType(db_row["media_type"]), db_row["item_id"]))
+
+        return result
+
     @api_command("music/item_by_uri")
     async def get_item_by_uri(self, uri: str) -> MediaItemType | BrowseFolder:
         """Fetch MediaItem by uri."""
index 35f16ef4013dd7818d1d8abd5d2b043fc9aefe25..8fe35228da73a223b9ab892542c4e5c19c0fa821 100644 (file)
@@ -6,6 +6,7 @@ import functools
 import itertools
 import time
 from collections.abc import AsyncGenerator, Callable, Coroutine, Sequence
+from contextlib import suppress
 from typing import TYPE_CHECKING, Any, ParamSpec, TypeVar, cast
 
 import aioaudiobookshelf as aioabs
@@ -1430,8 +1431,17 @@ for more details.
         __updated_items = 0
 
         known_ids = self._get_all_known_item_ids()
+        abs_ids_with_progress = set()
 
         for progress in progresses:
+            # save progress ids for later
+            ma_item_id = (
+                progress.library_item_id
+                if progress.episode_id is None
+                else f"{progress.library_item_id} {progress.episode_id}"
+            )
+            abs_ids_with_progress.add(ma_item_id)
+
             # Guard. Also makes sure, that we don't write to db again if no state change happened.
             # This is achieved by adding a Helper Progress in the update playlog functions, which
             # then has the most recent timestamp. If a subsequent progress sent by abs has an older
@@ -1450,6 +1460,32 @@ for more details.
                 await self._update_playlog_episode(progress)
         self.logger.debug(f"Updated {__updated_items} from full playlog.")
 
+        # Get MA's known progresses of ABS.
+        # In ABS the user may discard a progress, which removes the progress completely.
+        # There is no socket notification for this event.
+        ma_playlog_state = await self.mass.music.get_playlog_provider_item_ids(
+            provider_instance_id=self.instance_id
+        )
+        ma_ids_with_progress = {x for _, x in ma_playlog_state}
+        discarded_progress_ids = ma_ids_with_progress.difference(abs_ids_with_progress)
+        for discarded_progress_id in discarded_progress_ids:
+            if len(discarded_progress_id.split(" ")) == 1:
+                if discarded_item := await self.mass.music.get_library_item_by_prov_id(
+                    media_type=MediaType.AUDIOBOOK,
+                    item_id=discarded_progress_id,
+                    provider_instance_id_or_domain=self.lookup_key,
+                ):
+                    self.progress_guard.add_progress(discarded_progress_id)
+                    await self.mass.music.mark_item_unplayed(discarded_item)
+            else:
+                with suppress(MediaNotFoundError):
+                    discarded_item = await self.get_podcast_episode(
+                        prov_episode_id=discarded_progress_id, add_progress=False
+                    )
+                    self.progress_guard.add_progress(*discarded_progress_id.split(" "))
+                    await self.mass.music.mark_item_unplayed(discarded_item)
+            self.logger.debug("Discarded item %s ", discarded_progress_id)
+
     async def _update_playlog_book(self, progress: MediaProgress) -> None:
         # helper progress also ensures no useless progress updates,
         # see comment above