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.
@@ -0,0 +1,283 @@
1
+ """Command line commands."""
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 argparse
20
+ import logging
21
+ import pathlib
22
+ import re
23
+ from typing import Any
24
+
25
+ from audiolibrarian import __version__, audiofile, audiosource, base, genremanager
26
+
27
+ log = logging.getLogger(__name__)
28
+
29
+
30
+ class _Command:
31
+ # Base class for commands.
32
+ help = ""
33
+ parser = argparse.ArgumentParser()
34
+
35
+ @staticmethod
36
+ def validate_args(args: argparse.Namespace) -> bool:
37
+ """Validate command line arguments."""
38
+ _ = args
39
+ return True
40
+
41
+
42
+ class Convert(_Command, base.Base):
43
+ """AudioLibrarian tool for converting and tagging audio files.
44
+
45
+ This class performs all of its tasks on instantiation and provides no public members or
46
+ methods.
47
+ """
48
+
49
+ command = "convert"
50
+ help = "convert music from files"
51
+ parser = argparse.ArgumentParser()
52
+ parser.add_argument("--artist", "-a", help="provide artist (ignore tags)")
53
+ parser.add_argument("--album", "-m", help="provide album (ignore tags)")
54
+ parser.add_argument("--mb-artist-id", help="Musicbrainz artist ID")
55
+ parser.add_argument("--mb-release-id", help="Musicbrainz release ID")
56
+ parser.add_argument("--disc", "-d", help="format: x/y: disc x of y for multi-disc release")
57
+ parser.add_argument("filename", nargs="+", help="directory name or audio file name")
58
+
59
+ def __init__(self, args: argparse.Namespace) -> None:
60
+ """Initialize a Convert command handler."""
61
+ super().__init__(args)
62
+ self._source_is_cd = False
63
+ self._audio_source = audiosource.FilesAudioSource([pathlib.Path(x) for x in args.filename])
64
+ self._get_tag_info()
65
+ self._convert()
66
+ self._write_manifest()
67
+
68
+ @staticmethod
69
+ def validate_args(args: argparse.Namespace) -> bool:
70
+ """Validate command line arguments."""
71
+ return _validate_disc_arg(args)
72
+
73
+
74
+ class Genre(_Command):
75
+ """Do stuff with genres."""
76
+
77
+ command = "genre"
78
+ help = "manage MB genre"
79
+ parser = argparse.ArgumentParser(
80
+ description=(
81
+ "Process all audio files in the given directory(ies), allowing the user to *update* "
82
+ "the genre in Musicbrainz or *tag* audio files with the user-defined genre in "
83
+ "Musicbrainz."
84
+ )
85
+ )
86
+ parser.add_argument("directory", nargs="+", help="root of directory tree to process")
87
+ parser_action = parser.add_mutually_exclusive_group()
88
+ parser_action.add_argument("--tag", action="store_true", help="update tags")
89
+ parser_action.add_argument("--update", action="store_true", help="update Musicbrainz")
90
+
91
+ def __init__(self, args: argparse.Namespace) -> None:
92
+ """Initialize a Genre command handler."""
93
+ genremanager.GenreManager(args)
94
+
95
+
96
+ class Manifest(_Command, base.Base):
97
+ """AudioLibrarian tool for writing manifest.yaml files.
98
+
99
+ This class performs all of its tasks on instantiation and provides no public members or
100
+ methods.
101
+ """
102
+
103
+ command = "manifest"
104
+ help = "create the Manifest.yaml file"
105
+ parser = argparse.ArgumentParser()
106
+ parser.add_argument("--artist", "-a", help="provide artist (ignore tags)")
107
+ parser.add_argument("--album", "-m", help="provide album (ignore tags)")
108
+ parser.add_argument("--cd", "-c", action="store_true", help="original source was a CD")
109
+ parser.add_argument("--mb-artist-id", help="Musicbrainz artist ID")
110
+ parser.add_argument("--mb-release-id", help="Musicbrainz release ID")
111
+ parser.add_argument("--disc", "-d", help="format: x/y: disc x of y for multi-disc release")
112
+ parser.add_argument("filename", nargs="+", help="directory name or audio file name")
113
+
114
+ def __init__(self, args: argparse.Namespace) -> None:
115
+ """Initialize a Manifest command handler."""
116
+ super().__init__(args)
117
+ self._source_is_cd = args.cd
118
+ self._audio_source = audiosource.FilesAudioSource([pathlib.Path(x) for x in args.filename])
119
+ source_filenames = self._audio_source.get_source_filenames()
120
+ self._source_example = audiofile.AudioFile.open(source_filenames[0]).read_tags()
121
+ self._get_tag_info()
122
+ self._write_manifest()
123
+
124
+ @staticmethod
125
+ def validate_args(args: argparse.Namespace) -> bool:
126
+ """Validate command line arguments."""
127
+ return _validate_disc_arg(args)
128
+
129
+
130
+ class Reconvert(_Command, base.Base):
131
+ """AudioLibrarian tool for re-converting and tagging audio files from existing source files.
132
+
133
+ This class performs all of its tasks on instantiation and provides no public members or
134
+ methods.
135
+ """
136
+
137
+ command = "reconvert"
138
+ help = "re-convert files from an existing source directory"
139
+ parser = argparse.ArgumentParser()
140
+ parser.add_argument("directories", nargs="+", help="source directories")
141
+
142
+ def __init__(self, args: argparse.Namespace) -> None:
143
+ """Initialize a Reconvert command handler."""
144
+ super().__init__(args)
145
+ self._source_is_cd = False
146
+ manifest_paths = self._find_manifests(args.directories)
147
+ count = len(manifest_paths)
148
+ for i, manifest_path in enumerate(manifest_paths):
149
+ print(f"Processing {i + 1} of {count} ({i / count:.0%}): {manifest_path}...")
150
+ self._audio_source = audiosource.FilesAudioSource([manifest_path.parent])
151
+ manifest = self._read_manifest(manifest_path)
152
+ self._disc_number, self._disc_count = manifest["disc_number"], manifest["disc_count"]
153
+ self._get_tag_info()
154
+ self._convert(make_source=False)
155
+
156
+ @staticmethod
157
+ def validate_args(args: argparse.Namespace) -> bool:
158
+ """Validate command line arguments."""
159
+ return _validate_directories_arg(args)
160
+
161
+
162
+ class Rename(_Command, base.Base):
163
+ """AudioLibrarian tool for renaming audio files based on their tags.
164
+
165
+ This class performs all of its tasks on instantiation and provides no public members or
166
+ methods.
167
+ """
168
+
169
+ command = "rename"
170
+ help = "rename files based on tags or MusicBrainz data"
171
+ parser = argparse.ArgumentParser()
172
+ parser.add_argument("--dry-run", action="store_true", help="don't actually rename files")
173
+ parser.add_argument("directories", nargs="+", help="audio file directories")
174
+
175
+ def __init__(self, args: argparse.Namespace) -> None:
176
+ """Initialize a Rename command handler."""
177
+ super().__init__(args)
178
+ self._source_is_cd = False
179
+ print("Finding audio files...")
180
+ for audio_file in self._find_audio_files(args.directories):
181
+ filepath = audio_file.filepath
182
+ if audio_file.one_track.track is None:
183
+ log.warning("%s has no title", filepath)
184
+ continue
185
+ depth = 3 if filepath.parent.name.startswith("disc") else 2
186
+ old_name = filepath
187
+ new_name = (
188
+ filepath.parents[depth]
189
+ / audio_file.one_track.get_artist_album_disc_path()
190
+ / audio_file.one_track.track.get_filename(filepath.suffix)
191
+ )
192
+ if old_name != new_name:
193
+ print(f"Renaming:\n {old_name} -> \n {new_name}")
194
+ if args.dry_run:
195
+ continue
196
+ old_parent = old_name.parent
197
+ new_parent = new_name.parent
198
+ new_parent.mkdir(parents=True, exist_ok=True)
199
+ old_name.rename(new_name)
200
+ if not old_parent.samefile(new_parent):
201
+ # Move the Manifest if it's the only file left.
202
+ man = "Manifest.yaml"
203
+ if [f.name for f in old_parent.glob("*")] == [man]:
204
+ print(f"Renaming:\n {old_parent / man} -> \n {new_parent / man}")
205
+ (old_parent / man).rename(new_parent / man)
206
+ for idx in range(depth):
207
+ if not list(old_name.parents[idx].glob("*")):
208
+ print(f"Removing: {old_name.parents[idx]}")
209
+ old_name.parents[idx].rmdir()
210
+
211
+ else:
212
+ log.debug("Not renaming %s", filepath)
213
+
214
+ @staticmethod
215
+ def validate_args(args: argparse.Namespace) -> bool:
216
+ """Validate command line arguments."""
217
+ return _validate_directories_arg(args)
218
+
219
+
220
+ class Rip(_Command, base.Base):
221
+ """AudioLibrarian tool for ripping, converting and tagging audio files.
222
+
223
+ This class performs all of its tasks on instantiation and provides no public members or
224
+ methods.
225
+ """
226
+
227
+ command = "rip"
228
+ help = "rip music from a CD"
229
+ parser = argparse.ArgumentParser()
230
+ parser.add_argument("--artist", "-a", help="provide artist")
231
+ parser.add_argument("--album", "-m", help="provide album")
232
+ parser.add_argument("--mb-artist-id", help="Musicbrainz artist ID")
233
+ parser.add_argument("--mb-release-id", help="Musicbrainz release ID")
234
+ parser.add_argument("--disc", "-d", help="x/y: disc x of y; multi-disc release")
235
+
236
+ def __init__(self, args: argparse.Namespace) -> None:
237
+ """Initialize a Rip command handler."""
238
+ super().__init__(args)
239
+ self._source_is_cd = True
240
+ self._audio_source = audiosource.CDAudioSource()
241
+ self._get_tag_info()
242
+ self._convert()
243
+ self._write_manifest()
244
+
245
+ @staticmethod
246
+ def validate_args(args: argparse.Namespace) -> bool:
247
+ """Validate command line arguments."""
248
+ return _validate_disc_arg(args)
249
+
250
+
251
+ class Version(_Command):
252
+ """Print the version."""
253
+
254
+ command = "version"
255
+ help = "display the program version"
256
+
257
+ def __init__(self, args: argparse.Namespace) -> None:
258
+ """Initialize a Version command handler."""
259
+ _ = args
260
+ print(f"audiolibrarian {__version__}")
261
+
262
+
263
+ def _validate_directories_arg(args: argparse.Namespace) -> bool:
264
+ for directory in args.directories:
265
+ if not pathlib.Path(directory).is_dir():
266
+ print(f"Directory not found: {directory}")
267
+ return False
268
+ return True
269
+
270
+
271
+ def _validate_disc_arg(args: argparse.Namespace) -> bool:
272
+ if "disc" in args and args.disc:
273
+ if not re.match(r"\d+/\d+", args.disc):
274
+ print("Invalid --disc specification; should be 'x/y'")
275
+ return False
276
+ x, y = args.disc.split("/")
277
+ if int(x) > int(y) or int(x) < 1 or int(y) < 1:
278
+ print("Invalid --disc specification; should be 'x/y' where x <= y and x and y >= 1")
279
+ return False
280
+ return True
281
+
282
+
283
+ COMMANDS: set[Any] = {Convert, Genre, Manifest, Reconvert, Rename, Rip, Version}
@@ -0,0 +1,176 @@
1
+ """Genre Manager."""
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 argparse
20
+ import logging
21
+ import pathlib
22
+ import pickle
23
+ import typing
24
+ import webbrowser
25
+ from typing import Any
26
+
27
+ import mutagen
28
+ import mutagen.flac
29
+ import mutagen.id3
30
+ import mutagen.mp4
31
+
32
+ from audiolibrarian import musicbrainz, text
33
+ from audiolibrarian.settings import SETTINGS
34
+
35
+ log = logging.getLogger(__name__)
36
+
37
+
38
+ class GenreManager:
39
+ """Manage genres."""
40
+
41
+ def __init__(self, args: argparse.Namespace) -> None:
42
+ """Initialize a GenreManager instance."""
43
+ self._args = args
44
+ self._mb = musicbrainz.MusicBrainzSession()
45
+ self._paths = self._get_all_paths()
46
+ self._paths_by_artist = self._get_paths_by_artist()
47
+ _u, _c = self._get_genres_by_artist()
48
+ self._user_genres_by_artist, self._community_genres_by_artist = _u, _c
49
+ if self._args.update:
50
+ self._update_user_artists()
51
+ elif self._args.tag:
52
+ self._update_tags() # type: ignore[no-untyped-call]
53
+
54
+ @typing.no_type_check # The mutagen library doesn't provide type hints.
55
+ def _update_tags(self) -> None:
56
+ """Set the genre tags for all songs to the user-based genre."""
57
+ for artist_id, paths in self._paths_by_artist.items():
58
+ genre = self._user_genres_by_artist.get(artist_id)
59
+ if not genre:
60
+ continue
61
+ for path in paths:
62
+ if path.suffix == ".flac":
63
+ flac_song = mutagen.flac.FLAC(str(path))
64
+ current_genre = flac_song.tags["genre"][0]
65
+ if current_genre != genre:
66
+ flac_song.tags["genre"] = genre
67
+ flac_song.save()
68
+ print(f"{path}: {current_genre} --> {genre}")
69
+ elif path.suffix == ".m4a":
70
+ m4a_song = mutagen.mp4.MP4(str(path))
71
+ current_genre = m4a_song.tags["\xa9gen"][0]
72
+ if current_genre != genre:
73
+ m4a_song.tags["\xa9gen"] = genre
74
+ m4a_song.save()
75
+ print(f"{path}: {current_genre} --> {genre}")
76
+ elif path.suffix == ".mp3":
77
+ mp3_song = mutagen.File(str(path))
78
+ current_genre = str(mp3_song.tags["TCON"])
79
+ if current_genre != genre:
80
+ id3 = mutagen.id3.ID3(str(path))
81
+ id3.add(mutagen.id3.TCON(encoding=3, text=genre))
82
+ id3.save()
83
+ print(f"{path}: {current_genre} --> {genre}")
84
+
85
+ def _update_user_artists(self) -> None:
86
+ """Pull up a web page to allow the user to set the genre.
87
+
88
+ Update the genre for all artists that only have community-base genres.
89
+ """
90
+ for artist_id, artist in sorted(
91
+ self._community_genres_by_artist.items(),
92
+ key=lambda x: x[1]["name"],
93
+ ):
94
+ artist_name = artist["name"]
95
+ i = (
96
+ text.input_(f"Continue with {artist_name} (YES, no, skip)[Y, n, s]: ")
97
+ .lower()
98
+ .strip()
99
+ )
100
+ if i == "n":
101
+ break
102
+ if i != "s":
103
+ webbrowser.open(f"https://musicbrainz.org/artist/{artist_id}/tags")
104
+
105
+ def _get_all_paths(self) -> list[pathlib.Path]:
106
+ """Return a list of paths for all files in the directories specified in the args."""
107
+ paths = []
108
+ for directory in self._args.directory:
109
+ paths.extend([p for p in list(pathlib.Path(directory).glob("**/*")) if p.is_file()])
110
+ return paths
111
+
112
+ def _get_paths_by_artist(self) -> dict[str, list[pathlib.Path]]:
113
+ """Return a map of artist-IDs to paths representing audio files by that artist."""
114
+ artists: dict[str, list[pathlib.Path]] = {}
115
+ for path in self._paths:
116
+ artist_id = None
117
+ song = mutagen.File(str(path))
118
+ if path.suffix == ".flac":
119
+ artist_id = str(
120
+ song.tags.get("musicbrainz_albumartistid", [""])[0]
121
+ or song.tags.get("musicbrainz_artistid", [""])[0]
122
+ )
123
+ elif path.suffix == ".m4a":
124
+ artist_id = (
125
+ song.tags.get("----:com.apple.iTunes:MusicBrainz Album Artist Id", [""])[0]
126
+ or song.tags.get("----:com.apple.iTunes:MusicBrainz Artist Id", [""])[0]
127
+ ).decode("utf8")
128
+ elif path.suffix == ".mp3":
129
+ artist_id = str(
130
+ song.tags.get("TXXX:MusicBrainz Album Artist Id", [""])[0]
131
+ or song.tags.get("TXXX:MusicBrainz Artist Id", [""])[0]
132
+ )
133
+ if artist_id:
134
+ if artist_id not in artists:
135
+ artists[artist_id] = []
136
+ artists[artist_id].append(path)
137
+ return artists
138
+
139
+ def _get_genres_by_artist(
140
+ self,
141
+ ) -> tuple[dict[str, str], dict[str, dict[str, Any]]]:
142
+ """Return two dicts mapping Musicbrainz-artist-ID to user and community.
143
+
144
+ Returns:
145
+ user: a single genre, set in Musicbrainz by this app's user
146
+ community: a list genre records (dicts) set in Musicbrainz by the community
147
+ with "name" and "count" fields
148
+ """
149
+ user: dict[str, str] = {}
150
+ community: dict[str, dict[str, Any]] = {}
151
+ user_modified = False
152
+ cache_file = SETTINGS.work_dir / "user-genres.pickle"
153
+ if cache_file.exists():
154
+ with cache_file.open(mode="rb") as cache_file_obj:
155
+ user = pickle.load(cache_file_obj) # noqa: S301
156
+ for artist_id in self._paths_by_artist:
157
+ if artist_id in user:
158
+ log.debug("Cache hit: %s %s", artist_id, user[artist_id])
159
+ continue # Already in the cache.
160
+ artist = self._mb.get_artist_by_id(artist_id, includes=["genres", "user-genres"])
161
+ if artist["user-genres"]:
162
+ genre = artist["user-genres"][0]["name"].title()
163
+ user[artist_id] = genre
164
+ user_modified = True
165
+ elif artist["genres"]:
166
+ community[artist_id] = {
167
+ "name": artist["name"],
168
+ "genres": [{"name": x["name"], "count": x["count"]} for x in artist["genres"]],
169
+ }
170
+ if user_modified:
171
+ cache_file.parent.mkdir(exist_ok=True)
172
+ with cache_file.open(mode="wb") as cache_file_obj:
173
+ # noinspection PyTypeChecker
174
+ pickle.dump(user, cache_file_obj)
175
+
176
+ return user, community