Allow music providers to provide the "date_added" field to library items (#2920)
authorMarcel van der Veldt <m.vanderveldt@outlook.com>
Sun, 4 Jan 2026 11:12:50 +0000 (12:12 +0100)
committerGitHub <noreply@github.com>
Sun, 4 Jan 2026 11:12:50 +0000 (12:12 +0100)
* Parse date_added into the library database

* Process date_added in sync

* Add date_added to filesystem provider

* fix for mashumaro

---------

Co-authored-by: Fabian Munkes <105975993+fmunkes@users.noreply.github.com>
12 files changed:
music_assistant/controllers/media/albums.py
music_assistant/controllers/media/artists.py
music_assistant/controllers/media/audiobooks.py
music_assistant/controllers/media/base.py
music_assistant/controllers/media/playlists.py
music_assistant/controllers/media/podcasts.py
music_assistant/controllers/media/radio.py
music_assistant/controllers/media/tracks.py
music_assistant/helpers/database.py
music_assistant/models/music_provider.py
music_assistant/providers/filesystem_local/__init__.py
music_assistant/providers/filesystem_local/helpers.py

index c399c48739436ec5ae2f695c50303727b6822d98..5c463097e90b2885cd3733349799a06c1dd53ee8 100644 (file)
@@ -28,6 +28,7 @@ from music_assistant.helpers.compare import (
     create_safe_string,
     loose_compare_strings,
 )
+from music_assistant.helpers.database import UNSET
 from music_assistant.helpers.json import serialize_to_json
 from music_assistant.models.music_provider import MusicProvider
 
@@ -390,6 +391,7 @@ class AlbumsController(MediaControllerBase[Album]):
                 "external_ids": serialize_to_json(item.external_ids),
                 "search_name": create_safe_string(item.name, True, True),
                 "search_sort_name": create_safe_string(item.sort_name or "", True, True),
+                "timestamp_added": int(item.date_added.timestamp()) if item.date_added else UNSET,
             },
         )
         # update/set provider_mappings table
@@ -428,6 +430,9 @@ class AlbumsController(MediaControllerBase[Album]):
                 ),
                 "search_name": create_safe_string(name, True, True),
                 "search_sort_name": create_safe_string(sort_name or "", True, True),
+                "timestamp_added": int(update.date_added.timestamp())
+                if update.date_added
+                else UNSET,
             },
         )
         # update/set provider_mappings table
index 91566270d18d970bc4552af66f08c630244257c7..9f3b878f3f96881fb82efeb7a2750f7d204352c3 100644 (file)
@@ -23,6 +23,7 @@ from music_assistant.constants import (
 )
 from music_assistant.controllers.media.base import MediaControllerBase
 from music_assistant.helpers.compare import compare_artist, compare_strings, create_safe_string
+from music_assistant.helpers.database import UNSET
 from music_assistant.helpers.json import serialize_to_json
 
 if TYPE_CHECKING:
@@ -324,6 +325,7 @@ class ArtistsController(MediaControllerBase[Artist]):
                 "metadata": serialize_to_json(item.metadata),
                 "search_name": create_safe_string(item.name, True, True),
                 "search_sort_name": create_safe_string(item.sort_name or "", True, True),
+                "timestamp_added": int(item.date_added.timestamp()) if item.date_added else UNSET,
             },
         )
         # update/set provider_mappings table
@@ -367,6 +369,9 @@ class ArtistsController(MediaControllerBase[Artist]):
                 "metadata": serialize_to_json(metadata),
                 "search_name": create_safe_string(name, True, True),
                 "search_sort_name": create_safe_string(sort_name or "", True, True),
+                "timestamp_added": int(update.date_added.timestamp())
+                if update.date_added
+                else UNSET,
             },
         )
         self.logger.debug("updated %s in database: %s", update.name, db_id)
index d908d3a7ff9a4c05bf9a93b4afe0dab2394a485f..36035fd8b9a42af5bd397bdadfad90facbd444ba 100644 (file)
@@ -15,6 +15,7 @@ from music_assistant.helpers.compare import (
     create_safe_string,
     loose_compare_strings,
 )
+from music_assistant.helpers.database import UNSET
 from music_assistant.helpers.datetime import utc_timestamp
 from music_assistant.helpers.json import serialize_to_json
 from music_assistant.models.music_provider import MusicProvider
@@ -148,6 +149,7 @@ class AudiobooksController(MediaControllerBase[Audiobook]):
                 "duration": item.duration,
                 "search_name": create_safe_string(item.name, True, True),
                 "search_sort_name": create_safe_string(item.sort_name or "", True, True),
