1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
|
from __future__ import annotations
import re
from dataclasses import dataclass
from pathlib import Path
from types import SimpleNamespace
from mutagen.flac import FLAC
import covers
import expergo as core
FIELDS = [
("FILE", "Filename"),
("TITLE", "Title"),
("ARTIST", "Artist"),
("ALBUM", "Album"),
("ALBUMARTIST", "Album Artist"),
("GENRE", "Genre"),
]
TAG_KEYS = [key for key, _ in FIELDS[1:]]
LRC_COL = len(FIELDS)
ART_COL = LRC_COL + 1
HEADERS = [label for _, label in FIELDS] + ["LRC", "Art"]
def lrc_status(path: Path) -> str:
lrc = path.with_suffix(".lrc")
if not lrc.exists():
return "Missing"
return "OK" if lrc.stat().st_size > 0 else "Empty"
def find_flacs(paths) -> list[Path]:
found = []
for p in map(Path, paths):
if p.is_dir():
found += core.find_flacs(p)
else:
r = p.resolve()
if r.is_file() and r.suffix.lower() == ".flac" and not r.name.endswith(".tmp.flac"):
found.append(r)
return sorted(set(found))
def flac_name(name: str) -> str:
name = core.sanitize_filename(name)
return name if name.lower().endswith(".flac") else name + ".flac"
def track_number(path: Path) -> int | None:
try:
value = FLAC(str(path)).get("TRACKNUMBER", [""])[0]
except Exception:
value = ""
for text in (str(value), path.stem):
match = re.match(r"\s*0*(\d{1,3})\b", text)
if match:
return int(match.group(1))
return None
@dataclass
class Track:
path: Path
orig: dict
tags: dict
lrc: str
art: bool
@classmethod
def load(cls, path: Path) -> Track:
audio = FLAC(str(path))
orig = {"FILE": path.name, **{k: audio.get(k, [""])[0] for k in TAG_KEYS}}
return cls(path, orig, dict(orig), lrc_status(path), bool(audio.pictures))
@property
def name(self) -> str:
return self.path.name
def is_dirty(self, key: str | None = None) -> bool:
return self.tags[key] != self.orig[key] if key else self.tags != self.orig
def refresh_lrc(self) -> None:
self.lrc = lrc_status(self.path)
def refresh_art(self) -> None:
try:
self.art = bool(FLAC(str(self.path)).pictures)
except Exception:
self.art = False
@property
def album_key(self) -> tuple[str, str]:
return (self.tags["ALBUMARTIST"] or self.tags["ARTIST"]).strip(), self.tags["ALBUM"].strip()
def write_tags(path: Path, track: Track) -> None:
"""Write only edited tags so untouched multi-value tags are preserved."""
changed = [k for k in TAG_KEYS if track.is_dirty(k)]
if not changed:
return
audio = FLAC(str(path))
for key in changed:
value = track.tags[key].strip()
if value:
audio[key] = value
elif key in audio:
del audio[key]
audio.save()
def save_track(track: Track) -> None:
write_tags(track.path, track)
if track.is_dirty("FILE"):
name = flac_name(track.tags["FILE"])
if name == ".flac":
raise ValueError("filename cannot be empty")
target = track.path.with_name(name)
if target.exists() and not target.samefile(track.path):
raise FileExistsError(f"{name} already exists")
track.path = core.rename_file(track.path, name)
track.tags["FILE"] = track.path.name
track.orig = dict(track.tags)
track.refresh_lrc()
class FolderLayout:
"""GUI adapter over main.FolderLayout (GUI tags use uppercase keys)."""
def __init__(self, root: Path, prefer_album_artist: bool = True):
self._core = core.FolderLayout(root, prefer_album_artist)
def folder_for(self, tags: dict) -> Path:
return self._core.folder_for({
"artist": tags.get("ARTIST", ""),
"albumartist": tags.get("ALBUMARTIST", ""),
"album": tags.get("ALBUM", ""),
})
def plan_reorganize(tracks: list[Track], root: Path, prefer_album_artist: bool) -> list[SimpleNamespace]:
plan = core.plan_reorg([t.path for t in tracks], root, prefer_album_artist)
names = {t.path: t.name for t in tracks}
return [SimpleNamespace(name=names.get(m.src, m.src.name), src=m.src, dst=m.dst, conflict=m.conflict) for m in plan]
def move_track(item: SimpleNamespace) -> str:
return core.move_item(core.MoveItem(src=item.src, dst=item.dst, conflict=False))
def remove_empty_dirs(root: Path) -> int:
return core.remove_empty_dirs(root)
def fetch_lrc(track: Track) -> str:
return core.auto_lrc_file(track.path, force=False)
def apply_cover(source: tuple[str, str], tracks: list[Track]) -> str:
jpeg = covers.prepare_cover(covers.load_source(source))
for track in tracks:
covers.write_cover(track.path, jpeg)
return f"Applied album art to {len(tracks)} track(s)"
def auto_cover(group) -> str:
return core.auto_cover_group(core.AlbumGroup(key=group.key, tracks=[t.path for t in group.tracks]))
|