From 63d455e182bc5dc547b607762687f850b1bc4f27 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Sun, 4 Jan 2026 12:12:50 +0100 Subject: [PATCH] Allow music providers to provide the "date_added" field to library items (#2920) * 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> --- music_assistant/controllers/media/albums.py | 5 +++ music_assistant/controllers/media/artists.py | 5 +++ .../controllers/media/audiobooks.py | 5 +++ music_assistant/controllers/media/base.py | 4 ++ .../controllers/media/playlists.py | 5 +++ music_assistant/controllers/media/podcasts.py | 5 +++ music_assistant/controllers/media/radio.py | 5 +++ music_assistant/controllers/media/tracks.py | 5 +++ music_assistant/helpers/database.py | 29 ++++++++++++++ music_assistant/models/music_provider.py | 40 ++++++++++++++++--- .../providers/filesystem_local/__init__.py | 26 ++++++++++-- .../providers/filesystem_local/helpers.py | 6 +++ 12 files changed, 132 insertions(+), 8 deletions(-) diff --git a/music_assistant/controllers/media/albums.py b/music_assistant/controllers/media/albums.py index c399c487..5c463097 100644 --- a/music_assistant/controllers/media/albums.py +++ b/music_assistant/controllers/media/albums.py @@ -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 diff --git a/music_assistant/controllers/media/artists.py b/music_assistant/controllers/media/artists.py index 91566270..9f3b878f 100644 --- a/music_assistant/controllers/media/artists.py +++ b/music_assistant/controllers/media/artists.py @@ -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) diff --git a/music_assistant/controllers/media/audiobooks.py b/music_assistant/controllers/media/audiobooks.py index d908d3a7..36035fd8 100644 --- a/music_assistant/controllers/media/audiobooks.py +++ b/music_assistant/controllers/media/audiobooks.py @@ -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 diff --git a/music_assistant/controllers/media/base.py b/music_assistant/controllers/media/base.py index 01d0915d..2a895d85 100644 --- a/music_assistant/controllers/media/base.py +++ b/music_assistant/controllers/media/base.py @@ -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: diff --git a/music_assistant/controllers/media/playlists.py b/music_assistant/controllers/media/playlists.py index e658a3cc..528fe032 100644 --- a/music_assistant/controllers/media/playlists.py +++ b/music_assistant/controllers/media/playlists.py @@ -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 diff --git a/music_assistant/controllers/media/podcasts.py b/music_assistant/controllers/media/podcasts.py index 8361ec23..a247c0dc 100644 --- a/music_assistant/controllers/media/podcasts.py +++ b/music_assistant/controllers/media/podcasts.py @@ -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 diff --git a/music_assistant/controllers/media/radio.py b/music_assistant/controllers/media/radio.py index d5e902a8..28d38b47 100644 --- a/music_assistant/controllers/media/radio.py +++ b/music_assistant/controllers/media/radio.py @@ -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 diff --git a/music_assistant/controllers/media/tracks.py b/music_assistant/controllers/media/tracks.py index 542ff2b4..5e912499 100644 --- a/music_assistant/controllers/media/tracks.py +++ b/music_assistant/controllers/media/tracks.py @@ -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 diff --git a/music_assistant/helpers/database.py b/music_assistant/helpers/database.py index e56124cc..f0bb90cf 100644 --- a/music_assistant/helpers/database.py +++ b/music_assistant/helpers/database.py @@ -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) diff --git a/music_assistant/models/music_provider.py b/music_assistant/models/music_provider.py index f0d83775..9a9e00e1 100644 --- a/music_assistant/models/music_provider.py +++ b/music_assistant/models/music_provider.py @@ -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 diff --git a/music_assistant/providers/filesystem_local/__init__.py b/music_assistant/providers/filesystem_local/__init__.py index e7c81a89..54a52b39 100644 --- a/music_assistant/providers/filesystem_local/__init__.py +++ b/music_assistant/providers/filesystem_local/__init__.py @@ -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)) diff --git a/music_assistant/providers/filesystem_local/helpers.py b/music_assistant/providers/filesystem_local/helpers.py index 5c627ebb..30b6f6e9 100644 --- a/music_assistant/providers/filesystem_local/helpers.py +++ b/music_assistant/providers/filesystem_local/helpers.py @@ -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, ) -- 2.34.1