From 80c8d2b8f932c44f7618abeeefff67b56be1e562 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Wed, 18 May 2022 00:13:35 +0200 Subject: [PATCH] Add support for removal of items to filesystem (#320) Add removal of items to filesystem --- .../controllers/music/providers/filesystem.py | 79 ++++++++++++++++--- music_assistant/models/media_controller.py | 20 +++++ music_assistant/models/media_items.py | 4 + 3 files changed, 93 insertions(+), 10 deletions(-) diff --git a/music_assistant/controllers/music/providers/filesystem.py b/music_assistant/controllers/music/providers/filesystem.py index 4fafc395..f5b563bb 100644 --- a/music_assistant/controllers/music/providers/filesystem.py +++ b/music_assistant/controllers/music/providers/filesystem.py @@ -5,7 +5,7 @@ import asyncio import os import urllib.parse from contextlib import asynccontextmanager -from typing import Generator, List, Optional, Tuple +from typing import Generator, List, Optional, Set, Tuple import aiofiles import xmltodict @@ -26,6 +26,7 @@ from music_assistant.models.media_items import ( Artist, ContentType, ImageType, + ItemMapping, MediaItemImage, MediaItemProviderId, MediaItemType, @@ -109,16 +110,18 @@ class FileSystemProvider(MusicProvider): async def sync_library(self) -> None: """Run library sync for this provider.""" cache_key = f"{self.id}.checksums" - checksums = await self.mass.cache.get(cache_key) - if checksums is None: - checksums = {} + prev_checksums = await self.mass.cache.get(cache_key) + if prev_checksums is None: + prev_checksums = {} # find all music files in the music directory and all subfolders # we work bottom up, as-in we derive all info from the tracks + cur_checksums = {} for entry in scantree(self.config.path): # mtime is used as file checksum checksum = int(entry.stat().st_mtime) - if checksum == checksums.get(entry.path): + cur_checksums[entry.path] = checksum + if checksum == prev_checksums.get(entry.path): continue try: @@ -140,11 +143,67 @@ class FileSystemProvider(MusicProvider): # we don't want the whole sync to crash on one file so we catch all exceptions here self.logger.exception("Error processing %s", entry.path) - # save checksum in cache for next sync - checksums[entry.path] = checksum - - # TODO: Handle deletions - await self.mass.cache.set(cache_key, checksums) + # save checksums for next sync + await self.mass.cache.set(cache_key, cur_checksums) + + # work out deletions + deleted_files = set(prev_checksums.keys()) - set(cur_checksums.keys()) + artists: Set[ItemMapping] = set() + albums: Set[ItemMapping] = set() + # process deleted tracks + for file_path in deleted_files: + item_id = self._get_item_id(file_path) + if db_item := await self.mass.music.tracks.get_db_item_by_prov_id( + item_id, self.type + ): + # remove provider mapping from track + db_item.provider_ids = { + x for x in db_item.provider_ids if x.item_id != item_id + } + if not db_item.provider_ids: + # track has no more provider_ids left, it is completely deleted + await self.mass.music.tracks.delete_db_item(db_item.item_id) + else: + await self.mass.music.tracks.update_db_item( + db_item.item_id, db_item + ) + for artist in db_item.artists: + artists.add(artist) + if db_item.album: + albums.add(db_item.album) + # check if artists are deleted + for artist in artists: + if db_item := await self.mass.music.artists.get_db_item_by_prov_id( + artist.item_id, artist.provider + ): + if len(db_item.provider_ids) > 1: + continue + artist_tracks = await self.mass.music.artists.toptracks( + db_item.item_id, db_item.provider + ) + if artist_tracks: + continue + artist_albums = await self.mass.music.artists.albums( + db_item.item_id, db_item.provider + ) + if artist_albums: + continue + # artist has no more items attached, delete it + await self.mass.music.artists.delete_db_item(db_item.item_id) + # check if albums are deleted + for album in albums: + if db_item := await self.mass.music.albums.get_db_item_by_prov_id( + album.item_id, album.provider + ): + if len(db_item.provider_ids) > 1: + continue + album_tracks = await self.mass.music.albums.tracks( + db_item.item_id, db_item.provider + ) + if album_tracks: + continue + # album has no more tracks attached, delete it + await self.mass.music.albums.delete_db_item(db_item.item_id) async def get_artist(self, prov_artist_id: str) -> Artist: """Get full artist details by id.""" diff --git a/music_assistant/models/media_controller.py b/music_assistant/models/media_controller.py index e28f5c7a..b595a661 100644 --- a/music_assistant/models/media_controller.py +++ b/music_assistant/models/media_controller.py @@ -7,6 +7,7 @@ from typing import TYPE_CHECKING, Generic, List, Optional, Tuple, TypeVar from databases import Database as Db +from music_assistant.helpers.database import TABLE_PROV_MAPPINGS from music_assistant.models.errors import MediaNotFoundError, ProviderUnavailableError from .enums import MediaType, ProviderType @@ -249,3 +250,22 @@ class MediaControllerBase(Generic[ItemCls], metaclass=ABCMeta): f"{self.media_type.value} {item_id} not found on provider {provider.name}" ) return item + + async def delete_db_item(self, item_id: int) -> None: + """Delete record from the database.""" + async with self.mass.database.get_db() as _db: + + # delete prov mappings + await self.mass.database.delete( + TABLE_PROV_MAPPINGS, + {"item_id": int(item_id), "media_type": self.media_type.value}, + db=_db, + ) + # delete item + await self.mass.database.delete( + self.db_table, + {"item_id": int(item_id)}, + db=_db, + ) + # NOTE: this does not delete any references to this item in other records + self.logger.debug("deleted item with id %s from database", item_id) diff --git a/music_assistant/models/media_items.py b/music_assistant/models/media_items.py index cad16b87..f095d2bb 100755 --- a/music_assistant/models/media_items.py +++ b/music_assistant/models/media_items.py @@ -227,6 +227,10 @@ class Artist(MediaItem): media_type: MediaType = MediaType.ARTIST musicbrainz_id: Optional[str] = None + def __hash__(self): + """Return custom hash.""" + return hash((self.provider, self.item_id)) + @dataclass class Album(MediaItem): -- 2.34.1