nice
This commit is contained in:
parent
35905ea942
commit
99cd872227
13 changed files with 630 additions and 190 deletions
|
|
@ -1,64 +1,97 @@
|
|||
import os
|
||||
import fnmatch
|
||||
import mutagen
|
||||
import eyed3
|
||||
from mutagen.flac import FLAC
|
||||
from mutagen import File as MutagenFile
|
||||
from mutagen.id3 import ID3, ID3NoHeaderError
|
||||
from mutagen.flac import FLAC
|
||||
from mutagen.mp4 import MP4
|
||||
from mutagen.oggvorbis import OggVorbis
|
||||
from tqdm import tqdm
|
||||
|
||||
from src.helpers import *
|
||||
|
||||
|
||||
music_extensions = ('*.mp3', '*.flac', '*.wav', '*.aac', '*.ogg', '*.m4a')
|
||||
exclude_directories = ("*mary--*", "*example-word*")
|
||||
|
||||
|
||||
from mutagen.id3 import ID3
|
||||
from mutagen.id3 import ID3NoHeaderError
|
||||
|
||||
|
||||
def detect_lyrics(abs_path, ext):
|
||||
ext = ext.lower()
|
||||
|
||||
# ---------- MP3 ----------
|
||||
if ext == ".mp3":
|
||||
try:
|
||||
tags = ID3(abs_path)
|
||||
except ID3NoHeaderError:
|
||||
return False
|
||||
|
||||
return bool(
|
||||
tags.getall("SYLT") or
|
||||
tags.getall("USLT")
|
||||
)
|
||||
|
||||
# ---------- FLAC / OGG / OPUS ----------
|
||||
if ext in {".flac", ".ogg", ".opus"}:
|
||||
try:
|
||||
audio = MutagenFile(abs_path)
|
||||
if not audio or not audio.tags:
|
||||
return False
|
||||
|
||||
for key in audio.tags.keys():
|
||||
k = key.upper()
|
||||
if k in {"LYRICS", "LRC", "SYNCEDLYRICS"}:
|
||||
if audio.tags[key]:
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
# ---------- Fallback ----------
|
||||
try:
|
||||
audio = MutagenFile(abs_path)
|
||||
if audio and audio.tags:
|
||||
for key in audio.tags.keys():
|
||||
if "LYRIC" in key.upper():
|
||||
return True
|
||||
if not audio or not audio.tags:
|
||||
return False
|
||||
|
||||
# MP3-specific SYLT/USLT detection
|
||||
if ext == ".mp3" and isinstance(audio, ID3):
|
||||
return bool(audio.getall("SYLT") or audio.getall("USLT"))
|
||||
|
||||
# FLAC/OGG/OPUS lyrics detection
|
||||
for key, value in audio.tags.items():
|
||||
k = key.upper()
|
||||
if k in {"LYRICS", "LRC", "SYNCEDLYRICS"} and value:
|
||||
return True
|
||||
|
||||
# Generic fallback
|
||||
for key in audio.tags.keys():
|
||||
if "LYRIC" in key.upper():
|
||||
return True
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
return False
|
||||
|
||||
def update_local_library(root_dir, output_dir):
|
||||
|
||||
def get_artist(tags, fallback=None) -> list :
|
||||
# FLAC / OGG / general multiple artist fields
|
||||
artist_fields = ["ARTISTS", "TXXX:ARTISTS", "©ART", "artist"]
|
||||
for field in artist_fields:
|
||||
if field in tags and tags[field]:
|
||||
values = tags.get(field)
|
||||
if not isinstance(values, list):
|
||||
return values.text
|
||||
return values
|
||||
|
||||
# M4A
|
||||
if '\xa9ART' in tags and tags['\xa9ART']:
|
||||
values = tags['\xa9ART']
|
||||
return values
|
||||
|
||||
if tags.get("TPE1"):
|
||||
text = getattr(tags.get("TPE1"), "text", None)
|
||||
if text:
|
||||
return text
|
||||
|
||||
return fallback
|
||||
|
||||
def safe_tag_get(tags, key, index=0):
|
||||
try:
|
||||
value = tags.get(key, None)
|
||||
if isinstance(value, list):
|
||||
# filter out None/empty, convert to str
|
||||
value = [str(v) for v in value if v]
|
||||
return value[index] if len(value) > index else fallback
|
||||
return str(value)
|
||||
except (KeyError, ValueError, TypeError):
|
||||
return None
|
||||
|
||||
def get_title(tags):
|
||||
ids = ["TIT2", "TITLE", "title", "\xa9nam"]
|
||||
for key in ids:
|
||||
val = safe_tag_get(tags, key)
|
||||
if val and val != "None":
|
||||
return val
|
||||
return None
|
||||
|
||||
def get_album(tags):
|
||||
ids = ["TALB", "album", "\xa9alb"]
|
||||
for key in ids:
|
||||
val = safe_tag_get(tags, key)
|
||||
if val and val != "None":
|
||||
return val
|
||||
return None
|
||||
|
||||
def update_local_library(root_dir, output_dir):
|
||||
set_workdir(output_dir / "local")
|
||||
|
||||
old_library = get_data("local_library")
|
||||
|
|
@ -91,61 +124,48 @@ def update_local_library(root_dir, output_dir):
|
|||
song_id = get_new_song_id(song_id, relative_path)
|
||||
|
||||
new_library[song_id] = {
|
||||
"name": song_name,
|
||||
"name": None,
|
||||
"path": relative_path,
|
||||
"type": track_type,
|
||||
"artist": "",
|
||||
"album": "",
|
||||
"artists": None,
|
||||
"album": None,
|
||||
"duration": None
|
||||
}
|
||||
|
||||
for track_id, track in new_library.items():
|
||||
abs_path = os.path.join(root_dir, track['path'])
|
||||
to_remove = []
|
||||
|
||||
track_abs_path = os.path.join(root_dir, track["path"])
|
||||
track["has_lyrics"] = detect_lyrics(track_abs_path, track['type'].lower())
|
||||
with tqdm(total=len(new_library.items()), desc='Indexing local library') as process_bar:
|
||||
for track_id, track in new_library.items():
|
||||
process_bar.update(1)
|
||||
|
||||
abs_path = os.path.join(root_dir, track['path'])
|
||||
track["has_lyrics"] = detect_lyrics(abs_path, track['type'].lower())
|
||||
|
||||
try:
|
||||
audio = MutagenFile(abs_path)
|
||||
if audio and audio.tags:
|
||||
tags = audio.tags
|
||||
|
||||
track['artists'] = get_artist(tags)
|
||||
track['name'] = get_title(tags)
|
||||
track['album'] = get_album(tags)
|
||||
|
||||
if not len(track['artists']) or not track['name'] or not track['album']:
|
||||
print(track)
|
||||
raise "Cannot index track"
|
||||
|
||||
match track['type'].lower():
|
||||
case ".mp3":
|
||||
mp3track = eyed3.load(abs_path)
|
||||
if not mp3track or not mp3track.info:
|
||||
print(f"Failed to load mp3 {abs_path}")
|
||||
track['name'] = track['path']
|
||||
else:
|
||||
track['duration'] = mp3track.info.time_secs
|
||||
print(track)
|
||||
to_remove.append(track_id)
|
||||
# raise "Cannot index track"
|
||||
|
||||
tag = mp3track.tag
|
||||
if tag:
|
||||
track['artist'] = tag.artist
|
||||
track['name'] = tag.title
|
||||
track['album'] = tag.album
|
||||
except Exception as e:
|
||||
print(e)
|
||||
print(abs_path)
|
||||
raise e
|
||||
|
||||
case ".flac":
|
||||
try:
|
||||
audio = FLAC(abs_path)
|
||||
|
||||
if audio.info:
|
||||
track['duration'] = audio.info.length
|
||||
|
||||
metadata = audio.tags
|
||||
if metadata:
|
||||
if 'artist' in metadata:
|
||||
track['artist'] = " ".join(metadata['artist'])
|
||||
if 'TITLE' in metadata:
|
||||
track['name'] = " ".join(metadata['TITLE'])
|
||||
if 'album' in metadata:
|
||||
track['album'] = " ".join(metadata['album'])
|
||||
|
||||
except mutagen.MutagenError:
|
||||
print(f"Invalid flac header {abs_path}")
|
||||
track['name'] = track['path']
|
||||
|
||||
case _:
|
||||
try:
|
||||
audio = MutagenFile(abs_path)
|
||||
if audio and audio.info:
|
||||
track['duration'] = audio.info.length
|
||||
except Exception:
|
||||
pass
|
||||
for rem in to_remove:
|
||||
new_library.pop(rem)
|
||||
|
||||
save_data(new_library, "local_library")
|
||||
|
|
|
|||
|
|
@ -10,46 +10,6 @@ PROTECTED_ARTISTS = {
|
|||
"The Good, The Bad & The Queen",
|
||||
}
|
||||
|
||||
def split_artists(artist_tag: str) -> list[str]:
|
||||
if not artist_tag:
|
||||
return []
|
||||
|
||||
placeholder_comma = "§COMMA§"
|
||||
placeholder_amp = "§AMP§"
|
||||
|
||||
protected_map = {}
|
||||
|
||||
# Protect known artist names
|
||||
for artist in PROTECTED_ARTISTS:
|
||||
protected = (
|
||||
artist
|
||||
.replace(",", placeholder_comma)
|
||||
.replace("&", placeholder_amp)
|
||||
)
|
||||
protected_map[protected] = artist
|
||||
artist_tag = artist_tag.replace(artist, protected)
|
||||
|
||||
# Split remaining separators
|
||||
parts = re.split(r"[,&]", artist_tag)
|
||||
|
||||
# Restore protected names and trim spaces
|
||||
result = []
|
||||
for part in parts:
|
||||
part = part.strip()
|
||||
if not part:
|
||||
continue
|
||||
|
||||
part = (
|
||||
part
|
||||
.replace(placeholder_comma, ",")
|
||||
.replace(placeholder_amp, "&")
|
||||
)
|
||||
|
||||
result.append(part)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
class LocalParser:
|
||||
def __init__(self):
|
||||
self.track_map: dict[str, Track] = {}
|
||||
|
|
@ -60,9 +20,7 @@ class LocalParser:
|
|||
|
||||
def parse_artists(self, data: str) -> List[Artist]:
|
||||
if not data:
|
||||
data = "unknown"
|
||||
|
||||
data = split_artists(data)
|
||||
raise "No artist found"
|
||||
|
||||
artists = []
|
||||
|
||||
|
|
@ -87,7 +45,7 @@ class LocalParser:
|
|||
|
||||
def parse_album(self, data: str) -> Album:
|
||||
if not data:
|
||||
data = "unknown"
|
||||
raise "album is none"
|
||||
|
||||
name = data
|
||||
album_id = name
|
||||
|
|
@ -113,12 +71,15 @@ class LocalParser:
|
|||
|
||||
track.id = track_id
|
||||
track.title = data.get("name")
|
||||
track.artists = self.parse_artists(data.get("artist"))
|
||||
track.artists = self.parse_artists(data.get("artists"))
|
||||
track.album = self.parse_album(data.get("album"))
|
||||
track.local_path = data.get("path")
|
||||
track.duration_ms = data.get("duration")
|
||||
track.has_lyrics = data.get("has_lyrics")
|
||||
|
||||
if not track.album or not len(track.artists) or not track.title:
|
||||
raise "error parsing"
|
||||
|
||||
self.track_map[track.id] = track
|
||||
self.library.tracks.append(track)
|
||||
|
||||
|
|
@ -129,6 +90,8 @@ class LocalParser:
|
|||
|
||||
self.library.update_cache()
|
||||
|
||||
self.library.name = "Local"
|
||||
|
||||
return self.library
|
||||
|
||||
|
||||
|
|
|
|||
110
src/backends/navidrome/navidrome.py
Normal file
110
src/backends/navidrome/navidrome.py
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
import sqlite3
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
from typing import List, Optional
|
||||
|
||||
@dataclass
|
||||
class Track:
|
||||
id: int
|
||||
title: str
|
||||
path: str
|
||||
favorite: bool
|
||||
rating: Optional[int]
|
||||
play_count: int
|
||||
last_played: Optional[str]
|
||||
playlists: List[str] = field(default_factory=list)
|
||||
|
||||
@dataclass
|
||||
class Album:
|
||||
id: int
|
||||
title: str
|
||||
tracks: List[Track] = field(default_factory=list)
|
||||
|
||||
@dataclass
|
||||
class Artist:
|
||||
id: str
|
||||
name: str
|
||||
albums: List[Album] = field(default_factory=list)
|
||||
|
||||
class NavidromeDB:
|
||||
def __init__(self, db_path: str):
|
||||
if not os.path.isfile(db_path):
|
||||
raise FileNotFoundError(f"Navidrome DB not found: {db_path}")
|
||||
self.conn = sqlite3.connect(db_path)
|
||||
self.conn.row_factory = sqlite3.Row
|
||||
|
||||
def close(self):
|
||||
self.conn.close()
|
||||
|
||||
def get_artists(self) -> List[Artist]:
|
||||
artists = []
|
||||
c = self.conn.cursor()
|
||||
c.execute("SELECT id, name FROM artist ORDER BY name")
|
||||
for row in c.fetchall():
|
||||
artist = Artist(id=row["id"], name=row["name"])
|
||||
artist.albums = self.get_albums(artist.id)
|
||||
artists.append(artist)
|
||||
return artists
|
||||
|
||||
def get_albums(self, artist_id: str) -> List[Album]:
|
||||
albums = []
|
||||
c = self.conn.cursor()
|
||||
c.execute(
|
||||
"SELECT id, name FROM album WHERE album_artist_id=? ORDER BY name",
|
||||
(artist_id,)
|
||||
)
|
||||
for row in c.fetchall():
|
||||
album = Album(id=row["id"], title=row["name"])
|
||||
album.tracks = self.get_tracks(album.id)
|
||||
albums.append(album)
|
||||
return albums
|
||||
|
||||
def get_tracks(self, album_id: int) -> List[Track]:
|
||||
tracks = []
|
||||
c = self.conn.cursor()
|
||||
c.execute(
|
||||
"SELECT t.id, t.name AS Title, t.path, ut.favorite, ut.rating, ut.play_count AS PlayCount, ut.last_played AS LastPlayed "
|
||||
"FROM track t "
|
||||
"LEFT JOIN usertrack ut ON ut.track_id = t.id "
|
||||
"WHERE t.album_id=? ORDER BY t.track_number",
|
||||
(album_id,)
|
||||
)
|
||||
for row in c.fetchall():
|
||||
track = Track(
|
||||
id=row["id"],
|
||||
title=row["Title"],
|
||||
path=row["path"],
|
||||
favorite=bool(row["favorite"]),
|
||||
rating=row["rating"],
|
||||
play_count=row["PlayCount"] or 0,
|
||||
last_played=row["LastPlayed"],
|
||||
playlists=self.get_playlists_for_track(row["id"])
|
||||
)
|
||||
tracks.append(track)
|
||||
return tracks
|
||||
|
||||
def get_playlists_for_track(self, track_id: int) -> List[str]:
|
||||
c = self.conn.cursor()
|
||||
c.execute(
|
||||
"SELECT p.name FROM playlist p "
|
||||
"JOIN playlisttrack pt ON pt.playlist_id=p.id "
|
||||
"WHERE pt.track_id=?",
|
||||
(track_id,)
|
||||
)
|
||||
return [row["name"] for row in c.fetchall()]
|
||||
|
||||
# Example usage
|
||||
if __name__ == "__main__":
|
||||
db_path = "/mnt/main/data/music/navidrome.db"
|
||||
navidb = NavidromeDB(db_path)
|
||||
|
||||
try:
|
||||
artists = navidb.get_artists()
|
||||
for artist in artists:
|
||||
print(f"Artist: {artist.name}")
|
||||
for album in artist.albums:
|
||||
print(f" Album: {album.title}")
|
||||
for track in album.tracks:
|
||||
print(f" Track: {track.title} | Path: {track.path} | Fav: {track.favorite} | Plays: {track.play_count}")
|
||||
finally:
|
||||
navidb.close()
|
||||
|
|
@ -30,6 +30,8 @@ class Parser:
|
|||
self.parse_top_tracks(json.loads(top_tracks.read_text()))
|
||||
|
||||
self.library.update_cache()
|
||||
self.library.name = "Spotify"
|
||||
self.library.auto_score()
|
||||
|
||||
return self.library
|
||||
|
||||
|
|
@ -75,7 +77,7 @@ class Parser:
|
|||
|
||||
track.id = track_id
|
||||
track.title = data.get("name")
|
||||
track.artists = [self.parse_artist(artist) for artist in data.get("artists")]
|
||||
track.artists = [] + self.parse_album(data.get("album")).artists
|
||||
track.album = self.parse_album(data.get("album"))
|
||||
track.duration_ms = data.get("duration_ms")
|
||||
|
||||
|
|
@ -114,6 +116,9 @@ class Parser:
|
|||
artist.id = data.get("id")
|
||||
artist.title = data.get("name")
|
||||
|
||||
if artist.title is None:
|
||||
artist.title = "err_no_title"
|
||||
|
||||
self.library.artists.append(artist)
|
||||
|
||||
return artist
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue