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}')
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)

View File

@ -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

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)
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

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
# 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()

View File

@ -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