diff --git a/components/MusicTable.py b/components/MusicTable.py index 403f176..66b902c 100644 --- a/components/MusicTable.py +++ b/components/MusicTable.py @@ -648,9 +648,9 @@ class MusicTable(QTableView): """ # debug(f'add_files_to_library() files={files}') worker = Worker(add_files_to_database, files, None) - _ = worker.signals.signal_progress.connect(self.qapp.handle_progress) # type: ignore - _ = worker.signals.signal_result.connect(self.on_add_files_to_database_finished) - _ = worker.signals.signal_finished.connect(self.load_music_table) + worker.signals.signal_progress.connect(self.qapp.handle_progress) # type: ignore + worker.signals.signal_result.connect(self.on_add_files_to_database_finished) + worker.signals.signal_finished.connect(self.load_music_table) if self.qapp: threadpool = self.qapp.threadpool # type: ignore threadpool.start(worker) diff --git a/main.py b/main.py index efbc747..8f13c02 100644 --- a/main.py +++ b/main.py @@ -739,7 +739,7 @@ if __name__ == "__main__": sys.exit(1) # Allow for dynamic imports of my custom classes and utilities - # ? + # such as `from utils import whatever` project_root = os.path.abspath(os.path.dirname(__file__)) sys.path.append(project_root) # Start the app diff --git a/utils/add_files_to_database.py b/utils/add_files_to_database.py index 123946f..a9e21a7 100644 --- a/utils/add_files_to_database.py +++ b/utils/add_files_to_database.py @@ -13,11 +13,12 @@ def add_files_to_database(files: list[str], playlist_id: int | None = None, prog files: list() of fully qualified paths to audio file(s) progress_callback: emit data for user feedback - Returns a tuple where the first value is the success state - and the second value is a list of failed to add items - ``` - (True, {"filename.mp3":"failed because i said so"}) - ``` + Returns: + tuple + - 1st value is the success state + - 2nd value is a list of failed to add items + + (e.g., (True, {"filename.mp3":"failed because i said so"})) """ debug('add_files_to_database()') # yea @@ -52,14 +53,17 @@ def add_files_to_database(files: list[str], playlist_id: int | None = None, prog # FIXME: can this be improved? # filename.basename or something like that filename = filepath.split("/")[-1] + print('getting tags') tags, fail_reason = get_tags(filepath) if fail_reason: # if we fail to get audio tags, skip to next song failed_dict[filepath] = fail_reason continue + print('remapping tags') # remap tags from ID3 to database tags audio: dict[str, str | int | None] = id3_remap(tags) # Append data tuple to insert_data list + print('appending extra info') insert_data.append( ( filepath, @@ -79,7 +83,10 @@ def add_files_to_database(files: list[str], playlist_id: int | None = None, prog debug(f"inserting a LOT of songs: {len(insert_data)}") with DBA.DBAccess() as db: db.executemany( - "INSERT OR IGNORE INTO song (filepath, title, album, artist, track_number, genre, codec, album_date, bitrate, length_ms) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?);", + """ + INSERT OR IGNORE INTO song + (filepath, title, album, artist, track_number, genre, codec, album_date, bitrate, length_ms) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?);""", insert_data, ) insert_data = [] # Reset the insert_data list @@ -88,7 +95,9 @@ def add_files_to_database(files: list[str], playlist_id: int | None = None, prog # debug(f'inserting the rest of the songs | insert_data={insert_data}') with DBA.DBAccess() as db: db.executemany( - "INSERT OR IGNORE INTO song (filepath, title, album, artist, track_number, genre, codec, album_date, bitrate, length_ms) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?);", + """INSERT OR IGNORE INTO song + (filepath, title, album, artist, track_number, genre, codec, album_date, bitrate, length_ms) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?);""", insert_data, ) return True, failed_dict diff --git a/utils/get_album_art.py b/utils/get_album_art.py index 516d12f..bf531d4 100644 --- a/utils/get_album_art.py +++ b/utils/get_album_art.py @@ -1,26 +1,100 @@ -from mutagen.id3 import ID3 +from mutagen.id3 import ID3, APIC +from mutagen.flac import FLAC, Picture +from mutagen.mp4 import MP4 +from mutagen.wave import WAVE +from mutagen.aiff import AIFF from logging import debug, error +# def get_album_art(file: str | None) -> bytes: +# """ +# Get the album art for an audio file +# # Parameters +# `file` | str | Fully qualified path to file +# # Returns +# bytes for album art or placeholder artwork +# """ +# default_image_path = "./assets/default_album_art.jpg" +# if file: +# try: +# audio = ID3(file) +# for tag in audio.getall("APIC"): +# if tag.type == 3: # 3 is the type for front cover +# return tag.data +# if audio.getall("APIC"): +# return audio.getall("APIC")[0].data +# except Exception as e: +# error(f"Error retrieving album art: {e}") +# return bytes() +# with open(default_image_path, "rb") as f: +# # debug("loading placeholder album art") +# return f.read() + + + + def get_album_art(file: str | None) -> bytes: - """Get the album art for an audio file - # Parameters - `file` | str | Fully qualified path to file - # Returns - bytes for album art or placeholder artwork """ + Get album art for MP3, FLAC, WAV, AIFF, M4A files. + Parameters: + - `file` | str | Fully qualified path to file + + Returns: + - bytes for album art or placeholder artwork + """ + default_image_path = "./assets/default_album_art.jpg" - if file: + def fallback(): try: + with open(default_image_path, "rb") as f: + return f.read() + except Exception: + return b"" + if not file: + return fallback() + + try: + ext = file.lower().split(".")[-1] + # ---------------- MP3 ---------------- + if ext == "mp3": audio = ID3(file) - for tag in audio.getall("APIC"): - if tag.type == 3: # 3 is the type for front cover - return tag.data - if audio.getall("APIC"): - return audio.getall("APIC")[0].data - except Exception as e: - error(f"Error retrieving album art: {e}") - return bytes() - with open(default_image_path, "rb") as f: - # debug("loading placeholder album art") - return f.read() + apics = audio.getall("APIC") + if apics: + # prefer front cover (type 3) + for tag in apics: + if tag.type == 3: + return tag.data + return apics[0].data + # ---------------- FLAC ---------------- + elif ext == "flac": + audio = FLAC(file) + if audio.pictures: + # prefer front cover + for pic in audio.pictures: + if pic.type == 3: + return pic.data + return audio.pictures[0].data + # ---------------- M4A / MP4 ---------------- + elif ext in ("m4a", "mp4"): + audio = MP4(file) + cov = audio.tags.get("covr") + if cov: + # covr is a list of MP4Cover objects + return bytes(cov[0]) + # ---------------- WAV ---------------- + elif ext == "wav": + return fallback() + # audio = WAVE(file) + # if hasattr(audio, "pictures") and audio.pictures: + # return audio.pictures[0].data + # ---------------- AIFF ---------------- + elif ext in ("aiff", "aif"): + return fallback() + # audio = AIFF(file) + # if hasattr(audio, "pictures") and audio.pictures: + # return audio.pictures[0].data + + except Exception as e: + error(f"Error retrieving album art: {e}") + + return fallback() diff --git a/utils/get_tags.py b/utils/get_tags.py index b85f240..8197d79 100644 --- a/utils/get_tags.py +++ b/utils/get_tags.py @@ -3,11 +3,93 @@ from logging import debug, error from mutagen.id3 import ID3 from mutagen.mp3 import MP3 from mutagen.flac import FLAC +from mutagen.wave import WAVE +from mutagen.aiff import AIFF +from mutagen.mp4 import MP4 from mutagen.id3._frames import TIT2 from mutagen.id3._util import ID3NoHeaderError from utils import handle_date_tag +def get_flac_tags(filename: str) -> tuple[FLAC, str]: + try: + audio = FLAC(filename) + except Exception: + audio = FLAC() + try: + if os.path.exists(filename): + audio.save(os.path.abspath(filename)) + title = os.path.splitext(os.path.basename(filename))[0] + if audio.tags is None: + audio.add_tags() + if "title" not in audio: + audio["title"] = [title] + audio.save() + return audio, "" + + except Exception as e: + return FLAC(), f"Could not assign FLAC tag: {e}" + +def get_m4a_tags(filename: str) -> tuple[MP4, str]: + try: + audio = MP4(filename) + except Exception: + audio = MP4() + try: + if os.path.exists(filename): + audio.save(os.path.abspath(filename)) + title = os.path.splitext(os.path.basename(filename))[0] + if audio.tags is None: + audio.add_tags() + tags = audio.tags + if "©nam" not in tags: + tags["©nam"] = [title] + audio.save() + return audio, "" + except Exception as e: + return MP4(), f"Could not assign M4A tag: {e}" + +def get_aiff_tags(filename: str) -> tuple[AIFF, str]: + try: + audio = AIFF(filename) + except Exception: + audio = AIFF() + try: + if os.path.exists(filename): + audio.save(os.path.abspath(filename)) + title = os.path.splitext(os.path.basename(filename))[0] + if audio.tags is None: + audio.add_tags() + if "TIT2" not in audio: + audio["TIT2"] = TIT2(encoding=3, text=[title]) + audio.save() + return audio, "" + except Exception as e: + return AIFF(), f"Could not assign AIFF tag: {e}" + + + +def get_wav_tags(filename: str) -> tuple[WAVE, str]: + """Get or create basic WAV tags (RIFF INFO)""" + try: + audio = WAVE(filename) + except Exception: + audio = WAVE() + try: + if os.path.exists(filename): + audio = WAVE(filename) + title = os.path.splitext(os.path.basename(filename))[0] + # IMPORTANT: ensure tag container exists + if audio.tags is None: + audio.add_tags() + # RIFF INFO lives in audio.tags.info in many Mutagen versions + if not hasattr(audio.tags, "info") or audio.tags.info is None: + audio.add_tags() + audio.tags["INAM"] = title + audio.save(filename) + return audio, "" + except Exception as e: + return WAVE(), f"Could not assign WAV tag to file: {e}" def get_mp3_tags(filename: str) -> tuple[MP3 | ID3 | FLAC, str]: """Get ID3 tags for mp3 file""" @@ -16,11 +98,9 @@ def get_mp3_tags(filename: str) -> tuple[MP3 | ID3 | FLAC, str]: audio = MP3(filename) except ID3NoHeaderError: audio = MP3() - try: if os.path.exists(filename): audio.save(os.path.abspath(filename)) - title = os.path.splitext(os.path.basename(filename))[0] if "TIT2" not in list(audio.keys()): # if title tag doesnt exist, create it - filename @@ -29,23 +109,21 @@ def get_mp3_tags(filename: str) -> tuple[MP3 | ID3 | FLAC, str]: # Save the updated tags audio.save() return audio, "" - except Exception as e: return MP3(), f"Could not assign ID3 tag to file: {e}" -def id3_remap(audio: MP3 | ID3 | FLAC) -> dict[str, str | int | None]: +def id3_remap(audio: MP3 | ID3 | FLAC | WAVE | AIFF | MP4) -> dict[str, str | int | None]: """ Turns the ID3 dict of an audio file into a normal dict that I, the human, can use. Add extra fields too :D yahooo """ - # FIXME: implement other filetypes here too + # TODO: implement other filetypes here too remap = {} + # .mp3 if isinstance(audio, MP3): - # so ugly uslt_tags = [tag for tag in audio.keys() if tag.startswith("USLT::")] lyrics = next((audio[tag].text for tag in uslt_tags), "") - # so ugly remap = { "title": audio.get("TIT2"), "artist": audio.get("TPE1"), @@ -64,10 +142,96 @@ def id3_remap(audio: MP3 | ID3 | FLAC) -> dict[str, str | int | None]: continue if not isinstance(v, str) and not isinstance(v, int): remap[k] = v.text[0] + # .wav + if isinstance(audio, WAVE): + print('is wave file') + remap = { + "title": audio.get("INAM"), + "artist": audio.get("IART"), + "album": audio.get("IPRD"), + "track_number": audio.get("ITRK"), + "genre": audio.get("IGNR"), + "date": audio.get("ICRD"), + "comment": audio.get("ICMT"), + "bitrate": getattr(audio.info, "bitrate", None), + "length": int(round(audio.info.length, 0)), + "lyrics": audio.get("USLT") or "", + } + for k, v in remap.items(): + print(f'k: {k}, v: {v}') + if v is None: + continue + # WAV tags are often mutagen.id3.TextFrame-like or lists/bytes + if not isinstance(v, str) and not isinstance(v, int): + try: + # RIFF INFO tags are often simple strings but sometimes wrapped + remap[k] = v.text[0] if hasattr(v, "text") else str(v) + except Exception: + remap[k] = str(v) + if isinstance(audio, AIFF): + tags = audio.tags or {} + remap = { + "title": audio.get("TIT2"), + "artist": audio.get("TPE1"), + "album": audio.get("TALB"), + "track_number": audio.get("TRCK"), + "genre": audio.get("TCON"), + "date": audio.get("TDRC"), + "lyrics": audio.get("USLT::eng") if "USLT::eng" in tags else "", + "bitrate": getattr(audio.info, "bitrate", None), + "length": int(round(audio.info.length, 0)), + } + for k, v in remap.items(): + if v is None: + continue + if not isinstance(v, (str, int)): + remap[k] = v.text[0] if hasattr(v, "text") else str(v) + + if isinstance(audio, MP4): + tags = audio.tags or {} + + remap = { + "title": audio.get("©nam"), + "artist": audio.get("©ART"), + "album": audio.get("©alb"), + "track_number": audio.get("trkn"), + "genre": audio.get("©gen"), + "date": audio.get("©day"), + "lyrics": audio.get("©lyr"), + "length": int(round(audio.info.length, 0)) if audio.info else 0, + "bitrate": getattr(audio.info, "bitrate", None), + } + + for k, v in remap.items(): + if v is None: + continue + if not isinstance(v, (str, int)): + remap[k] = str(v) + + if isinstance(audio, FLAC): + tags = audio.tags or {} + + remap = { + "title": audio.get("title"), + "artist": audio.get("artist"), + "album": audio.get("album"), + "track_number": audio.get("tracknumber"), + "genre": audio.get("genre"), + "date": audio.get("date"), + "lyrics": audio.get("lyrics"), + "bitrate": getattr(audio.info, "bitrate", None), + "length": int(round(audio.info.length, 0)), + } + + for k, v in remap.items(): + if v is None: + continue + if not isinstance(v, (str, int)): + remap[k] = str(v) return remap -def get_tags(filename: str) -> tuple[MP3 | ID3 | FLAC, str]: +def get_tags(filename: str) -> tuple[MP3 | ID3 | WAVE | FLAC | AIFF | MP4, str]: """ Get the "ID3" tags for an audio file Returns a tuple of: @@ -77,7 +241,7 @@ def get_tags(filename: str) -> tuple[MP3 | ID3 | FLAC, str]: Args - filename Returns - - tuple(ID3/dict, fail_reason) + - tuple(MP3/WAVE/FLAC/ID3/dict, fail_reason) ID3 dict looks like this: { @@ -86,12 +250,33 @@ def get_tags(filename: str) -> tuple[MP3 | ID3 | FLAC, str]: ... } """ - # FIXME: this is where i implement other filetypes if filename.lower().endswith(".mp3"): try: tags, details = get_mp3_tags(filename) except Exception as e: tags, details = ID3(), str(e) + elif filename.lower().endswith(".wav"): + try: + tags, details = get_wav_tags(filename) + except Exception as e: + tags, details = WAVE(), str(e) + elif filename.lower().endswith(".flac"): + try: + tags, details = get_flac_tags(filename) + except Exception as e: + tags, details = FLAC(), str(e) + elif filename.lower().endswith(".aiff"): + try: + tags, details = get_aiff_tags(filename) + except Exception as e: + tags, details = AIFF(), str(e) + elif filename.lower().endswith(".m4a"): + try: + tags, details = get_m4a_tags(filename) + except Exception as e: + tags, details = MP4(), str(e) else: - tags, details = ID3(), "non mp3 file" + tags, details = ID3(), "Unsupported filetype" return tags, details + +