From a027cbe776153f890bb51d46201ea12f618899bb Mon Sep 17 00:00:00 2001 From: Keith Edmunds Date: Sun, 4 Jul 2021 19:27:01 +0100 Subject: [PATCH] Greatly improve database update --- app/model.py | 64 +++++++++++++++++++++++++++++++++++ app/songdb.py | 94 +++++++++++++++++++++++++++++++++++++++++++-------- 2 files changed, 143 insertions(+), 15 deletions(-) diff --git a/app/model.py b/app/model.py index 35c3450..5c43c6d 100644 --- a/app/model.py +++ b/app/model.py @@ -14,6 +14,7 @@ from sqlalchemy import ( String, func ) +from sqlalchemy.exc import IntegrityError from sqlalchemy.orm.exc import MultipleResultsFound, NoResultFound from sqlalchemy.orm import relationship, sessionmaker @@ -106,6 +107,14 @@ class Playdates(Base): track.update_lastplayed() session.commit() + @staticmethod + def last_played(session, track): + "Return datetime track last played or None" + + return session.query(Playdates).filter( + (Playdates.track_id == track.id) + ).order_by(Playdates.lastplayed.desc()).first() + class Playlists(Base): """ @@ -279,6 +288,20 @@ class PlaylistTracks(Base): session.add(plt) session.commit() + @staticmethod + def get_playlists_containing_track_id(session, track_id): + + playlists = [] + playlist_ids = session.query(PlaylistTracks.playlist_id).filter( + PlaylistTracks.track_id == track_id).all() + for p in playlist_ids: + playlist = session.query(Playlists).filter( + Playlists.id == p[0]).first() + if playlist: + playlists.append(playlist) + + return playlists + @staticmethod def move_track(session, from_playlist_id, row, to_playlist_id): DEBUG( @@ -458,6 +481,44 @@ class Tracks(Base): ERROR(f"get_track({id}): not found") return None + @staticmethod + def get_track_from_filename(session, filename): + """ + Return track if one and only one track in database has passed + filename (ie, basename of path). Return None if zero or more + than one track matches. + """ + + DEBUG(f"Tracks.get_track_from_filename({filename=})") + try: + track = session.query(Tracks).filter(Tracks.path.ilike( + # TODO: filename separator is hardcoded here + f'%/{filename}')).one() + return track + except (NoResultFound, MultipleResultsFound): + return None + + @staticmethod + def get_track_from_path(session, path): + """ + Return track with passee path, or None. + """ + + DEBUG(f"Tracks.get_track_from_path({path=})") + + return session.query(Tracks).filter(Tracks.path == path).first() + + @staticmethod + def remove_path(session, path): + "Remove track with passed path from database" + + DEBUG(f"Tracks.remove_path({path=})") + + try: + session.query(Tracks).filter(Tracks.path == path).delete() + except IntegrityError as exception: + ERROR(f"Can't remove track with {path=} ({exception=})") + @staticmethod def search_titles(session, text): return ( @@ -473,3 +534,6 @@ class Tracks(Base): def update_lastplayed(self): self.lastplayed = datetime.now() + + def update_path(self, newpath): + self.path = newpath diff --git a/app/songdb.py b/app/songdb.py index 8d432fc..d6ce04b 100755 --- a/app/songdb.py +++ b/app/songdb.py @@ -7,7 +7,7 @@ import tempfile from config import Config from log import DEBUG, INFO -from model import Tracks, Session +from model import Tracks, Playdates, PlaylistTracks, Session from mutagen.flac import FLAC from pydub import AudioSegment, effects from tinytag import TinyTag @@ -22,7 +22,10 @@ def main(): p = argparse.ArgumentParser() p.add_argument('-u', '--update', action="store_true", dest="update", - default=True, help="Update database") + default=False, help="Update database") + p.add_argument('-f', '--full-update', + action="store_true", dest="full_update", + default=False, help="Update database") args = p.parse_args() # Run as required @@ -30,25 +33,32 @@ def main(): INFO("Updating database") with Session() as session: update_db(session) + elif args.full_update: + INFO("Full update of database") + with Session() as session: + full_update_db(session) + else: + INFO("No action specified") INFO("Finished") def create_track_from_file(session, path): """ - Create track in database from passed path. + Create track in database from passed path, or update database entry + if path already in database. Return track. """ track = Tracks.get_or_create(session, path) - tag = TinyTag.get(path) - audio = get_audio_segment(path) + t = get_music_info(path) + track.title = t['title'] + track.artist = t['artist'] + track.duration = int(round( + t['duration'], Config.MILLISECOND_SIGFIGS) * 1000) - track.title = tag.title - track.artist = tag.artist - track.duration = int(round(tag.duration, - Config.MILLISECOND_SIGFIGS) * 1000) + audio = get_audio_segment(path) track.start_gap = leading_silence(audio) track.fade_at = round(fade_point(audio) / 1000, Config.MILLISECOND_SIGFIGS) * 1000 @@ -92,6 +102,12 @@ def create_track_from_file(session, path): return track +def full_update_db(): + "Rescan all entries in database" + + pass + + def get_audio_segment(path): try: if path.endswith('.mp3'): @@ -102,6 +118,21 @@ def get_audio_segment(path): return None +def get_music_info(path): + """ + Return a dictionary of title, artist, duration-in-milliseconds and path. + """ + + tag = TinyTag.get(path) + + return dict( + title=tag.title, + artist=tag.artist, + duration=tag.duration, + path=path + ) + + def leading_silence(audio_segment, silence_threshold=Config.DBFS_SILENCE, chunk_size=Config.AUDIO_SEGMENT_CHUNK_SIZE): """ @@ -187,14 +218,47 @@ def update_db(session): os_paths_list.append(path) os_paths = set(os_paths_list) - for path in list(db_paths - os_paths): - # TODO - INFO(f"To remove from database: {path}") + # If a track is moved, only the path will have changed. + # For any files we have found whose paths are not in the database, + # check to see whether the filename (basename) is present in the + # database: for path in list(os_paths - db_paths): - # TODO - INFO(f"Adding to dataabase: {path}") - create_track_from_file(session, path) + DEBUG(f"songdb.update_db: {path=} not in database") + # is filename in database? + track = Tracks.get_track_from_filename(session, os.path.basename(path)) + if not track: + DEBUG(f"songdb.update_db: Adding to database: {path}") + create_track_from_file(session, path) + else: + # Check track info matches found track + t = get_music_info(path) + if t['artist'] == track.artist and t['title'] == track.title: + track.update_path(path) + else: + create_track_from_file(session, path) + + # Refresh database paths + db_paths = set(Tracks.get_all_paths(session)) + # Remote any tracks from database whose paths don't exist + for path in list(db_paths - os_paths): + track = Tracks.get_track_from_path(session, path) + DEBUG(f"songdb.update_db(): remove from database: {path=} {track=}") + played = Playdates.last_played(session, track) + playlists = PlaylistTracks.get_playlists_containing_track_id( + session, track.id) + if played: + INFO( + f"songdb.update_db: Can't remove {track.id=} ({track.path=}) " + f"as it's in playdates.id={played.id}" + ) + elif playlists: + INFO( + f"songdb.update_db: Can't remove {track.id=} ({track.path=} " + f"as it's in playlists {[p.name for p in playlists]}" + ) + else: + Tracks.remove_path(session, path) if __name__ == '__main__' and '__file__' in globals():