This commit is contained in:
Шурупов Илья Викторович 2026-02-07 00:48:46 +03:00
parent 35905ea942
commit 99cd872227
13 changed files with 630 additions and 190 deletions

View file

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

View file

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

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

View file

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