+                "timestamp_added": int(item.date_added.timestamp()) if item.date_added else UNSET,
             },
         )
         # update/set provider_mappings table
@@ -187,6 +189,9 @@ class AudiobooksController(MediaControllerBase[Audiobook]):
                 "duration": update.duration if overwrite else cur_item.duration or update.duration,
                 "search_name": create_safe_string(name, True, True),
                 "search_sort_name": create_safe_string(sort_name or "", True, True),
+                "timestamp_added": int(update.date_added.timestamp())
+                if update.date_added
+                else UNSET,
             },
         )
         # update/set provider_mappings table
index 01d0915db748418d26857934866affb98e0b2c56..2a895d85b99d74c088096b91126e8ca151abbd90 100644 (file)
@@ -7,6 +7,7 @@ import logging
 from abc import ABCMeta, abstractmethod
 from collections.abc import Iterable
 from contextlib import suppress
+from datetime import datetime
 from typing import TYPE_CHECKING, Any, TypeVar, cast, final
 
 from music_assistant_models.enums import EventType, ExternalID, MediaType, ProviderFeature
@@ -942,6 +943,9 @@ class MediaControllerBase[ItemCls: "MediaItemType"](metaclass=ABCMeta):
         db_row_dict["provider"] = "library"
         db_row_dict["favorite"] = bool(db_row_dict["favorite"])
         db_row_dict["item_id"] = str(db_row_dict["item_id"])
+        db_row_dict["date_added"] = datetime.fromtimestamp(
+            db_row_dict["timestamp_added"]
+        ).isoformat()
 
         for key in JSON_KEYS:
             if key not in db_row_dict:
index e658a3cc9533bba466c46cfaeb753ec44a0af612..528fe032029a545536e6a6412700906d571165ea 100644 (file)
@@ -16,6 +16,7 @@ from music_assistant_models.media_items import Playlist, Track
 
 from music_assistant.constants import DB_TABLE_PLAYLISTS
 from music_assistant.helpers.compare import create_safe_string
+from music_assistant.helpers.database import UNSET
 from music_assistant.helpers.json import serialize_to_json
 from music_assistant.helpers.uri import create_uri, parse_uri
 from music_assistant.helpers.util import guard_single_request
@@ -371,6 +372,7 @@ class PlaylistController(MediaControllerBase[Playlist]):
                 "external_ids": serialize_to_json(item.external_ids),
                 "search_name": create_safe_string(item.name, True, True),
                 "search_sort_name": create_safe_string(item.sort_name or "", True, True),
+                "timestamp_added": int(item.date_added.timestamp()) if item.date_added else UNSET,
             },
         )
         # update/set provider_mappings table
@@ -404,6 +406,9 @@ class PlaylistController(MediaControllerBase[Playlist]):
                 ),
                 "search_name": create_safe_string(name, True, True),
                 "search_sort_name": create_safe_string(sort_name or "", True, True),
+                "timestamp_added": int(update.date_added.timestamp())
+                if update.date_added
+                else UNSET,
             },
         )
         # update/set provider_mappings table
index 8361ec23d0c53fbbc2f9316e8373a1e106445a9e..a247c0dce12b9fc103a03b4302cb8bddba5dc218 100644 (file)
@@ -17,6 +17,7 @@ from music_assistant.helpers.compare import (
     create_safe_string,
     loose_compare_strings,
 )
+from music_assistant.helpers.database import UNSET
 from music_assistant.helpers.json import serialize_to_json
 from music_assistant.models.music_provider import MusicProvider
 
@@ -156,6 +157,7 @@ class PodcastsController(MediaControllerBase[Podcast]):
                 "total_episodes": item.total_episodes or 0,
                 "search_name": create_safe_string(item.name, True, True),
                 "search_sort_name": create_safe_string(item.sort_name or "", True, True),
+                "timestamp_added": int(item.date_added.timestamp()) if item.date_added else UNSET,
             },
         )
         # update/set provider_mappings table
@@ -188,6 +190,9 @@ class PodcastsController(MediaControllerBase[Podcast]):
                 "total_episodes": cur_item.total_episodes or update.total_episodes or 0,
                 "search_name": create_safe_string(name, True, True),
                 "search_sort_name": create_safe_string(sort_name or "", True, True),
+                "timestamp_added": int(update.date_added.timestamp())
+                if update.date_added
+                else UNSET,
             },
         )
         # update/set provider_mappings table
index d5e902a8d2dee9192751e72c718de95e9d75eb5b..28d38b47e3ed67222a0dbecae69f7eabe3ce7ce2 100644 (file)
@@ -15,6 +15,7 @@ from music_assistant.helpers.compare import (
     create_safe_string,
     loose_compare_strings,
 )
+from music_assistant.helpers.database import UNSET
 from music_assistant.helpers.json import serialize_to_json
 from music_assistant.models.music_provider import MusicProvider
 
@@ -79,6 +80,7 @@ class RadioController(MediaControllerBase[Radio]):
                 "search_sort_name": create_safe_string(
                     item.sort_name if item.sort_name is not None else "", True, True
                 ),
+                "timestamp_added": int(item.date_added.timestamp()) if item.date_added else UNSET,
             },
         )
         # update/set provider_mappings table
@@ -111,6 +113,9 @@ class RadioController(MediaControllerBase[Radio]):
                 ),
                 "search_name": create_safe_string(name, True, True),
                 "search_sort_name": create_safe_string(sort_name or "", True, True),
+                "timestamp_added": int(update.date_added.timestamp())
+                if update.date_added
+                else UNSET,
             },
         )
         # update/set provider_mappings table
index 542ff2b44954c1301d32a833c2de917ba16c25f0..5e9124994599c588308fdfd520ff70c873412017 100644 (file)
@@ -34,6 +34,7 @@ from music_assistant.helpers.compare import (
     create_safe_string,
     loose_compare_strings,
 )
+from music_assistant.helpers.database import UNSET
 from music_assistant.helpers.json import serialize_to_json
 from music_assistant.models.music_provider import MusicProvider
 
@@ -523,6 +524,7 @@ class TracksController(MediaControllerBase[Track]):
                 "metadata": serialize_to_json(item.metadata),
                 "search_name": create_safe_string(item.name, True, True),
                 "search_sort_name": create_safe_string(item.sort_name or "", True, True),
+                "timestamp_added": int(item.date_added.timestamp()) if item.date_added else UNSET,
             },
         )
         # update/set provider_mappings table
@@ -564,6 +566,9 @@ class TracksController(MediaControllerBase[Track]):
                 ),
                 "search_name": create_safe_string(name, True, True),
                 "search_sort_name": create_safe_string(sort_name or "", True, True),
+                "timestamp_added": int(update.date_added.timestamp())
+                if update.date_added
+                else UNSET,
             },
         )
         # update/set provider_mappings table
index e56124ccd33f5d888b541021a73c86fcb7b0977e..f0bb90cff3cb809cdd1484a946c8105637586a1e 100644 (file)
@@ -20,6 +20,29 @@ if TYPE_CHECKING:
 
 LOGGER = logging.getLogger(f"{MASS_LOGGER_NAME}.database")
 
+
+class _UnsetType:
+    """Sentinel value to indicate a field should use the database default."""
+
+    _instance: _UnsetType | None = None
+
+    def __new__(cls) -> _UnsetType:
+        """Create singleton instance."""
+        if cls._instance is None:
+            cls._instance = super().__new__(cls)
+        return cls._instance
+
+    def __repr__(self) -> str:
+        """Return string representation."""
+        return "UNSET"
+
+    def __bool__(self) -> bool:
+        """Return False for boolean context."""
+        return False
+
+
+UNSET: _UnsetType = _UnsetType()
+
 ENABLE_DEBUG = os.environ.get("PYTHONDEVMODE") == "1"
 
 
@@ -188,6 +211,8 @@ class DatabaseConnection:
         allow_replace: bool = False,
     ) -> int:
         """Insert data in given table."""
+        # Filter out UNSET values so database defaults are used
+        values = {k: v for k, v in values.items() if v is not UNSET}
         keys = tuple(values.keys())
         if allow_replace:
             sql_query = f"INSERT OR REPLACE INTO {table}({','.join(keys)})"
@@ -206,6 +231,8 @@ class DatabaseConnection:
 
     async def upsert(self, table: str, values: dict[str, Any]) -> None:
         """Upsert data in given table."""
+        # Filter out UNSET values so database defaults are used
+        values = {k: v for k, v in values.items() if v is not UNSET}
         keys = tuple(values.keys())
         sql_query = (
             f"INSERT INTO {table}({','.join(keys)}) VALUES ({','.join(f':{x}' for x in keys)})"
@@ -221,6 +248,8 @@ class DatabaseConnection:
         values: dict[str, Any],
     ) -> Mapping[str, Any]:
         """Update record."""
