aboutsummaryrefslogtreecommitdiffstats
path: root/models.py
blob: 5e35c972eef4fab9baf7079756e5d52e1c19e04e (plain) (blame)
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]))
send patches to the email below
yukais@pinapelz.com
include the subject [PATCH repo_name]
pinapelz.com
homepage