implement wave, flac, m4a, aiff files

This commit is contained in:
billy 2026-06-24 14:31:21 -04:00
parent df9399403a
commit 890a6171c9
5 changed files with 308 additions and 40 deletions

View File

@ -648,9 +648,9 @@ class MusicTable(QTableView):
""" """
# debug(f'add_files_to_library() files={files}') # debug(f'add_files_to_library() files={files}')
worker = Worker(add_files_to_database, files, None) worker = Worker(add_files_to_database, files, None)
_ = worker.signals.signal_progress.connect(self.qapp.handle_progress) # type: ignore 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_result.connect(self.on_add_files_to_database_finished)
_ = worker.signals.signal_finished.connect(self.load_music_table) worker.signals.signal_finished.connect(self.load_music_table)
if self.qapp: if self.qapp:
threadpool = self.qapp.threadpool # type: ignore threadpool = self.qapp.threadpool # type: ignore
threadpool.start(worker) threadpool.start(worker)

View File

@ -739,7 +739,7 @@ if __name__ == "__main__":
sys.exit(1) sys.exit(1)
# Allow for dynamic imports of my custom classes and utilities # 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__)) project_root = os.path.abspath(os.path.dirname(__file__))
sys.path.append(project_root) sys.path.append(project_root)
# Start the app # Start the app

View File

@ -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) files: list() of fully qualified paths to audio file(s)
progress_callback: emit data for user feedback progress_callback: emit data for user feedback
Returns a tuple where the first value is the success state Returns:
and the second value is a list of failed to add items tuple
``` - 1st value is the success state
(True, {"filename.mp3":"failed because i said so"}) - 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()') debug('add_files_to_database()')
# yea # yea
@ -52,14 +53,17 @@ def add_files_to_database(files: list[str], playlist_id: int | None = None, prog
# FIXME: can this be improved? # FIXME: can this be improved?
# filename.basename or something like that # filename.basename or something like that
filename = filepath.split("/")[-1] filename = filepath.split("/")[-1]
print('getting tags')
tags, fail_reason = get_tags(filepath) tags, fail_reason = get_tags(filepath)
if fail_reason: if fail_reason:
# if we fail to get audio tags, skip to next song # if we fail to get audio tags, skip to next song
failed_dict[filepath] = fail_reason failed_dict[filepath] = fail_reason
continue continue
print('remapping tags')
# remap tags from ID3 to database tags # remap tags from ID3 to database tags
audio: dict[str, str | int | None] = id3_remap(tags) audio: dict[str, str | int | None] = id3_remap(tags)
# Append data tuple to insert_data list # Append data tuple to insert_data list
print('appending extra info')
insert_data.append( insert_data.append(
( (
filepath, 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)}") debug(f"inserting a LOT of songs: {len(insert_data)}")
with DBA.DBAccess() as db: with DBA.DBAccess() as db:
db.executemany( 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,
) )
insert_data = [] # Reset the insert_data list 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}') # debug(f'inserting the rest of the songs | insert_data={insert_data}')
with DBA.DBAccess() as db: with DBA.DBAccess() as db:
db.executemany( 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,
) )
return True, failed_dict return True, failed_dict

View File

@ -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 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: 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" default_image_path = "./assets/default_album_art.jpg"
if file: def fallback():
try: 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) audio = ID3(file)
for tag in audio.getall("APIC"): apics = audio.getall("APIC")
if tag.type == 3: # 3 is the type for front cover if apics:
# prefer front cover (type 3)
for tag in apics:
if tag.type == 3:
return tag.data return tag.data
if audio.getall("APIC"): return apics[0].data
return audio.getall("APIC")[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: except Exception as e:
error(f"Error retrieving album art: {e}") error(f"Error retrieving album art: {e}")
return bytes()
with open(default_image_path, "rb") as f: return fallback()
# debug("loading placeholder album art")
return f.read()

View File

@ -3,11 +3,93 @@ from logging import debug, error
from mutagen.id3 import ID3 from mutagen.id3 import ID3
from mutagen.mp3 import MP3 from mutagen.mp3 import MP3
from mutagen.flac import FLAC 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._frames import TIT2
from mutagen.id3._util import ID3NoHeaderError from mutagen.id3._util import ID3NoHeaderError
from utils import handle_date_tag 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]: def get_mp3_tags(filename: str) -> tuple[MP3 | ID3 | FLAC, str]:
"""Get ID3 tags for mp3 file""" """Get ID3 tags for mp3 file"""
@ -16,11 +98,9 @@ def get_mp3_tags(filename: str) -> tuple[MP3 | ID3 | FLAC, str]:
audio = MP3(filename) audio = MP3(filename)
except ID3NoHeaderError: except ID3NoHeaderError:
audio = MP3() audio = MP3()
try: try:
if os.path.exists(filename): if os.path.exists(filename):
audio.save(os.path.abspath(filename)) audio.save(os.path.abspath(filename))
title = os.path.splitext(os.path.basename(filename))[0] title = os.path.splitext(os.path.basename(filename))[0]
if "TIT2" not in list(audio.keys()): if "TIT2" not in list(audio.keys()):
# if title tag doesnt exist, create it - filename # 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 # Save the updated tags
audio.save() audio.save()
return audio, "" return audio, ""
except Exception as e: except Exception as e:
return MP3(), f"Could not assign ID3 tag to file: {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. Turns the ID3 dict of an audio file into a normal dict that I, the human, can use.
Add extra fields too :D yahooo Add extra fields too :D yahooo
""" """
# FIXME: implement other filetypes here too # TODO: implement other filetypes here too
remap = {} remap = {}
# .mp3
if isinstance(audio, MP3): if isinstance(audio, MP3):
# so ugly
uslt_tags = [tag for tag in audio.keys() if tag.startswith("USLT::")] uslt_tags = [tag for tag in audio.keys() if tag.startswith("USLT::")]
lyrics = next((audio[tag].text for tag in uslt_tags), "") lyrics = next((audio[tag].text for tag in uslt_tags), "")
# so ugly
remap = { remap = {
"title": audio.get("TIT2"), "title": audio.get("TIT2"),
"artist": audio.get("TPE1"), "artist": audio.get("TPE1"),
@ -64,10 +142,96 @@ def id3_remap(audio: MP3 | ID3 | FLAC) -> dict[str, str | int | None]:
continue continue
if not isinstance(v, str) and not isinstance(v, int): if not isinstance(v, str) and not isinstance(v, int):
remap[k] = v.text[0] 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 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 Get the "ID3" tags for an audio file
Returns a tuple of: Returns a tuple of:
@ -77,7 +241,7 @@ def get_tags(filename: str) -> tuple[MP3 | ID3 | FLAC, str]:
Args Args
- filename - filename
Returns Returns
- tuple(ID3/dict, fail_reason) - tuple(MP3/WAVE/FLAC/ID3/dict, fail_reason)
ID3 dict looks like this: 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"): if filename.lower().endswith(".mp3"):
try: try:
tags, details = get_mp3_tags(filename) tags, details = get_mp3_tags(filename)
except Exception as e: except Exception as e:
tags, details = ID3(), str(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: else:
tags, details = ID3(), "non mp3 file" tags, details = ID3(), "Unsupported filetype"
return tags, details return tags, details