+        # Filter out UNSET values so those fields are not updated
+        values = {k: v for k, v in values.items() if v is not UNSET}
         keys = tuple(values.keys())
         sql_query = f"UPDATE {table} SET {','.join(f'{x}=:{x}' for x in keys)} WHERE "
         sql_query += " AND ".join(f"{x} = :{x}" for x in match)
index f0d83775e2213f1b31c90d1eaf015797ac408a04..9a9e00e1442efa2d830eae5d0215d39408c91c31 100644 (file)
@@ -757,6 +757,11 @@ class MusicProvider(Provider):
                     library_item = await self.mass.music.artists.update_item_in_library(
                         library_item.item_id, prov_item
                     )
+                elif prov_item.date_added and library_item.date_added != prov_item.date_added:
+                    # update date_added if it changed
+                    library_item = await self.mass.music.artists.update_item_in_library(
+                        library_item.item_id, prov_item
+                    )
                 if not library_item.favorite and prov_item.favorite:
                     # existing library item not favorite but should be
                     await self.mass.music.artists.set_favorite(library_item.item_id, True)
@@ -794,6 +799,11 @@ class MusicProvider(Provider):
                     library_item = await self.mass.music.albums.update_item_in_library(
                         library_item.item_id, prov_item
                     )
+                elif prov_item.date_added and library_item.date_added != prov_item.date_added:
+                    # update date_added if it changed
+                    library_item = await self.mass.music.albums.update_item_in_library(
+                        library_item.item_id, prov_item
+                    )
                 if not library_item.favorite and prov_item.favorite:
                     # existing library item not favorite but should be
                     await self.mass.music.albums.set_favorite(library_item.item_id, True)
@@ -858,10 +868,14 @@ class MusicProvider(Provider):
                     library_item = await self.mass.music.audiobooks.update_item_in_library(
                         library_item.item_id, prov_item
                     )
+                elif prov_item.date_added and library_item.date_added != prov_item.date_added:
+                    # update date_added if it changed
+                    library_item = await self.mass.music.audiobooks.update_item_in_library(
+                        library_item.item_id, prov_item
+                    )
                 if not library_item.favorite and prov_item.favorite:
                     # existing library item not favorite but should be
                     await self.mass.music.audiobooks.set_favorite(library_item.item_id, True)
-
                 # check if resume_position_ms or fully_played changed
                 if (
                     prov_item.resume_position_ms is not None
@@ -909,10 +923,14 @@ class MusicProvider(Provider):
                     library_item = await self.mass.music.playlists.update_item_in_library(
                         library_item.item_id, prov_item
                     )
+                elif prov_item.date_added and library_item.date_added != prov_item.date_added:
+                    # update date_added if it changed
+                    library_item = await self.mass.music.playlists.update_item_in_library(
+                        library_item.item_id, prov_item
+                    )
                 if not library_item.favorite and prov_item.favorite:
                     # existing library item not favorite but should be
                     await self.mass.music.playlists.set_favorite(library_item.item_id, True)
-
                 cur_db_ids.add(int(library_item.item_id))
                 await asyncio.sleep(0)  # yield to eventloop
                 # optionally sync playlist tracks
@@ -985,10 +1003,14 @@ class MusicProvider(Provider):
                     library_item = await self.mass.music.tracks.update_item_in_library(
                         library_item.item_id, prov_item
                     )
+                elif prov_item.date_added and library_item.date_added != prov_item.date_added:
+                    # update date_added if it changed
+                    library_item = await self.mass.music.tracks.update_item_in_library(
+                        library_item.item_id, prov_item
+                    )
                 if not library_item.favorite and prov_item.favorite:
                     # existing library item not favorite but should be
                     await self.mass.music.tracks.set_favorite(library_item.item_id, True)
-
                 cur_db_ids.add(int(library_item.item_id))
                 await asyncio.sleep(0)  # yield to eventloop
             except MusicAssistantError as err:
@@ -1018,10 +1040,14 @@ class MusicProvider(Provider):
                     library_item = await self.mass.music.podcasts.update_item_in_library(
                         library_item.item_id, prov_item
                     )
+                elif prov_item.date_added and library_item.date_added != prov_item.date_added:
+                    # update date_added if it changed
+                    library_item = await self.mass.music.podcasts.update_item_in_library(
+                        library_item.item_id, prov_item
+                    )
                 if not library_item.favorite and prov_item.favorite:
                     # existing library item not favorite but should be
                     await self.mass.music.podcasts.set_favorite(library_item.item_id, True)
-
                 cur_db_ids.add(int(library_item.item_id))
                 await asyncio.sleep(0)  # yield to eventloop
 
@@ -1057,10 +1083,14 @@ class MusicProvider(Provider):
                     library_item = await self.mass.music.radio.update_item_in_library(
                         library_item.item_id, prov_item
                     )
+                elif prov_item.date_added and library_item.date_added != prov_item.date_added:
+                    # update date_added if it changed
+                    library_item = await self.mass.music.radio.update_item_in_library(
+                        library_item.item_id, prov_item
+                    )
                 if not library_item.favorite and prov_item.favorite:
                     # existing library item not favorite but should be
                     await self.mass.music.radio.set_favorite(library_item.item_id, True)
-
                 cur_db_ids.add(int(library_item.item_id))
                 await asyncio.sleep(0)  # yield to eventloop
 
index e7c81a89a59fe76af5a30e327a13e488022e0920..54a52b3929a443bc8466ef0592004c3cecd3f744 100644 (file)
@@ -10,6 +10,7 @@ import os.path
 import time
 import urllib.parse
 from collections.abc import AsyncGenerator, Iterator, Sequence
+from datetime import UTC, datetime
 from pathlib import Path
 from typing import TYPE_CHECKING, Any, cast
 
@@ -916,6 +917,11 @@ class LocalFileSystemProvider(MusicProvider):
             },
             disc_number=tags.disc or 0,
             track_number=tags.track or 0,
