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

@ -96,6 +96,10 @@ class Flow:
self.spotify_library = LibraryLoader().load(work_dir / "spotify_library.json") self.spotify_library = LibraryLoader().load(work_dir / "spotify_library.json")
self.local_library = LibraryLoader().load(work_dir / "local_library.json") self.local_library = LibraryLoader().load(work_dir / "local_library.json")
def save_libraries(self):
LibrarySaver(self.spotify_library).save(work_dir / "spotify_library.json")
LibrarySaver(self.local_library).save(work_dir / "local_library.json")
def map_local_to_spotify(self): def map_local_to_spotify(self):
resolve_remote_tracks(client_id, client_secret, self.local_library, self.spotify_library, work_dir) resolve_remote_tracks(client_id, client_secret, self.local_library, self.spotify_library, work_dir)

206
Gui.py Normal file
View file

@ -0,0 +1,206 @@
import time
import streamlit as st
from st_aggrid import AgGrid, GridOptionsBuilder, GridUpdateMode
import pandas as pd
import Flow
from src.Library import Library, Artist, Album, Track, Playlist
def track_to_row(track: Track):
return {
"Title": track.title,
"Album": track.album.title if track.album else "",
"Artists": ", ".join(a.title if a.title else "no_arist_name" for a in track.artists),
"Lyrics": track.has_lyrics,
"AutoScore": track.auto_score,
"Added": track.added_at.strftime("%Y-%m-%d") if track.added_at else "",
"LocalId": str(track.local_id) if track.local_id else "",
"Path": str(track.local_path) if track.local_path else "",
}
def artist_to_row(artist: Artist):
return {
"Title": artist.title,
"AutoScore": artist.auto_score,
"Resolved": int(artist.resolved_percentage * 100),
"LocalId": str(artist.local_id) if artist.local_id else "",
}
def album_to_row(album: Album):
return {
"Title": album.title,
"AutoScore": album.auto_score,
"Resolved": int(album.resolved_percentage * 100),
"LocalId": str(album.local_id) if album.local_id else "",
}
class LibraryView:
def __init__(self):
self.lib : Library = None
self.libs : list[Library] = None
self.view = None
self.lyrics_only = False
self.query = None
def add_libraries(self, libs : list[Library]):
self.libs = libs
def render(self):
st.set_page_config(layout="wide")
with st.sidebar:
lib = st.selectbox(
"Library",
options=[lib.name for lib in self.libs],
)
for iter in self.libs:
if iter.name == lib:
self.lib = iter
st.write(f"Showing library '{self.lib.name}'")
self.view = st.radio(
"View",
["Tree"]
)
if self.view == "Tree":
self.render_artists_albums_tracks()
# elif self.view == "Tracks":
# self.render_tracks()
# elif self.view == "Playlists":
# self.render_playlists()
def track_visible(
self,
track: Track,
artist: Artist | None,
album: Album | None,
):
if self.lyrics_only and not track.has_lyrics:
return False
if self.query and self.query.lower() not in track.title.lower():
return False
if artist and all(a.id != artist.id for a in track.artists):
return False
if album and (not track.album or track.album.id != album.id):
return False
return True
def render_artists_albums_tracks(self):
# --- ARTISTS TABLE ---
st.subheader("Artists")
artist_rows = [artist_to_row(a) for a in self.lib.artists]
df_artists = pd.DataFrame(artist_rows)
gb_artists = GridOptionsBuilder.from_dataframe(df_artists)
gb_artists.configure_selection(selection_mode="multiple", use_checkbox=True)
gb_artists.configure_column("Title", filter="agTextColumnFilter")
gb_artists.configure_column("AutoScore", sort="desc")
grid_options_artists = gb_artists.build()
grid_response_artists = AgGrid(
df_artists,
gridOptions=grid_options_artists,
update_mode=GridUpdateMode.SELECTION_CHANGED,
allow_unsafe_jscode=True,
enable_enterprise_modules=False,
fit_columns_on_grid_load=True,
)
selected_rows_df = grid_response_artists.get("selected_rows")
selected_artist_titles = selected_rows_df["Title"].tolist() if selected_rows_df is not None and not selected_rows_df.empty else []
selected_artists = [a for a in self.lib.artists if a.title in selected_artist_titles]
# --- ALBUMS TABLE ---
st.subheader("Albums")
if selected_artists:
albums_list = []
for artist in selected_artists:
albums_list.extend(artist.albums)
albums_list = list({al.id: al for al in albums_list}.values())
else:
albums_list = self.lib.albums
album_rows = [album_to_row(a) for a in albums_list]
df_albums = pd.DataFrame(album_rows)
gb_albums = GridOptionsBuilder.from_dataframe(df_albums)
gb_albums.configure_selection(selection_mode="multiple", use_checkbox=True)
gb_albums.configure_column("Title", filter="agTextColumnFilter")
gb_albums.configure_column("AutoScore", sort="desc")
grid_options_albums = gb_albums.build()
grid_response_albums = AgGrid(
df_albums,
gridOptions=grid_options_albums,
update_mode=GridUpdateMode.SELECTION_CHANGED,
allow_unsafe_jscode=True,
enable_enterprise_modules=False,
fit_columns_on_grid_load=True,
)
selected_rows_df = grid_response_albums.get("selected_rows")
selected_album_titles = selected_rows_df["Title"].tolist() if selected_rows_df is not None and not selected_rows_df.empty else []
selected_albums = [al for al in albums_list if al.title in selected_album_titles]
# --- TRACKS TABLE ---
st.subheader("Tracks")
if selected_albums:
tracks_list = []
for album in selected_albums:
tracks_list.extend(album.tracks)
elif selected_artists:
tracks_list = []
for artist in selected_artists:
tracks_list.extend([t for t in self.lib.tracks if artist in t.artists])
else:
tracks_list = self.lib.tracks
track_rows = [track_to_row(t) for t in tracks_list]
df_tracks = pd.DataFrame(track_rows)
gb_tracks = GridOptionsBuilder.from_dataframe(df_tracks)
gb_tracks.configure_selection(selection_mode="multiple", use_checkbox=True)
gb_tracks.configure_column("Title", filter="agTextColumnFilter")
gb_tracks.configure_column("AutoScore", sort="desc")
grid_options_tracks = gb_tracks.build()
AgGrid(
df_tracks,
gridOptions=grid_options_tracks,
update_mode=GridUpdateMode.SELECTION_CHANGED,
allow_unsafe_jscode=True,
enable_enterprise_modules=False,
fit_columns_on_grid_load=True,
)
def run():
flow = Flow.Flow()
# flow.fetch_spotify()
# flow.fetch_local()
# flow.parse_spotify_library()
# flow.parse_local_library()
flow.load_libraries()
# flow.map_local_to_spotify()
# flow.log_stats()
gui = LibraryView()
gui.add_libraries([flow.spotify_library, flow.local_library])
gui.render()
run()

2
error.log Normal file
View file

@ -0,0 +1,2 @@
(process:38049): GLib-GIO-CRITICAL **: 14:06:16.561: g_dbus_connection_emit_signal: assertion 'G_IS_DBUS_CONNECTION (connection)' failed

1
gui.sh Executable file
View file

@ -0,0 +1 @@
streamlit run Gui.py

14
main.py
View file

@ -5,15 +5,14 @@ import Flow
def test1(): def test1():
flow = Flow.Flow() flow = Flow.Flow()
flow.fetch_local() # flow.fetch_local()
flow.parse_local_library() flow.parse_local_library()
flow.load_libraries() flow.load_libraries()
# flow.map_local_to_spotify() # flow.map_local_to_spotify()
# flow.log_stats() # flow.log_stats()
flow.fetch_and_save_lyrics() # flow.fetch_and_save_lyrics()
def main(): def main():
flow = Flow.Flow() flow = Flow.Flow()
@ -27,11 +26,12 @@ def main():
flow.load_libraries() flow.load_libraries()
flow.map_local_to_spotify() flow.map_local_to_spotify()
flow.save_libraries()
flow.log_stats() # flow.log_stats()
print("asd") # print("asd")
test1() # test1()
# main() main()

View file

@ -11,6 +11,11 @@ class Artist:
self.title: str = None self.title: str = None
self.tracks: List[Track] = [] self.tracks: List[Track] = []
self.albums: List[Album] = [] self.albums: List[Album] = []
self.local_id: str = None
self.auto_score: float = 0
self.fav: bool = False
self.rating: int = 0
self.resolved_percentage : float = 0
class Track: class Track:
@ -23,6 +28,10 @@ class Track:
self.added_at: datetime = None self.added_at: datetime = None
self.local_path: Path = None self.local_path: Path = None
self.has_lyrics: bool = False self.has_lyrics: bool = False
self.local_id: str = None
self.auto_score: float = 0
self.fav: bool = False
self.rating: int = 0
class Album: class Album:
@ -32,6 +41,11 @@ class Album:
self.release_date: datetime = None self.release_date: datetime = None
self.tracks: List[Track] = [] self.tracks: List[Track] = []
self.artists: List[Artist] = [] self.artists: List[Artist] = []
self.local_id: str = None
self.auto_score: float = 0
self.fav: bool = False
self.rating: int = 0
self.resolved_percentage : float = 0
class Playlist: class Playlist:
@ -40,6 +54,10 @@ class Playlist:
self.tracks: List[Track] = [] self.tracks: List[Track] = []
self.created_at: datetime = None self.created_at: datetime = None
self.description: str = None self.description: str = None
self.auto_score: float = 0
self.fav: bool = False
self.rating: int = 0
self.resolved_percentage : float = 0
class Library: class Library:
@ -48,6 +66,7 @@ class Library:
self.playlists: List[Playlist] = [] self.playlists: List[Playlist] = []
self.tracks: List[Track] = [] self.tracks: List[Track] = []
self.albums: List[Album] = [] self.albums: List[Album] = []
self.liked_tracks: List[Track] = [] self.liked_tracks: List[Track] = []
self.top_tracks: List[Track] = [] self.top_tracks: List[Track] = []
self.top_artists: List[Artist] = [] self.top_artists: List[Artist] = []
@ -56,10 +75,13 @@ class Library:
self.album_map: dict[str, Album] = None self.album_map: dict[str, Album] = None
self.track_map: dict[str, Track] = None self.track_map: dict[str, Track] = None
self.name = "Unnamed"
def update_cache(self): def update_cache(self):
self.update_id_maps() self.update_id_maps()
self.create_reverse_links() self.create_reverse_links()
self.score_albums()
self.calc_resolved_percentage()
def update_id_maps(self): def update_id_maps(self):
self.artist_map: dict[str, Artist] = {artist.id: artist for artist in self.artists} self.artist_map: dict[str, Artist] = {artist.id: artist for artist in self.artists}
@ -79,3 +101,36 @@ class Library:
for artist in album.artists: for artist in album.artists:
if album not in artist.albums: if album not in artist.albums:
artist.albums.append(album) artist.albums.append(album)
def auto_score(self):
self.score_tracks()
self.score_artists()
self.score_albums()
def score_tracks(self):
for idx, track in enumerate(self.top_tracks):
score = len(self.top_tracks) - idx
track.auto_score = score
def score_artists(self):
for track in self.tracks:
for artist in track.artists:
artist.auto_score += track.auto_score
def score_albums(self):
for track in self.tracks:
track.album.auto_score += track.auto_score
def calc_resolved_percentage(self):
for artist in self.artists:
artist_count_resolved = 0
artist_count = 0
for album in artist.albums:
album_count_resolved = 0
for track in album.tracks:
if track.local_id:
album_count_resolved += 1
album.resolved_percentage = album_count_resolved / len(album.tracks)
artist_count_resolved += album_count_resolved
artist_count += len(album.tracks)
artist.resolved_percentage = artist_count_resolved / artist_count

View file

@ -33,6 +33,10 @@ class LibrarySaver:
"title": artist.title, "title": artist.title,
"tracks": [track.id for track in artist.tracks], "tracks": [track.id for track in artist.tracks],
"albums": [album.id for album in artist.albums], "albums": [album.id for album in artist.albums],
"local_id": artist.local_id,
"auto_score": artist.auto_score,
"fav" : artist.fav,
"rating" : artist.rating,
} }
@staticmethod @staticmethod
@ -46,6 +50,10 @@ class LibrarySaver:
"added_at": _dt_to_str(track.added_at), "added_at": _dt_to_str(track.added_at),
"local_path": track.local_path, "local_path": track.local_path,
"has_lyrics": track.has_lyrics, "has_lyrics": track.has_lyrics,
"local_id": track.local_id,
"auto_score": track.auto_score,
"fav" : track.fav,
"rating" : track.rating,
} }
@staticmethod @staticmethod
@ -55,6 +63,9 @@ class LibrarySaver:
"tracks": [track.id for track in playlist.tracks], "tracks": [track.id for track in playlist.tracks],
"created_at": _dt_to_str(playlist.created_at), "created_at": _dt_to_str(playlist.created_at),
"description": playlist.description, "description": playlist.description,
"auto_score": playlist.auto_score,
"fav" : playlist.fav,
"rating" : playlist.rating,
} }
@staticmethod @staticmethod
@ -64,12 +75,17 @@ class LibrarySaver:
"title": album.title, "title": album.title,
"release_date": album.release_date, "release_date": album.release_date,
"tracks": [track.id for track in album.tracks], "tracks": [track.id for track in album.tracks],
"artists": [artist.id for artist in album.artists] "artists": [artist.id for artist in album.artists],
"local_id": album.local_id,
"auto_score": album.auto_score,
"fav" : album.fav,
"rating" : album.rating,
} }
def save(self, path: Path) -> None: def save(self, path: Path) -> None:
data = { data = {
"name": self.library.name,
"tracks": [self._track_to_dict(track) for track in self.library.tracks], "tracks": [self._track_to_dict(track) for track in self.library.tracks],
"albums": [self._album_to_dict(album) for album in self.library.albums], "albums": [self._album_to_dict(album) for album in self.library.albums],
"artists": [self._artist_to_dict(artist) for artist in self.library.artists], "artists": [self._artist_to_dict(artist) for artist in self.library.artists],
@ -121,6 +137,10 @@ class LibraryLoader:
track.artists = [self.get_artist(artist_id) for artist_id in data.get("artists")] track.artists = [self.get_artist(artist_id) for artist_id in data.get("artists")]
track.album = self.get_album(data.get("album")) track.album = self.get_album(data.get("album"))
track.has_lyrics = data.get("has_lyrics") track.has_lyrics = data.get("has_lyrics")
track.local_id = data.get("local_id")
track.auto_score = data.get("auto_score")
track.fav = data.get("fav")
track.rating = data.get("rating")
self.track_map[track.id] = track self.track_map[track.id] = track
return track return track
@ -131,6 +151,10 @@ class LibraryLoader:
artist.title = data.get("title") artist.title = data.get("title")
artist.tracks = [self.get_track(track_id) for track_id in data.get("tracks")] artist.tracks = [self.get_track(track_id) for track_id in data.get("tracks")]
artist.albums = [self.get_album(album_id) for album_id in data.get("albums")] if data.get("albums") else [] artist.albums = [self.get_album(album_id) for album_id in data.get("albums")] if data.get("albums") else []
artist.local_id = data.get("local_id")
artist.auto_score = data.get("auto_score")
artist.fav = data.get("fav")
artist.rating = data.get("rating")
self.artist_map[artist.id] = artist self.artist_map[artist.id] = artist
return artist return artist
@ -141,6 +165,10 @@ class LibraryLoader:
album.tracks = [self.get_track(track_id) for track_id in data.get("tracks")] album.tracks = [self.get_track(track_id) for track_id in data.get("tracks")]
album.title = data.get("title") album.title = data.get("title")
album.artists = [self.get_artist(artist_id) for artist_id in data.get("artists")] album.artists = [self.get_artist(artist_id) for artist_id in data.get("artists")]
album.local_id = data.get("local_id")
album.auto_score = data.get("auto_score")
album.fav = data.get("fav")
album.rating = data.get("rating")
self.album_map[album.id] = album self.album_map[album.id] = album
return album return album
@ -151,6 +179,7 @@ class LibraryLoader:
library = Library() library = Library()
library.name = data.get("name")
library.tracks = [self.load_track(track) for track in data.get("tracks")] library.tracks = [self.load_track(track) for track in data.get("tracks")]
library.albums = [self.load_album(album) for album in data.get("albums")] library.albums = [self.load_album(album) for album in data.get("albums")]
library.artists = [self.load_artist(artist) for artist in data.get("artists")] library.artists = [self.load_artist(artist) for artist in data.get("artists")]

View file

@ -15,13 +15,12 @@ def get_mapping(mapping_path) -> dict[str, Any]:
with Path(mapping_path).open("r", encoding="utf-8") as f: with Path(mapping_path).open("r", encoding="utf-8") as f:
data = json.load(f) data = json.load(f)
return data return data["track_map"]
class AlbumStat: class AlbumStat:
def __init__(self): def __init__(self):
self.album: Album = Album() self.album: Album = Album()
self.score: int = 0
self.resolved_precent: float = 0 self.resolved_precent: float = 0
def calc_resolved_precent(self): def calc_resolved_precent(self):
@ -37,7 +36,6 @@ class AlbumStat:
class ArtistStat: class ArtistStat:
def __init__(self): def __init__(self):
self.artist: Artist = Artist() self.artist: Artist = Artist()
self.score: int = 0
self.resolved_precent: float = 0 self.resolved_precent: float = 0
self.albums_stats: list[AlbumStat] = [] self.albums_stats: list[AlbumStat] = []
@ -87,18 +85,22 @@ class LibraryStats:
for remote, local in self.mapping.items(): for remote, local in self.mapping.items():
self.remote_id_map[remote].local_path = self.local_id_map[local].local_path self.remote_id_map[remote].local_path = self.local_id_map[local].local_path
def score_tracks(self):
for idx, track in enumerate(self.remote_lib.top_tracks):
score = len(self.remote_lib.top_tracks) - idx
track.auto_score = score
def score_artists(self): def score_artists(self):
for artist in self.remote_lib.artists: for artist in self.remote_lib.artists:
artist_stat = ArtistStat() artist_stat = ArtistStat()
artist_stat.artist = artist artist_stat.artist = artist
artist_stat.score = 0 artist_stat.artist.auto_score = 0
self.artists_map[artist_stat.artist.id] = artist_stat self.artists_map[artist_stat.artist.id] = artist_stat
self.artists.append(artist_stat) self.artists.append(artist_stat)
for idx, track in enumerate(self.remote_lib.top_tracks): for track in self.remote_lib.tracks:
score = len(self.remote_lib.top_tracks) - idx
for artist in track.artists: for artist in track.artists:
self.artists_map[artist.id].score += score self.artists_map[artist.id].artist.auto_score += track.auto_score
for artist_stat in self.artists: for artist_stat in self.artists:
artist_stat.calc_resolved_precent() artist_stat.calc_resolved_precent()
@ -111,9 +113,8 @@ class LibraryStats:
stat.album = album stat.album = album
self.albums_map[album.id] = stat self.albums_map[album.id] = stat
for idx, track in enumerate(self.remote_lib.top_tracks): for track in self.remote_lib.tracks:
score = len(self.remote_lib.top_tracks) - idx self.albums_map[track.album.id].album.auto_score += track.auto_score
self.albums_map[track.album.id].score += score
for _, album in self.albums_map.items(): for _, album in self.albums_map.items():
album.calc_resolved_precent() album.calc_resolved_precent()

View file

@ -1,64 +1,97 @@
import os
import fnmatch import fnmatch
import mutagen
import eyed3
from mutagen.flac import FLAC
from mutagen import File as MutagenFile 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 * from src.helpers import *
music_extensions = ('*.mp3', '*.flac', '*.wav', '*.aac', '*.ogg', '*.m4a') music_extensions = ('*.mp3', '*.flac', '*.wav', '*.aac', '*.ogg', '*.m4a')
exclude_directories = ("*mary--*", "*example-word*") exclude_directories = ("*mary--*", "*example-word*")
from mutagen.id3 import ID3
from mutagen.id3 import ID3NoHeaderError
def detect_lyrics(abs_path, ext): def detect_lyrics(abs_path, ext):
ext = ext.lower() 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: try:
audio = MutagenFile(abs_path) audio = MutagenFile(abs_path)
if not audio or not audio.tags: if not audio or not audio.tags:
return False return False
for key in audio.tags.keys(): # MP3-specific SYLT/USLT detection
k = key.upper() if ext == ".mp3" and isinstance(audio, ID3):
if k in {"LYRICS", "LRC", "SYNCEDLYRICS"}: return bool(audio.getall("SYLT") or audio.getall("USLT"))
if audio.tags[key]:
return True
except Exception:
return False
# ---------- Fallback ---------- # FLAC/OGG/OPUS lyrics detection
try: for key, value in audio.tags.items():
audio = MutagenFile(abs_path) k = key.upper()
if audio and audio.tags: if k in {"LYRICS", "LRC", "SYNCEDLYRICS"} and value:
return True
# Generic fallback
for key in audio.tags.keys(): for key in audio.tags.keys():
if "LYRIC" in key.upper(): if "LYRIC" in key.upper():
return True return True
except Exception: except Exception:
pass return False
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") set_workdir(output_dir / "local")
old_library = get_data("local_library") 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) song_id = get_new_song_id(song_id, relative_path)
new_library[song_id] = { new_library[song_id] = {
"name": song_name, "name": None,
"path": relative_path, "path": relative_path,
"type": track_type, "type": track_type,
"artist": "", "artists": None,
"album": "", "album": None,
"duration": None "duration": None
} }
to_remove = []
with tqdm(total=len(new_library.items()), desc='Indexing local library') as process_bar:
for track_id, track in new_library.items(): for track_id, track in new_library.items():
process_bar.update(1)
abs_path = os.path.join(root_dir, track['path']) abs_path = os.path.join(root_dir, track['path'])
track["has_lyrics"] = detect_lyrics(abs_path, track['type'].lower())
track_abs_path = os.path.join(root_dir, track["path"])
track["has_lyrics"] = detect_lyrics(track_abs_path, track['type'].lower())
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
tag = mp3track.tag
if tag:
track['artist'] = tag.artist
track['name'] = tag.title
track['album'] = tag.album
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: try:
audio = MutagenFile(abs_path) audio = MutagenFile(abs_path)
if audio and audio.info: if audio and audio.tags:
track['duration'] = audio.info.length tags = audio.tags
except Exception:
pass 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"
else:
print(track)
to_remove.append(track_id)
# raise "Cannot index track"
except Exception as e:
print(e)
print(abs_path)
raise e
for rem in to_remove:
new_library.pop(rem)
save_data(new_library, "local_library") save_data(new_library, "local_library")

View file

@ -10,46 +10,6 @@ PROTECTED_ARTISTS = {
"The Good, The Bad & The Queen", "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: class LocalParser:
def __init__(self): def __init__(self):
self.track_map: dict[str, Track] = {} self.track_map: dict[str, Track] = {}
@ -60,9 +20,7 @@ class LocalParser:
def parse_artists(self, data: str) -> List[Artist]: def parse_artists(self, data: str) -> List[Artist]:
if not data: if not data:
data = "unknown" raise "No artist found"
data = split_artists(data)
artists = [] artists = []
@ -87,7 +45,7 @@ class LocalParser:
def parse_album(self, data: str) -> Album: def parse_album(self, data: str) -> Album:
if not data: if not data:
data = "unknown" raise "album is none"
name = data name = data
album_id = name album_id = name
@ -113,12 +71,15 @@ class LocalParser:
track.id = track_id track.id = track_id
track.title = data.get("name") 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.album = self.parse_album(data.get("album"))
track.local_path = data.get("path") track.local_path = data.get("path")
track.duration_ms = data.get("duration") track.duration_ms = data.get("duration")
track.has_lyrics = data.get("has_lyrics") 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.track_map[track.id] = track
self.library.tracks.append(track) self.library.tracks.append(track)
@ -129,6 +90,8 @@ class LocalParser:
self.library.update_cache() self.library.update_cache()
self.library.name = "Local"
return self.library 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.parse_top_tracks(json.loads(top_tracks.read_text()))
self.library.update_cache() self.library.update_cache()
self.library.name = "Spotify"
self.library.auto_score()
return self.library return self.library
@ -75,7 +77,7 @@ class Parser:
track.id = track_id track.id = track_id
track.title = data.get("name") 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.album = self.parse_album(data.get("album"))
track.duration_ms = data.get("duration_ms") track.duration_ms = data.get("duration_ms")
@ -114,6 +116,9 @@ class Parser:
artist.id = data.get("id") artist.id = data.get("id")
artist.title = data.get("name") artist.title = data.get("name")
if artist.title is None:
artist.title = "err_no_title"
self.library.artists.append(artist) self.library.artists.append(artist)
return artist return artist

View file

@ -5,12 +5,46 @@ from tqdm import tqdm
from src.Library import * from src.Library import *
from src.helpers import * from src.helpers import *
import unicodedata
user_def_artist_map = {
"donda" : "kanye west",
}
def normalize(s):
return unicodedata.normalize("NFKC", s).replace("", "-")
def get_score(first: str, second: str) -> float: def get_score(first: str, second: str) -> float:
first_norm = normalize(first).lower()
second_norm = normalize(second).lower()
return float(first_norm == second_norm) * 100
return fuzz.token_set_ratio(first, second) return fuzz.token_set_ratio(first, second)
def get_album_score(first: str, second: str) -> float:
return get_score(first, second)
def get_track_score(first: str, second: str) -> float:
return get_score(first, second)
def get_artist_score(first: str, second: str) -> float:
if not first or not second:
return 0.0
first_lower = first.lower()
second_lower = second.lower()
for key, value in user_def_artist_map.items():
key_lower = key.lower()
value_lower = value.lower()
if (first_lower == key_lower and second_lower == value_lower) or \
(first_lower == value_lower and second_lower == key_lower):
return 100.0
return get_score(first, second)
def is_close_enough(first: float) -> bool: def is_close_enough(first: float) -> bool:
return first > 50 return first > 90
def generate_map(local_lib : Library, remote_lib : Library, output_dir : Path): def generate_map(local_lib : Library, remote_lib : Library, output_dir : Path):
@ -21,12 +55,13 @@ def generate_map(local_lib : Library, remote_lib : Library, output_dir : Path):
with (tqdm(total=len(remote_lib.artists), desc='searching remote artists in local library') as process_bar): with (tqdm(total=len(remote_lib.artists), desc='searching remote artists in local library') as process_bar):
for remote_artist in remote_lib.artists: for remote_artist in remote_lib.artists:
for local_artist in local_lib.artists: for local_artist in local_lib.artists:
score = get_score(remote_artist.title, local_artist.title) score = get_artist_score(remote_artist.title, local_artist.title)
if not is_close_enough(score): if not is_close_enough(score):
continue continue
if remote_artist.id not in artist_map or score > artist_map[remote_artist.id][1]: if remote_artist.id not in artist_map or score > artist_map[remote_artist.id][1]:
artist_map[remote_artist.id] = (local_artist.id, score) artist_map[remote_artist.id] = (local_artist.id, score)
remote_artist.local_id = local_artist.id
process_bar.update(1) process_bar.update(1)
@ -38,12 +73,13 @@ def generate_map(local_lib : Library, remote_lib : Library, output_dir : Path):
for remote_album in remote_artist.albums: for remote_album in remote_artist.albums:
for local_album in local_artist.albums: for local_album in local_artist.albums:
score = get_score(remote_album.title, local_album.title) score = get_album_score(remote_album.title, local_album.title)
if not is_close_enough(score): if not is_close_enough(score):
continue continue
if remote_album.id not in artist_map or score > album_map[remote_album.id][1]: if remote_album.id not in artist_map or score > album_map[remote_album.id][1]:
album_map[remote_album.id] = (local_album.id, score) album_map[remote_album.id] = (local_album.id, score)
remote_album.local_id = local_album.id
process_bar.update(1) process_bar.update(1)
@ -54,15 +90,23 @@ def generate_map(local_lib : Library, remote_lib : Library, output_dir : Path):
for remote_track in remote_album.tracks: for remote_track in remote_album.tracks:
for local_track in local_album.tracks: for local_track in local_album.tracks:
score = get_score(remote_track.title, local_track.title) score = get_track_score(remote_track.title, local_track.title)
if not is_close_enough(score): if not is_close_enough(score):
continue continue
if remote_track.id not in artist_map or score > track_map[remote_track.id][1]: if remote_track.id not in artist_map or score > track_map[remote_track.id][1]:
track_map[remote_track.id] = (local_track.id, score) track_map[remote_track.id] = (local_track.id, score)
remote_track.local_id = local_track.id
process_bar.update(1) process_bar.update(1)
out = { remote_track_id : local_track_id for remote_track_id, (local_track_id, _) in track_map.items() } remote_lib.calc_resolved_percentage()
out = {
"artist_map" : { remote_track_id : local_track_id for remote_track_id, (local_track_id, _) in artist_map.items() },
"album_map" : { remote_track_id : local_track_id for remote_track_id, (local_track_id, _) in album_map.items() },
"track_map" : { remote_track_id : local_track_id for remote_track_id, (local_track_id, _) in track_map.items() },
}
set_workdir(output_dir) set_workdir(output_dir)
save_data(out, "remote_to_local_map") save_data(out, "remote_to_local_map")