audiolibrarian 0.16.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- audiolibrarian/__init__.py +19 -0
- audiolibrarian/audiofile/__init__.py +23 -0
- audiolibrarian/audiofile/audiofile.py +114 -0
- audiolibrarian/audiofile/formats/__init__.py +1 -0
- audiolibrarian/audiofile/formats/flac.py +207 -0
- audiolibrarian/audiofile/formats/m4a.py +221 -0
- audiolibrarian/audiofile/formats/mp3.py +259 -0
- audiolibrarian/audiofile/tags.py +48 -0
- audiolibrarian/audiosource.py +215 -0
- audiolibrarian/base.py +433 -0
- audiolibrarian/cli.py +123 -0
- audiolibrarian/commands.py +283 -0
- audiolibrarian/genremanager.py +176 -0
- audiolibrarian/musicbrainz.py +465 -0
- audiolibrarian/output.py +57 -0
- audiolibrarian/records.py +259 -0
- audiolibrarian/settings.py +79 -0
- audiolibrarian/sh.py +55 -0
- audiolibrarian/text.py +115 -0
- audiolibrarian-0.16.2.dist-info/METADATA +334 -0
- audiolibrarian-0.16.2.dist-info/RECORD +28 -0
- audiolibrarian-0.16.2.dist-info/WHEEL +4 -0
- audiolibrarian-0.16.2.dist-info/entry_points.txt +2 -0
- audiolibrarian-0.16.2.dist-info/licenses/COPYING +674 -0
- audiolibrarian-0.16.2.dist-info/licenses/LICENSE +674 -0
- picard_src/README.md +11 -0
- picard_src/__init__.py +7 -0
- picard_src/textencoding.py +495 -0
@@ -0,0 +1,259 @@
|
|
1
|
+
"""AudioFile support for mp3 files."""
|
2
|
+
|
3
|
+
#
|
4
|
+
# Copyright (c) 2020 Stephen Jibson
|
5
|
+
#
|
6
|
+
# This file is part of audiolibrarian.
|
7
|
+
#
|
8
|
+
# Audiolibrarian is free software: you can redistribute it and/or modify it under the terms of the
|
9
|
+
# GNU General Public License as published by the Free Software Foundation, either version 3 of the
|
10
|
+
# License, or (at your option) any later version.
|
11
|
+
#
|
12
|
+
# Audiolibrarian is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
|
13
|
+
# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
|
14
|
+
# the GNU General Public License for more details.
|
15
|
+
#
|
16
|
+
# You should have received a copy of the GNU General Public License along with audiolibrarian.
|
17
|
+
# If not, see <https://www.gnu.org/licenses/>.
|
18
|
+
#
|
19
|
+
from typing import Any, no_type_check
|
20
|
+
|
21
|
+
import mutagen
|
22
|
+
import mutagen.id3
|
23
|
+
|
24
|
+
from audiolibrarian import audiofile, records
|
25
|
+
|
26
|
+
APIC = mutagen.id3.APIC
|
27
|
+
TXXX = mutagen.id3.TXXX
|
28
|
+
UFID = mutagen.id3.UFID
|
29
|
+
MB_UFID = "http://musicbrainz.org"
|
30
|
+
|
31
|
+
|
32
|
+
class Mp3File(audiofile.AudioFile, extensions={".mp3"}):
|
33
|
+
"""AudioFile for MP3 files."""
|
34
|
+
|
35
|
+
@no_type_check # The mutagen library doesn't provide type hints.
|
36
|
+
def read_tags(self) -> records.OneTrack:
|
37
|
+
"""Read the tags and return a OneTrack object."""
|
38
|
+
|
39
|
+
def get_l(key: str) -> records.ListF | None:
|
40
|
+
if (value := mut.get(key)) is None:
|
41
|
+
return None
|
42
|
+
if "/" in str(value):
|
43
|
+
return records.ListF(str(value).split("/"))
|
44
|
+
return records.ListF(value)
|
45
|
+
|
46
|
+
mut = self._mut_file
|
47
|
+
front_cover = None
|
48
|
+
if apic := mut.get("APIC:front cover", mut.get("APIC:front", mut.get("APIC:"))):
|
49
|
+
front_cover = records.FrontCover(data=apic.data, desc=apic.desc, mime=apic.mime)
|
50
|
+
tipl = mut["TIPL"].people if mut.get("TIPL") else []
|
51
|
+
roles = ("arranger", "composer", "conductor", "engineer", "mix", "producer", "writer")
|
52
|
+
medium_count = int(mut["TPOS"][0].split("/")[1]) if mut.get("TPOS") else None
|
53
|
+
medium_number = int(mut["TPOS"][0].split("/")[0]) if mut.get("TPOS") else None
|
54
|
+
track_count = int(mut["TRCK"][0].split("/")[1]) if mut.get("TRCK") else None
|
55
|
+
track_number = int(mut["TRCK"][0].split("/")[0]) if mut.get("TRCK") else None
|
56
|
+
bitrate = mut.info.bitrate // 1000
|
57
|
+
bitrate_mode = records.BitrateMode.__members__[
|
58
|
+
str(mut.info.bitrate_mode).rsplit(".", maxsplit=1)[-1]
|
59
|
+
]
|
60
|
+
# Shortcut for common CBRs.
|
61
|
+
if bitrate_mode == records.BitrateMode.UNKNOWN and bitrate in (128, 160, 192, 320):
|
62
|
+
bitrate_mode = records.BitrateMode.CBR
|
63
|
+
release = (
|
64
|
+
records.Release(
|
65
|
+
album=mut.get("TALB", [None])[0],
|
66
|
+
album_artists=get_l("TPE2"),
|
67
|
+
album_artists_sort=get_l("TSO2"),
|
68
|
+
asins=get_l("TXXX:ASIN"),
|
69
|
+
barcodes=get_l("TXXX:BARCODE"),
|
70
|
+
catalog_numbers=get_l("TXXX:CATALOGNUMBER"),
|
71
|
+
date=(mut.get("TDRC", [mutagen.id3.ID3TimeStamp("")])[0]).text or None,
|
72
|
+
front_cover=front_cover,
|
73
|
+
genres=get_l("TCON"),
|
74
|
+
labels=get_l("TPUB"),
|
75
|
+
media={
|
76
|
+
medium_number: records.Medium(
|
77
|
+
formats=get_l("TMED"),
|
78
|
+
titles=get_l("TSST"),
|
79
|
+
track_count=track_count,
|
80
|
+
tracks={
|
81
|
+
track_number: records.Track(
|
82
|
+
artist=mut.get("TPE1", [None])[0],
|
83
|
+
artists=get_l("TXXX:ARTISTS"),
|
84
|
+
artists_sort=get_l("TSOP"),
|
85
|
+
file_info=records.FileInfo(
|
86
|
+
bitrate=bitrate,
|
87
|
+
bitrate_mode=bitrate_mode,
|
88
|
+
path=self.filepath,
|
89
|
+
type=records.FileType.MP3,
|
90
|
+
),
|
91
|
+
isrcs=get_l("TSRC"),
|
92
|
+
musicbrainz_artist_ids=get_l("TXXX:MusicBrainz Artist Id"),
|
93
|
+
musicbrainz_release_track_id=mut.get(
|
94
|
+
"TXXX:MusicBrainz Release Track Id", [None]
|
95
|
+
)[0],
|
96
|
+
musicbrainz_track_id=mut.get(
|
97
|
+
f"UFID:{MB_UFID}", UFID()
|
98
|
+
).data.decode("utf8")
|
99
|
+
or None,
|
100
|
+
title=mut.get("TIT2", [None])[0],
|
101
|
+
track_number=track_number,
|
102
|
+
)
|
103
|
+
}
|
104
|
+
if track_number
|
105
|
+
else None,
|
106
|
+
)
|
107
|
+
}
|
108
|
+
if medium_number
|
109
|
+
else None,
|
110
|
+
medium_count=medium_count,
|
111
|
+
musicbrainz_album_artist_ids=get_l("TXXX:MusicBrainz Album Artist Id"),
|
112
|
+
musicbrainz_album_id=mut.get("TXXX:MusicBrainz Album Id", [None])[0],
|
113
|
+
musicbrainz_release_group_id=mut.get("TXXX:MusicBrainz Release Group Id", [None])[
|
114
|
+
0
|
115
|
+
],
|
116
|
+
original_year=str((mut["TDOR"][0]).year) if mut.get("TDOR") else None,
|
117
|
+
people=(
|
118
|
+
records.People(
|
119
|
+
arrangers=[name for role, name in tipl if role == "arranger"] or None,
|
120
|
+
composers=[name for role, name in tipl if role == "composer"] or None,
|
121
|
+
conductors=[name for role, name in tipl if role == "conductor"] or None,
|
122
|
+
engineers=[name for role, name in tipl if role == "engineer"] or None,
|
123
|
+
lyricists=get_l("TEXT"),
|
124
|
+
mixers=[name for role, name in tipl if role == "mix"] or None,
|
125
|
+
producers=[name for role, name in tipl if role == "producer"] or None,
|
126
|
+
performers=[
|
127
|
+
records.Performer(name=n, instrument=i)
|
128
|
+
for i, n in tipl
|
129
|
+
if i not in roles
|
130
|
+
]
|
131
|
+
or None,
|
132
|
+
writers=[name for role, name in tipl if role == "writer"] or None,
|
133
|
+
)
|
134
|
+
or None
|
135
|
+
),
|
136
|
+
release_countries=get_l("TXXX:MusicBrainz Album Release Country"),
|
137
|
+
release_statuses=get_l("TXXX:MusicBrainz Album Status"),
|
138
|
+
release_types=get_l("TXXX:MusicBrainz Album Type"),
|
139
|
+
script=mut.get("TXXX:SCRIPT", [None])[0],
|
140
|
+
)
|
141
|
+
or None
|
142
|
+
)
|
143
|
+
if release:
|
144
|
+
release.source = records.Source.TAGS
|
145
|
+
return records.OneTrack(
|
146
|
+
release=release, medium_number=medium_number, track_number=track_number
|
147
|
+
)
|
148
|
+
|
149
|
+
@no_type_check # The mutagen library doesn't provide type hints.
|
150
|
+
def write_tags(self) -> None: # noqa: C901, PLR0912, PLR0915
|
151
|
+
"""Write the tags."""
|
152
|
+
|
153
|
+
def slash(text: list[str] | records.ListF) -> str:
|
154
|
+
return "/".join(text)
|
155
|
+
|
156
|
+
release, medium_number, medium, track_number, track = self._get_tag_sources()
|
157
|
+
tipl_people = (
|
158
|
+
[["arranger", x] for x in (release.people and release.people.arrangers) or []]
|
159
|
+
+ [["composer", x] for x in (release.people and release.people.composers) or []]
|
160
|
+
+ [["conductor", x] for x in (release.people and release.people.conductors) or []]
|
161
|
+
+ [["engineer", x] for x in (release.people and release.people.engineers) or []]
|
162
|
+
+ [["mix", x] for x in (release.people and release.people.mixers) or []]
|
163
|
+
+ [["producer", x] for x in (release.people and release.people.producers) or []]
|
164
|
+
+ [
|
165
|
+
[p.instrument, p.name]
|
166
|
+
for p in (release.people and release.people.performers) or []
|
167
|
+
]
|
168
|
+
+ [["writer", x] for x in (release.people and release.people.writers) or []]
|
169
|
+
)
|
170
|
+
tags: list[mutagen.id3.Frame] = []
|
171
|
+
# noinspection PyUnusedLocal
|
172
|
+
tag: Any = None
|
173
|
+
if tag := release.album:
|
174
|
+
tags.append(mutagen.id3.TALB(encoding=1, text=tag))
|
175
|
+
if tag := release.people and release.people.composers:
|
176
|
+
tags.append(mutagen.id3.TCOM(encoding=1, text=slash(tag)))
|
177
|
+
if tag := release.genres:
|
178
|
+
tags.append(mutagen.id3.TCON(encoding=3, text=slash(tag)))
|
179
|
+
if tag := release.original_year:
|
180
|
+
tags.append(mutagen.id3.TDOR(encoding=0, text=str(tag)))
|
181
|
+
if tag := release.date:
|
182
|
+
tags.append(mutagen.id3.TDRC(encoding=0, text=tag))
|
183
|
+
if tag := release.people and release.people.lyricists:
|
184
|
+
tags.append(mutagen.id3.TEXT(encoding=1, text=slash(tag)))
|
185
|
+
if people := tipl_people:
|
186
|
+
tags.append(mutagen.id3.TIPL(encoding=1, people=people))
|
187
|
+
if tag := track.title:
|
188
|
+
tags.append(mutagen.id3.TIT2(encoding=1, text=tag))
|
189
|
+
if tag := medium.formats:
|
190
|
+
tags.append(mutagen.id3.TMED(encoding=1, text=slash(tag)))
|
191
|
+
if tag := track.artist:
|
192
|
+
tags.append(mutagen.id3.TPE1(encoding=1, text=tag))
|
193
|
+
if tag := release.album_artists:
|
194
|
+
tags.append(mutagen.id3.TPE2(encoding=1, text=slash(tag)))
|
195
|
+
if tag := release.people and release.people.conductors:
|
196
|
+
tags.append(mutagen.id3.TPE3(encoding=1, text=slash(tag)))
|
197
|
+
if (num := medium_number) and (tag := release.medium_count):
|
198
|
+
tags.append(mutagen.id3.TPOS(encoding=0, text=f"{num}/{tag}"))
|
199
|
+
if tag := release.labels:
|
200
|
+
tags.append(mutagen.id3.TPUB(encoding=1, text=slash(tag)))
|
201
|
+
if (num := track_number) and (tag := medium.track_count):
|
202
|
+
tags.append(mutagen.id3.TRCK(encoding=0, text=f"{num}/{tag}"))
|
203
|
+
if tag := release.album_artists_sort:
|
204
|
+
tags.append(mutagen.id3.TSO2(encoding=1, text=slash(tag)))
|
205
|
+
if tag := track.artists_sort:
|
206
|
+
tags.append(mutagen.id3.TSOP(encoding=1, text=slash(tag)))
|
207
|
+
if tag := track.isrcs:
|
208
|
+
tags.append(mutagen.id3.TSRC(encoding=1, text=slash(tag)))
|
209
|
+
if tag := medium.titles:
|
210
|
+
tags.append(mutagen.id3.TSST(encoding=1, text=slash(tag)))
|
211
|
+
if tag := track.artists:
|
212
|
+
tags.append(TXXX(encoding=1, desc="ARTISTS", text=slash(tag)))
|
213
|
+
if tag := release.asins:
|
214
|
+
tags.append(TXXX(encoding=1, desc="ASIN", text=slash(tag)))
|
215
|
+
if tag := release.barcodes:
|
216
|
+
tags.append(TXXX(encoding=1, desc="BARCODE", text=slash(tag)))
|
217
|
+
if tag := release.catalog_numbers:
|
218
|
+
tags.append(TXXX(encoding=1, desc="CATALOGNUMBER", text=slash(tag)))
|
219
|
+
if tag := release.musicbrainz_album_artist_ids:
|
220
|
+
tags.append(TXXX(encoding=1, desc="MusicBrainz Album Artist Id", text=slash(tag)))
|
221
|
+
if tag := release.musicbrainz_album_id:
|
222
|
+
tags.append(TXXX(encoding=1, desc="MusicBrainz Album Id", text=tag))
|
223
|
+
if tag := release.release_countries:
|
224
|
+
tags.append(
|
225
|
+
TXXX(encoding=1, desc="MusicBrainz Album Release Country", text=slash(tag))
|
226
|
+
)
|
227
|
+
if tag := release.release_statuses:
|
228
|
+
tags.append(TXXX(encoding=1, desc="MusicBrainz Album Status", text=slash(tag)))
|
229
|
+
if tag := release.release_types:
|
230
|
+
tags.append(TXXX(encoding=1, desc="MusicBrainz Album Type", text=slash(tag)))
|
231
|
+
if tag := track.musicbrainz_artist_ids:
|
232
|
+
tags.append(TXXX(encoding=1, desc="MusicBrainz Artist Id", text=slash(tag)))
|
233
|
+
if tag := release.musicbrainz_release_group_id:
|
234
|
+
tags.append(TXXX(encoding=1, desc="MusicBrainz Release Group Id", text=tag))
|
235
|
+
if tag := track.musicbrainz_release_track_id:
|
236
|
+
tags.append(TXXX(encoding=1, desc="MusicBrainz Release Track Id", text=tag))
|
237
|
+
if tag := release.script:
|
238
|
+
tags.append(TXXX(encoding=1, desc="SCRIPT", text=tag))
|
239
|
+
if tag := release.original_year:
|
240
|
+
tags.append(TXXX(encoding=1, desc="originalyear", text=str(tag)))
|
241
|
+
if id_ := track.musicbrainz_track_id:
|
242
|
+
tags.append(UFID(owner=MB_UFID, data=bytes(id_, "utf8")))
|
243
|
+
if (cover := release.front_cover) is not None:
|
244
|
+
tags.append(
|
245
|
+
APIC(encoding=0, mime=cover.mime, type=3, desc=cover.desc, data=cover.data)
|
246
|
+
)
|
247
|
+
|
248
|
+
try:
|
249
|
+
id3 = mutagen.id3.ID3(self.filepath)
|
250
|
+
except mutagen.id3.ID3NoHeaderError:
|
251
|
+
self._mut_file.add_tags()
|
252
|
+
self._mut_file.save()
|
253
|
+
id3 = mutagen.id3.ID3(self.filepath)
|
254
|
+
|
255
|
+
id3.delete()
|
256
|
+
for tag in tags:
|
257
|
+
id3.add(tag)
|
258
|
+
id3.save()
|
259
|
+
self._mut_file = mutagen.File(self.filepath.absolute())
|
@@ -0,0 +1,48 @@
|
|
1
|
+
"""Manage tags."""
|
2
|
+
|
3
|
+
#
|
4
|
+
# Copyright (c) 2020 Stephen Jibson
|
5
|
+
#
|
6
|
+
# This file is part of audiolibrarian.
|
7
|
+
#
|
8
|
+
# Audiolibrarian is free software: you can redistribute it and/or modify it under the terms of the
|
9
|
+
# GNU General Public License as published by the Free Software Foundation, either version 3 of the
|
10
|
+
# License, or (at your option) any later version.
|
11
|
+
#
|
12
|
+
# Audiolibrarian is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
|
13
|
+
# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
|
14
|
+
# the GNU General Public License for more details.
|
15
|
+
#
|
16
|
+
# You should have received a copy of the GNU General Public License along with audiolibrarian.
|
17
|
+
# If not, see <https://www.gnu.org/licenses/>.
|
18
|
+
#
|
19
|
+
from typing import Any
|
20
|
+
|
21
|
+
|
22
|
+
# noinspection PyMissingConstructor
|
23
|
+
class Tags(dict[Any, Any]):
|
24
|
+
"""A dict-like object that silently drops keys with None in their values.
|
25
|
+
|
26
|
+
A key will be dropped if:
|
27
|
+
* its value is None
|
28
|
+
* its value is a list containing None
|
29
|
+
* its value is a dict with None in its values
|
30
|
+
"""
|
31
|
+
|
32
|
+
def __init__(self, *args: Any, **kwargs: Any) -> None: # noqa: ANN401
|
33
|
+
"""Initialize a Tags object."""
|
34
|
+
self.update(*args, **kwargs)
|
35
|
+
|
36
|
+
def __setitem__(self, k: Any, v: Any) -> None: # noqa: ANN401
|
37
|
+
"""Set an item only if it should not be dropped."""
|
38
|
+
if not (
|
39
|
+
v is None
|
40
|
+
or (isinstance(v, list) and (None in v or "None" in v))
|
41
|
+
or (isinstance(v, dict) and (None in v.values() or "None" in v.values()))
|
42
|
+
):
|
43
|
+
super().__setitem__(k, v)
|
44
|
+
|
45
|
+
def update(self, *args: Any, **kwargs: Any) -> None: # noqa: ANN401
|
46
|
+
"""See base class."""
|
47
|
+
for key, value in dict(*args, **kwargs).items():
|
48
|
+
self[key] = value
|
@@ -0,0 +1,215 @@
|
|
1
|
+
"""AudioSource."""
|
2
|
+
|
3
|
+
#
|
4
|
+
# Copyright (c) 2020 Stephen Jibson
|
5
|
+
#
|
6
|
+
# This file is part of audiolibrarian.
|
7
|
+
#
|
8
|
+
# Audiolibrarian is free software: you can redistribute it and/or modify it under the terms of the
|
9
|
+
# GNU General Public License as published by the Free Software Foundation, either version 3 of the
|
10
|
+
# License, or (at your option) any later version.
|
11
|
+
#
|
12
|
+
# Audiolibrarian is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
|
13
|
+
# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
|
14
|
+
# the GNU General Public License for more details.
|
15
|
+
#
|
16
|
+
# You should have received a copy of the GNU General Public License along with audiolibrarian.
|
17
|
+
# If not, see <https://www.gnu.org/licenses/>.
|
18
|
+
#
|
19
|
+
import abc
|
20
|
+
import logging
|
21
|
+
import os
|
22
|
+
import pathlib
|
23
|
+
import shutil
|
24
|
+
import subprocess
|
25
|
+
import tempfile
|
26
|
+
from collections.abc import Callable # noqa: TC003
|
27
|
+
|
28
|
+
import discid
|
29
|
+
|
30
|
+
from audiolibrarian import audiofile, records, sh, text
|
31
|
+
from audiolibrarian.settings import SETTINGS
|
32
|
+
|
33
|
+
log = logging.getLogger(__name__)
|
34
|
+
|
35
|
+
|
36
|
+
class AudioSource(abc.ABC):
|
37
|
+
"""An abstract base class for AudioSource classes."""
|
38
|
+
|
39
|
+
def __init__(self) -> None:
|
40
|
+
"""Initialize an AudioSource."""
|
41
|
+
self._temp_dir: pathlib.Path = pathlib.Path(tempfile.mkdtemp())
|
42
|
+
self._source_list: list[pathlib.Path | None] | None = None
|
43
|
+
|
44
|
+
def __del__(self) -> None:
|
45
|
+
"""Remove any temp files."""
|
46
|
+
if self._temp_dir.is_dir():
|
47
|
+
shutil.rmtree(self._temp_dir)
|
48
|
+
|
49
|
+
@property
|
50
|
+
def source_list(self) -> list[pathlib.Path | None]:
|
51
|
+
"""Return a list with source file paths and blanks.
|
52
|
+
|
53
|
+
The list will be ordered by track number, with None in spaces where no
|
54
|
+
filename is present for that track number.
|
55
|
+
"""
|
56
|
+
if not self._source_list:
|
57
|
+
source_filenames = self.get_source_filenames()
|
58
|
+
length = max(text.get_track_number(str(f.name)) for f in source_filenames)
|
59
|
+
result: list[pathlib.Path | None] = [None] * length
|
60
|
+
if length:
|
61
|
+
for filename in source_filenames:
|
62
|
+
idx = text.get_track_number(str(filename.name)) - 1
|
63
|
+
result[idx] = filename
|
64
|
+
self._source_list = result
|
65
|
+
return self._source_list
|
66
|
+
|
67
|
+
def copy_wavs(self, dest_dir: pathlib.Path) -> None:
|
68
|
+
"""Copy wav files to the given destination directory."""
|
69
|
+
for filename in self.get_wav_filenames():
|
70
|
+
shutil.copy2(filename, dest_dir / filename.name)
|
71
|
+
|
72
|
+
def get_front_cover(self) -> records.FrontCover | None:
|
73
|
+
"""Return a FrontCover record or None."""
|
74
|
+
return None
|
75
|
+
|
76
|
+
@abc.abstractmethod
|
77
|
+
def get_search_data(self) -> dict[str, str]:
|
78
|
+
"""Return a dictionary of search data useful for doing a MusicBrainz search."""
|
79
|
+
|
80
|
+
@abc.abstractmethod
|
81
|
+
def get_source_filenames(self) -> list[pathlib.Path]:
|
82
|
+
"""Return a list of the original source file paths."""
|
83
|
+
|
84
|
+
def get_wav_filenames(self) -> list[pathlib.Path]:
|
85
|
+
"""Return a list of the prepared wav file paths."""
|
86
|
+
return sorted(self._temp_dir.glob("*.wav"), key=text.alpha_numeric_key)
|
87
|
+
|
88
|
+
@abc.abstractmethod
|
89
|
+
def prepare_source(self) -> None:
|
90
|
+
"""Convert the source to wav files."""
|
91
|
+
|
92
|
+
|
93
|
+
class CDAudioSource(AudioSource):
|
94
|
+
"""AudioSource from a compact disc."""
|
95
|
+
|
96
|
+
def __init__(self) -> None:
|
97
|
+
"""Initialize a CDAudioSource."""
|
98
|
+
super().__init__()
|
99
|
+
self._cd = discid.read(SETTINGS.discid_device, features=["mcn"])
|
100
|
+
|
101
|
+
def get_search_data(self) -> dict[str, str]:
|
102
|
+
"""Return a dictionary of search data useful for doing a MusicBrainz search."""
|
103
|
+
return {"disc_id": self._cd.id, "disc_mcn": self._cd.mcn}
|
104
|
+
|
105
|
+
def get_source_filenames(self) -> list[pathlib.Path]:
|
106
|
+
"""Return a list of the original source file paths.
|
107
|
+
|
108
|
+
Since we're working with a CD, these files may not yet exist if they have not been
|
109
|
+
read from the disc.
|
110
|
+
"""
|
111
|
+
return [
|
112
|
+
self._temp_dir / f"track{str(n + 1).zfill(2)}.cdda.wav"
|
113
|
+
for n in range(self._cd.last_track_num)
|
114
|
+
]
|
115
|
+
|
116
|
+
def prepare_source(self) -> None:
|
117
|
+
"""Pull audio from the CD to wav files."""
|
118
|
+
cwd = pathlib.Path.cwd()
|
119
|
+
os.chdir(self._temp_dir)
|
120
|
+
try:
|
121
|
+
subprocess.run(("cd-paranoia", "-B"), check=True) # noqa: S603
|
122
|
+
finally:
|
123
|
+
os.chdir(cwd)
|
124
|
+
subprocess.run(("eject",), check=False) # noqa: S603
|
125
|
+
|
126
|
+
|
127
|
+
class FilesAudioSource(AudioSource):
|
128
|
+
"""AudioSource from local files."""
|
129
|
+
|
130
|
+
def __init__(self, filenames: list[pathlib.Path]) -> None:
|
131
|
+
"""Initialize a FilesAudioSource."""
|
132
|
+
super().__init__()
|
133
|
+
self._filenames = filenames
|
134
|
+
if len(filenames) == 1 and filenames[0].is_dir():
|
135
|
+
# If we're given a directory, figure out what's in there.
|
136
|
+
for file_type in ("flac", "wav", "m4a", "mp3"):
|
137
|
+
if fns := sorted(filenames[0].glob(f"*.{file_type}"), key=text.alpha_numeric_key):
|
138
|
+
self._filenames = list(fns)
|
139
|
+
break
|
140
|
+
self._file_type = self._filenames[0].suffix.lstrip(".")
|
141
|
+
|
142
|
+
def get_front_cover(self) -> records.FrontCover | None:
|
143
|
+
"""Return a FrontCover record or None."""
|
144
|
+
for filename in self._filenames:
|
145
|
+
one_track = audiofile.AudioFile.open(filename).one_track
|
146
|
+
release = one_track.release
|
147
|
+
if release.front_cover:
|
148
|
+
return release.front_cover
|
149
|
+
return None
|
150
|
+
|
151
|
+
def get_search_data(self) -> dict[str, str]:
|
152
|
+
"""Return a dictionary of search data useful for doing a MusicBrainz search."""
|
153
|
+
for filename in self._filenames:
|
154
|
+
one_track = audiofile.AudioFile.open(filename).one_track
|
155
|
+
release = one_track.release
|
156
|
+
track = one_track.track
|
157
|
+
|
158
|
+
artist = track.artist or track.artists.first or "" if track else ""
|
159
|
+
album = release.album or "" if release else ""
|
160
|
+
mb_artist_id = (
|
161
|
+
release.musicbrainz_album_artist_ids.first
|
162
|
+
if release and release.musicbrainz_album_artist_ids
|
163
|
+
else "" or track.musicbrainz_artist_ids.first
|
164
|
+
if track and track.musicbrainz_artist_ids
|
165
|
+
else "" or ""
|
166
|
+
)
|
167
|
+
mb_release_id = release.musicbrainz_album_id or "" if release else ""
|
168
|
+
log.info("Artist from tags: %s", artist)
|
169
|
+
log.info("Album from tags: %s", album)
|
170
|
+
log.info("MB Artist ID from tags: %s", mb_artist_id)
|
171
|
+
log.info("MB Release ID from tags: %s", mb_release_id)
|
172
|
+
if mb_artist_id and mb_release_id:
|
173
|
+
return {"mb_artist_id": mb_artist_id, "mb_release_id": mb_release_id}
|
174
|
+
if artist and album:
|
175
|
+
return {"artist": artist, "album": album}
|
176
|
+
return {}
|
177
|
+
|
178
|
+
def get_source_filenames(self) -> list[pathlib.Path]:
|
179
|
+
"""Return a list of the original source file paths."""
|
180
|
+
return self._filenames
|
181
|
+
|
182
|
+
def prepare_source(self) -> None:
|
183
|
+
"""Convert the source files to wav files.
|
184
|
+
|
185
|
+
Raises:
|
186
|
+
ValueError if the file type is not supported.
|
187
|
+
"""
|
188
|
+
decoders: dict[str, Callable[[str, str], tuple[str, ...]]] = {
|
189
|
+
"flac": lambda i, o: ("flac", "--silent", "--decode", f"--output-name={o}", i),
|
190
|
+
"m4a": lambda i, o: ("faad", "-q", "-o", o, i),
|
191
|
+
"mp3": lambda i, o: ("mpg123", "-q", "-w", o, i),
|
192
|
+
}
|
193
|
+
try:
|
194
|
+
decode = decoders[self._file_type]
|
195
|
+
except KeyError as err:
|
196
|
+
msg = f"Unsupported source file type: {self._file_type}"
|
197
|
+
raise ValueError(msg) from err
|
198
|
+
tmp_dir = self._temp_dir / "__tmp__"
|
199
|
+
tmp_dir.mkdir(parents=True)
|
200
|
+
commands: list[tuple[str, ...]] = []
|
201
|
+
for track_number, filepath in enumerate(self.source_list, 1):
|
202
|
+
if filepath:
|
203
|
+
in_ = str(filepath)
|
204
|
+
out_path = tmp_dir / f"{str(track_number).zfill(2)}__.wav"
|
205
|
+
out = str(out_path)
|
206
|
+
commands.append(decode(in_, out))
|
207
|
+
log.info("DECODING: %s -> %s", filepath.name, out_path.name)
|
208
|
+
sh.parallel(f"Making {len(commands)} wav files...", commands)
|
209
|
+
sh.touch(tmp_dir.glob("*.wav"))
|
210
|
+
for filename in sorted(tmp_dir.glob("*.wav"), key=text.alpha_numeric_key):
|
211
|
+
subprocess.run( # noqa: S603
|
212
|
+
("sndfile-convert", "-pcm16", filename, str(filename).replace("/__tmp__/", "/")),
|
213
|
+
check=True,
|
214
|
+
)
|
215
|
+
shutil.rmtree(tmp_dir)
|