+            date_added=(
+                datetime.fromtimestamp(file_item.created_at, tz=UTC)
+                if file_item.created_at
+                else None
+            ),
         )
 
         if isrc_tags := tags.isrc:
@@ -927,7 +933,11 @@ class LocalFileSystemProvider(MusicProvider):
 
         # album
         album = track.album = (
-            await self._parse_album(track_path=file_item.relative_path, track_tags=tags)
+            await self._parse_album(
+                track_path=file_item.relative_path,
+                track_tags=tags,
+                track_created_at=file_item.created_at,
+            )
             if tags.album
             else None
         )
@@ -1360,8 +1370,15 @@ class LocalFileSystemProvider(MusicProvider):
             )
         return episode
 
-    async def _parse_album(self, track_path: str, track_tags: AudioTags) -> Album:
-        """Parse Album metadata from Track tags."""
+    async def _parse_album(
+        self, track_path: str, track_tags: AudioTags, track_created_at: int | None = None
+    ) -> Album:
+        """Parse Album metadata from Track tags.
+
+        :param track_path: Path to the track file.
+        :param track_tags: Audio tags from the track.
+        :param track_created_at: Creation timestamp of the track file (Unix epoch).
+        """
         assert track_tags.album
         # work out if we have an album and/or disc folder
         # track_dir is the folder level where the tracks are located
@@ -1459,6 +1476,9 @@ class LocalFileSystemProvider(MusicProvider):
                     in_library=True,
                 )
             },
+            date_added=(
+                datetime.fromtimestamp(track_created_at, tz=UTC) if track_created_at else None
+            ),
         )
         if track_tags.barcode:
             album.external_ids.add((ExternalID.BARCODE, track_tags.barcode))
index 5c627ebb25e9adf7fbda258a281145c4eadb2686..30b6f6e9691b030470a6de48711aa78d13d1f2c0 100644 (file)
@@ -22,6 +22,7 @@ class FileSystemItem:
     - is_dir: Boolean if item is directory (not file).
     - checksum: Checksum for this path (usually last modified time) None for dir.
     - file_size : File size in number of bytes or None if unknown (or not a file).
+    - created_at: File creation timestamp (Unix epoch) or None for directories.
     """
 
     filename: str
@@ -30,6 +31,7 @@ class FileSystemItem:
     is_dir: bool
     checksum: str | None = None
     file_size: int | None = None
+    created_at: int | None = None  # file creation timestamp (Unix epoch)
 
     @property
     def ext(self) -> str | None:
@@ -78,6 +80,9 @@ class FileSystemItem:
         # This can raise OSError for files with invalid encoding (e.g., emojis on SMB mounts)
         # Let the caller handle the exception
         stat = entry.stat(follow_symlinks=False)
+        # st_birthtime is available on macOS/Windows, st_ctime on Linux
+        # (on Linux st_ctime is metadata change time, not creation time)
+        created_at = int(getattr(stat, "st_birthtime", stat.st_ctime))
         return cls(
             filename=entry.name,
             relative_path=get_relative_path(base_path, entry.path),
@@ -85,6 +90,7 @@ class FileSystemItem:
             is_dir=False,
             checksum=str(int(stat.st_mtime)),
             file_size=stat.st_size,
+            created_at=created_at,
         )