diff --git a/pytest/modules/Audio/test_youtube.py b/pytest/modules/Audio/test_youtube.py index e2d9bcd..d52e5d3 100644 --- a/pytest/modules/Audio/test_youtube.py +++ b/pytest/modules/Audio/test_youtube.py @@ -1,5 +1,7 @@ +"""Tests for youtube.py""" + import unittest -from unittest.mock import patch, MagicMock +from unittest.mock import patch from src.modules.Audio.youtube import get_youtube_title from src.modules.Audio.youtube import download_and_convert_thumbnail @@ -14,7 +16,7 @@ def test_get_youtube_title(self, mock_youtube_dl): "title": " Test Artist - Test Track ", "channel": " Test Channel " } - url = " https://www.youtube.com/watch?v=dQw4w9WgXcQ " + url = " https://fakeUrl " # Act result = get_youtube_title(url) @@ -24,20 +26,16 @@ def test_get_youtube_title(self, mock_youtube_dl): mock_youtube_dl.assert_called_once() @patch("src.modules.Audio.youtube.yt_dlp.YoutubeDL") - @patch("src.modules.Audio.youtube.Image.open") - @patch("src.modules.Audio.youtube.os.path.join") - @patch("src.modules.Audio.youtube.crop_image_to_square") - def test_download_and_convert_thumbnail(self, mock_crop_image_to_square, mock_os_path_join, mock_image_open, mock_youtube_dl): + @patch("src.modules.Audio.youtube.save_image") + def test_download_and_convert_thumbnail(self, mock_save_image, mock_youtube_dl): # Arrange mock_youtube_dl.return_value.__enter__.return_value.extract_info.return_value = {"thumbnail": "test_thumbnail_url"} mock_youtube_dl.return_value.__enter__.return_value.urlopen.return_value.read.return_value = b"test_image_data" - mock_image = MagicMock() - mock_image.convert.return_value = mock_image - mock_image_open.return_value = mock_image - mock_os_path_join.return_value = "/path/to/output/test.jpg" + + mock_save_image.return_value = None ydl_opts = {} - url = "https://www.youtube.com/watch?v=dQw4w9WgXcQ" + url = "https://fakeUrl" clear_filename = "test" output_path = "/path/to/output" @@ -48,10 +46,6 @@ def test_download_and_convert_thumbnail(self, mock_crop_image_to_square, mock_os mock_youtube_dl.assert_called_once_with(ydl_opts) mock_youtube_dl.return_value.__enter__.return_value.extract_info.assert_called_once_with(url, download=False) mock_youtube_dl.return_value.__enter__.return_value.urlopen.assert_called_once_with("test_thumbnail_url") - mock_image.convert.assert_called_once_with('RGB') - mock_os_path_join.assert_called_once_with(output_path, clear_filename + " [CO].jpg") - mock_image.save.assert_called_once_with("/path/to/output/test.jpg", "JPEG") - mock_crop_image_to_square.assert_called_once_with("/path/to/output/test.jpg") if __name__ == "__main__": unittest.main() \ No newline at end of file diff --git a/pytest/modules/Image/test_image_helper.py b/pytest/modules/Image/test_image_helper.py new file mode 100644 index 0000000..1edc264 --- /dev/null +++ b/pytest/modules/Image/test_image_helper.py @@ -0,0 +1,32 @@ +"""Tests for image_helper.py""" + +import unittest +from unittest.mock import patch, MagicMock +from src.modules.Image.image_helper import save_image + +class TestImageHelper(unittest.TestCase): + + @patch("src.modules.Image.image_helper.Image.open") + @patch("src.modules.Image.image_helper.os.path.join") + @patch("src.modules.Image.image_helper.crop_image_to_square") + def test_save_image(self, mock_crop_image_to_square, mock_os_path_join, mock_image_open): + # Arrange + mock_image = MagicMock() + mock_image.convert.return_value = mock_image + mock_image_open.return_value = mock_image + mock_os_path_join.return_value = "/path/to/output/test.jpg" + + clear_filename = "test" + output_path = "/path/to/output" + + # Act + save_image(b'fake_image_bytes', clear_filename, output_path) + + # Assert + mock_image.convert.assert_called_once_with('RGB') + mock_os_path_join.assert_called_once_with(output_path, clear_filename + " [CO].jpg") + mock_image.save.assert_called_once_with("/path/to/output/test.jpg", "JPEG") + mock_crop_image_to_square.assert_called_once_with("/path/to/output/test.jpg") + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/pytest/modules/test_musicbrainz_client.py b/pytest/modules/test_musicbrainz_client.py index e342024..79d4407 100644 --- a/pytest/modules/test_musicbrainz_client.py +++ b/pytest/modules/test_musicbrainz_client.py @@ -2,173 +2,152 @@ import unittest from unittest.mock import patch -from src.modules.musicbrainz_client import get_music_infos +from src.modules.musicbrainz_client import search_musicbrainz class TestGetMusicInfos(unittest.TestCase): @patch('musicbrainzngs.search_artists') - @patch('musicbrainzngs.search_release_groups') - def test_get_music_infos(self, mock_search_release_groups, mock_search_artists): + @patch('musicbrainzngs.search_recordings') + @patch('musicbrainzngs.get_image_front') + @patch('musicbrainzngs.get_image_list') + @patch('musicbrainzngs.get_release_group_by_id') + def test_get_music_infos(self, mock_get_release_group_by_id, mock_get_image_list, mock_get_image_front, mock_search_recordings, mock_search_artists): # Arrange artist = 'UltraSinger' - title = 'That\'s Rocking!' - search = f'{artist} - {title} (UltrStar 2023) FULL HD' + title = 'That\'s Rocking! (UltrStar 2023) FULL HD' # Set up mock return values for the MusicBrainz API calls mock_search_artists.return_value = { 'artist-list': [ - {'name': artist} - ] - } - - mock_search_release_groups.return_value = { - 'release-group-list': [ { - 'title': title, - 'artist-credit-phrase': artist, - 'first-release-date': '2023-01-01', - 'tag-list': [ - {'name': 'Genre 1'}, - {'name': 'Genre 2'} - ] + 'id': 'fake_artist_id', + 'name': artist } ] } - # Call the function to test - title, artist, year, genre = get_music_infos(search) - - # Assert the returned values - self.assertEqual(title, 'That\'s Rocking!') - self.assertEqual(artist, 'UltraSinger') - self.assertEqual(year, '2023-01-01') - self.assertEqual(genre, 'Genre 1,Genre 2,') + # image_data = musicbrainzngs.get_image_front(release['id']) + mock_get_image_front.return_value = b'fake image data' - @patch('musicbrainzngs.search_artists') - @patch('musicbrainzngs.search_release_groups') - def test_get_music_infos_when_title_and_artist_are_the_same(self, mock_search_release_groups, mock_search_artists): - # Arrange - artist = "ArtistIsTitle" - title = "ArtistIsTitle" - search_not_same = "ArtistIsTitle - ArtistNotTitle" - search_is_same = f"{artist} - {title}" - # Set up mock return values for the MusicBrainz API calls - mock_search_artists.return_value = { - 'artist-list': [ - {'name': artist} - ] - } - - mock_search_release_groups.return_value = { - 'release-group-list': [ + mock_get_image_list.return_value = { + 'images': [ { - 'title': title, - 'artist-credit-phrase': artist, + 'front': True, + 'image': 'https://example.com/image.jpg' } ] } - # Act search_not_same but musicbrainz returns the same artist and title - title, artist, year, genre = get_music_infos(search_not_same) + mock_get_release_group_by_id.return_value = { + 'release-group': { + 'first-release-date': '2023-01-01' + } + } - # Assert - self.assertEqual(title, None) - self.assertEqual(artist, None) - self.assertEqual(year, None) - self.assertEqual(genre, None) + mock_search_recordings.return_value = { + 'recording-list': [ + { + 'title': 'That\'s Rocking!', + 'artist-credit-phrase': artist, + 'release-list': [ + { + 'id': 'fake_release_id', + 'release-group': { + 'id': 'fake_group_id', + } + }, + ], + 'tag-list': [ + {'name': 'Genre 1'}, + {'name': 'Genre 2'}, + ], + 'artist-credit': [ + { + 'artist': {'id': 'fake_artist_id'} + } + ], + } + ]} - # Act search_is_same and musicbrainz returns the same artist and title - title, artist, year, genre = get_music_infos(search_is_same) + # Call the function to test + song_info_single_line = search_musicbrainz(f'{artist} - {title}', None) # Single line test - # Assert - self.assertEqual(title, 'ArtistIsTitle') - self.assertEqual(artist, 'ArtistIsTitle') - self.assertEqual(year, None) - self.assertEqual(genre, None) + # Assert the returned values + self.assertEqual(song_info_single_line.title, 'That\'s Rocking!') + self.assertEqual(song_info_single_line.artist, 'UltraSinger') + self.assertEqual(song_info_single_line.year, '2023') + self.assertEqual(song_info_single_line.genres, 'Genre 1,Genre 2,') + self.assertEqual(song_info_single_line.cover_image_data, b'fake image data') + self.assertEqual(song_info_single_line.cover_url, 'https://example.com/image.jpg') - @patch('musicbrainzngs.search_artists') - @patch('musicbrainzngs.search_release_groups') - def test_get_music_infos(self, mock_search_release_groups, mock_search_artists): - # Arrange - artist = 'UltraSinger' - title = 'That\'s Rocking!' - search = f'{artist} - {title} (UltrStar 2023) FULL HD' + song_info_multi_line = search_musicbrainz(title, artist) # multi line test - # Set up mock return values for the MusicBrainz API calls - mock_search_artists.return_value = { - 'artist-list': [ - {'name': f' {artist} '} # Also test leading and trailing whitespaces - ] - } + self.assertEqual(song_info_multi_line.title, 'That\'s Rocking!') + self.assertEqual(song_info_multi_line.artist, 'UltraSinger') + self.assertEqual(song_info_multi_line.year, '2023') + self.assertEqual(song_info_multi_line.genres, 'Genre 1,Genre 2,') + self.assertEqual(song_info_multi_line.cover_image_data, b'fake image data') + self.assertEqual(song_info_multi_line.cover_url, 'https://example.com/image.jpg') - mock_search_release_groups.return_value = { - 'release-group-list': [ - { - 'title': f' {title} ', # Also test leading and trailing whitespaces - 'artist-credit-phrase': f' {artist} ', # Also test leading and trailing whitespaces - 'first-release-date': ' 2023-01-01 ', # Also test leading and trailing whitespaces - 'tag-list': [ - {'name': ' Genre 1 '}, # Also test leading and trailing whitespaces - {'name': ' Genre 2 '} # Also test leading and trailing whitespaces - ] - } - ] - } - # Act - title, artist, year, genre = get_music_infos(search) - # Assert - self.assertEqual(title, 'That\'s Rocking!') - self.assertEqual(artist, 'UltraSinger') - self.assertEqual(year, '2023-01-01') - self.assertEqual(genre, 'Genre 1,Genre 2,') @patch('musicbrainzngs.search_artists') - @patch('musicbrainzngs.search_release_groups') - def test_get_empty_artist_music_infos(self, mock_search_release_groups, mock_search_artists): + @patch('musicbrainzngs.search_recordings') + def test_get_empty_artist_music_infos(self, mock_search_recordings, mock_search_artists): # Arrange artist = 'UltraSinger' - title = 'That\'s Rocking!' - search = f'{artist} - {title} (UltrStar 2023) FULL HD' + title = 'That\'s Rocking! (UltrStar 2023) FULL HD' # Set up mock return values for the MusicBrainz API calls mock_search_artists.return_value = { 'artist-list': [] } - mock_search_release_groups.return_value = { - 'release-group-list': [ + mock_search_recordings.return_value = { + 'recording-list': [ { - 'title': f' {title} ', # Also test leading and trailing whitespaces - 'artist-credit-phrase': f' {artist} ', # Also test leading and trailing whitespaces - 'first-release-date': ' 2023-01-01 ', # Also test leading and trailing whitespaces + 'title': 'That\'s Rocking!', + 'artist-credit-phrase': artist, + 'release-list': [ + { + 'id': 'fake_release_id', + 'release-group': { + 'id': 'fake_group_id', + } + }, + ], 'tag-list': [ - {'name': ' Genre 1 '}, # Also test leading and trailing whitespaces - {'name': ' Genre 2 '} # Also test leading and trailing whitespaces - ] + {'name': 'Genre 1'}, + {'name': 'Genre 2'}, + ], + 'artist-credit': [ + { + 'artist': {'id': 'fake_artist_id'} + } + ], } - ] - } + ]} # Act - title, artist, year, genre = get_music_infos(search) + song_info_single_line = search_musicbrainz(f'{artist} - {title}', None) # Single line test # Assert - self.assertEqual(title, None) - self.assertEqual(artist, None) - self.assertEqual(year, None) - self.assertEqual(genre, None) + self.assertEqual(song_info_single_line.title, f'{artist} - {title}') + self.assertEqual(song_info_single_line.artist, "Unknown Artist") + self.assertEqual(song_info_single_line.year, None) + self.assertEqual(song_info_single_line.genres, None) + self.assertEqual(song_info_single_line.cover_image_data, None) + self.assertEqual(song_info_single_line.cover_url, None) @patch('musicbrainzngs.search_artists') - @patch('musicbrainzngs.search_release_groups') - def test_get_empty_release_music_infos(self, mock_search_release_groups, mock_search_artists): + @patch('musicbrainzngs.search_recordings') + def test_get_empty_release_music_infos(self, mock_search_recordings, mock_search_artists): # Arrange artist = 'UltraSinger' - title = 'That\'s Rocking!' - search = f'{artist} - {title} (UltrStar 2023) FULL HD' + title = 'That\'s Rocking! (UltrStar 2023) FULL HD' # Set up mock return values for the MusicBrainz API calls mock_search_artists.return_value = { @@ -177,18 +156,56 @@ def test_get_empty_release_music_infos(self, mock_search_release_groups, mock_se ] } - mock_search_release_groups.return_value = { - 'release-group-list': [] + mock_search_recordings.return_value = { + 'recording-list': [] } # Act - title, artist, year, genre = get_music_infos(search) + song_info_single_line = search_musicbrainz(f'{artist} - {title}', None) # Single line test # Assert - self.assertEqual(title, None) - self.assertEqual(artist, None) - self.assertEqual(year, None) - self.assertEqual(genre, None) + self.assertEqual(song_info_single_line.title, f'{artist} - {title}') + self.assertEqual(song_info_single_line.artist, "Unknown Artist") + self.assertEqual(song_info_single_line.year, None) + self.assertEqual(song_info_single_line.genres, None) + self.assertEqual(song_info_single_line.cover_image_data, None) + self.assertEqual(song_info_single_line.cover_url, None) + + + @unittest.skip("Search with real data only test manually") + def test_search_musicbrainz_with_real_data(self): + + # Arrange + search_list = [ + # (search_artist, seartch_title, expected_artist, expected_title) + + (None, 't', None, None), # this should return "Unknown artist" + ('Căsuța noastră', 'Gică Petrescu', 'Gică Petrescu', 'Căsuța noastră'), # Gică Petrescu - Gică Petrescu + ("Shawn James - Through the Valley - Official Music Video", None, "Shawn James", "Through the Valley"), + # (None, 'Corey Taylor Snuff (Acoustic)', 'Corey Taylor', 'Snuff'), # Fixme: is wrong + # (None, 'Corey Taylor Snuff', 'Corey Taylor', 'Snuff'), # Fixme: is wrong + # (None, 'Songs für Liam Kraftklub', 'Kraftklub', 'Songs Für Liam'), # Fixme: is wrong + # ('Kummer feat. Fred Rabe', 'Der letzte Song (Alles wird gut)', 'Kummer feat. Fred Rabe', 'Der letzte Song (Alles wird gut)'), # Todo: Wrong image? + # ('Der letzte Song (Alles wird gut)', 'Kummer feat. Fred Rabe', 'Kummer feat. Fred Rabe', 'Der letzte Song (Alles wird gut)'), # Todo: Wrong image? + # (None, 'Der letzte Song (Alles wird gut) Kummer feat. Fred Rabe', 'Kummer feat. Fred Rabe', 'Der letzte Song (Alles wird gut)'), # Todo: Wrong image? + # (None, 'Thats life Shawn James', 'Shawn James', 'Thats life'), # Fixme: is wrong + # (None, 'Gloryhole Explicit Steel Panther', 'Steel Panther', 'Gloryhole Explicit'), # Fixme: is wrong + ] + + failed = 0 + success = 0 + count = 0 + for i, search_string in enumerate(search_list): + artist = search_string[0] + title = search_string[1] + print(f"({i}) - {artist} - {title}") + count = i + + song_info = search_musicbrainz(title, artist) + print(f'\t{search_string}\t -> {song_info.artist}, {song_info.title}, {song_info.year}, {song_info.genres}') + print('-------------------------------') + print(f"Faild: {failed} | Success: {success} Count: {count}") + if __name__ == '__main__': unittest.main() diff --git a/src/UltraSinger.py b/src/UltraSinger.py index 2e8daa1..75feb65 100644 --- a/src/UltraSinger.py +++ b/src/UltraSinger.py @@ -58,11 +58,12 @@ from modules.common_print import print_support, print_help, print_version from modules.os_helper import check_file_exists, get_unused_song_output_dir from modules.plot import create_plots -from modules.musicbrainz_client import get_music_infos +from modules.musicbrainz_client import search_musicbrainz from modules.sheet import create_sheet from modules.ProcessData import ProcessData, ProcessDataPaths, MediaInfo from modules.DeviceDetection.device_detection import check_gpu_support from modules.Audio.bpm import get_bpm_from_file +from modules.Image.image_helper import save_image from Settings import Settings @@ -506,24 +507,13 @@ def infos_from_audio_input_file() -> tuple[str, str, str, MediaInfo]: artist, title = None, None if " - " in basename_without_ext: artist, title = basename_without_ext.split(" - ", 1) - search_string = f"{artist} - {title}" - else: - search_string = basename_without_ext - - # Get additional data for song - (title_info, artist_info, year_info, genre_info) = get_music_infos(search_string) - - if title_info is not None: - title = title_info - artist = artist_info else: title = basename_without_ext - artist = "Unknown Artist" - if artist is not None and title is not None: - basename_without_ext = f"{artist} - {title}" - extension = os.path.splitext(basename)[1] - basename = f"{basename_without_ext}{extension}" + song_info = search_musicbrainz(title, artist) + basename_without_ext = f"{song_info.artist} - {song_info.title}" + extension = os.path.splitext(basename)[1] + basename = f"{basename_without_ext}{extension}" song_folder_output_path = os.path.join(settings.output_folder_path, basename_without_ext) song_folder_output_path = get_unused_song_output_dir(song_folder_output_path) @@ -534,13 +524,15 @@ def infos_from_audio_input_file() -> tuple[str, str, str, MediaInfo]: os.path.join(song_folder_output_path, basename), ) # Todo: Read ID3 tags + if song_info.cover_image_data is not None: + save_image(song_info.cover_image_data, basename_without_ext, song_folder_output_path) ultrastar_audio_input_path = os.path.join(song_folder_output_path, basename) real_bpm = get_bpm_from_file(settings.input_file_path) return ( basename_without_ext, song_folder_output_path, ultrastar_audio_input_path, - MediaInfo(artist=artist, title=title, year=year_info, genre=genre_info, bpm=real_bpm), + MediaInfo(artist=song_info.artist, title=song_info.title, year=song_info.year, genre=song_info.genres, bpm=real_bpm, cover_url=song_info.cover_url), ) diff --git a/src/modules/Audio/youtube.py b/src/modules/Audio/youtube.py index 38cece8..13bab5c 100644 --- a/src/modules/Audio/youtube.py +++ b/src/modules/Audio/youtube.py @@ -1,18 +1,15 @@ """YouTube Downloader""" -import io import os - import yt_dlp -from PIL import Image from modules.os_helper import sanitize_filename, get_unused_song_output_dir from modules import os_helper from modules.ProcessData import MediaInfo from modules.Audio.bpm import get_bpm_from_file from modules.console_colors import ULTRASINGER_HEAD -from modules.Image.image_helper import crop_image_to_square -from modules.musicbrainz_client import get_music_infos +from modules.Image.image_helper import save_image +from modules.musicbrainz_client import search_musicbrainz def get_youtube_title(url: str, cookiefile: str = None) -> tuple[str, str]: @@ -72,11 +69,7 @@ def download_and_convert_thumbnail(ydl_opts, url: str, clear_filename: str, outp if thumbnail_url: response = ydl.urlopen(thumbnail_url) image_data = response.read() - image = Image.open(io.BytesIO(image_data)) - image = image.convert('RGB') # Convert to RGB to avoid transparency or RGBA issues - image_path = os.path.join(output_path, clear_filename + " [CO].jpg") - image.save(image_path, "JPEG") - crop_image_to_square(image_path) + save_image(image_data, clear_filename, output_path) return thumbnail_url else: return "" @@ -108,23 +101,22 @@ def download_from_youtube(input_url: str, output_folder_path: str, cookiefile: s (artist, title) = get_youtube_title(input_url, cookiefile) # Get additional data for song - (title_info, artist_info, year_info, genre_info) = get_music_infos( - f"{artist} - {title}" - ) + song_info = search_musicbrainz(title, artist) - if title_info is not None: - title = title_info - artist = artist_info - - basename_without_ext = sanitize_filename(f"{artist} - {title}") + basename_without_ext = sanitize_filename(f"{song_info.artist} - {song_info.title}") basename = basename_without_ext + ".mp3" song_output = os.path.join(output_folder_path, basename_without_ext) song_output = get_unused_song_output_dir(song_output) os_helper.create_folder(song_output) __download_youtube_audio(input_url, basename_without_ext, song_output, cookiefile) __download_youtube_video(input_url, basename_without_ext, song_output, cookiefile) - thumbnail_url = __download_youtube_thumbnail( - input_url, basename_without_ext, song_output + + if song_info.cover_url is not None and song_info.cover_image_data is not None: + cover_url = song_info.cover_url + save_image(song_info.cover_image_data, basename_without_ext, song_output) + else: + cover_url = __download_youtube_thumbnail( + input_url, basename_without_ext, song_output ) audio_file_path = os.path.join(song_output, basename) real_bpm = get_bpm_from_file(audio_file_path) @@ -132,6 +124,6 @@ def download_from_youtube(input_url: str, output_folder_path: str, cookiefile: s basename_without_ext, song_output, audio_file_path, - MediaInfo(artist=artist, title=title, year=year_info, genre=genre_info, bpm=real_bpm, - youtube_thumbnail_url=thumbnail_url, youtube_video_url=input_url), + MediaInfo(artist=song_info.artist, title=song_info.title, year=song_info.year, genre=song_info.genres, bpm=real_bpm, + cover_url=cover_url, video_url=input_url), ) diff --git a/src/modules/Image/image_helper.py b/src/modules/Image/image_helper.py index 2dd02d0..a87fc1d 100644 --- a/src/modules/Image/image_helper.py +++ b/src/modules/Image/image_helper.py @@ -1,6 +1,19 @@ +"""Image file helper""" + +import io +import os + from PIL import Image +def save_image(image_data: bytes, clear_filename: str, output_path: str) -> None: + image = Image.open(io.BytesIO(image_data)) + image = image.convert('RGB') # Convert to RGB to avoid transparency or RGBA issues + image_path = os.path.join(output_path, clear_filename + " [CO].jpg") + image.save(image_path, "JPEG") + crop_image_to_square(image_path) + + def crop_image_to_square(image_path): image = Image.open(image_path) width, height = image.size @@ -15,4 +28,4 @@ def crop_image_to_square(image_path): cropped_image = image.crop((left, top, right, bottom)) # Override the original image with the cropped version - cropped_image.save(image_path) \ No newline at end of file + cropped_image.save(image_path) diff --git a/src/modules/ProcessData.py b/src/modules/ProcessData.py index 8ca6a62..13a95cf 100644 --- a/src/modules/ProcessData.py +++ b/src/modules/ProcessData.py @@ -23,8 +23,8 @@ class MediaInfo: year: Optional[str] = None genre: Optional[str] = None language: Optional[str] = None - youtube_thumbnail_url: Optional[str] = None - youtube_video_url: Optional[str] = None + cover_url: Optional[str] = None + video_url: Optional[str] = None @dataclass class ProcessData: diff --git a/src/modules/Ultrastar/coverter/ultrastar_txt_converter.py b/src/modules/Ultrastar/coverter/ultrastar_txt_converter.py index a37b17a..2ce87b0 100644 --- a/src/modules/Ultrastar/coverter/ultrastar_txt_converter.py +++ b/src/modules/Ultrastar/coverter/ultrastar_txt_converter.py @@ -86,8 +86,10 @@ def create_ultrastar_txt_from_automation( ultrastar_txt.year = extract_year(media_info.year) if media_info.genre is not None: ultrastar_txt.genre = format_separated_string(media_info.genre) - if media_info.youtube_video_url is not None: - ultrastar_txt.videoUrl = media_info.youtube_video_url + if media_info.video_url is not None: + ultrastar_txt.videoUrl = media_info.video_url + if media_info.cover_url is not None: + ultrastar_txt.coverUrl = media_info.cover_url ultrastar_file_output_path = os.path.join(song_folder_output_path, basename + ".txt") create_ultrastar_txt( @@ -109,7 +111,7 @@ def create_ultrastar_txt_from_automation( media_info.bpm, ) if version.parse(format_version.value) < version.parse(FormatVersion.V1_2_0.value): - ultrastar_txt.videoUrl = media_info.youtube_video_url + ultrastar_txt.videoUrl = media_info.video_url return ultrastar_file_output_path diff --git a/src/modules/Ultrastar/ultrastar_txt.py b/src/modules/Ultrastar/ultrastar_txt.py index b82590b..2e61932 100644 --- a/src/modules/Ultrastar/ultrastar_txt.py +++ b/src/modules/Ultrastar/ultrastar_txt.py @@ -129,6 +129,7 @@ class UltrastarTxtValue: bpm = "" language = None cover = None + coverUrl = None background = None vocals = None instrumental = None diff --git a/src/modules/Ultrastar/ultrastar_writer.py b/src/modules/Ultrastar/ultrastar_writer.py index 945affd..b7eb924 100644 --- a/src/modules/Ultrastar/ultrastar_writer.py +++ b/src/modules/Ultrastar/ultrastar_writer.py @@ -64,6 +64,9 @@ def create_ultrastar_txt( file.write(f"#{UltrastarTxtTag.GENRE}:{ultrastar_class.genre}\n") if ultrastar_class.cover is not None: file.write(f"#{UltrastarTxtTag.COVER}:{ultrastar_class.cover}\n") + if version.parse(ultrastar_class.version) >= version.parse("1.2.0"): + if ultrastar_class.coverUrl is not None: + file.write(f"#{UltrastarTxtTag.COVERURL}:{ultrastar_class.coverUrl}\n") if ultrastar_class.background is not None: file.write(f"#{UltrastarTxtTag.BACKGROUND}:{ultrastar_class.background}\n") file.write(f"#{UltrastarTxtTag.MP3}:{ultrastar_class.mp3}\n") diff --git a/src/modules/musicbrainz_client.py b/src/modules/musicbrainz_client.py index 0d07f73..f945aa7 100644 --- a/src/modules/musicbrainz_client.py +++ b/src/modules/musicbrainz_client.py @@ -1,64 +1,204 @@ import musicbrainzngs import string +from Levenshtein import ratio +from dataclasses import dataclass +from typing import Optional +from src.Settings import Settings + from modules.console_colors import ULTRASINGER_HEAD, blue_highlighted, red_highlighted -def get_music_infos(search_string: str) -> tuple[str, str, str, str]: - print(f"{ULTRASINGER_HEAD} Searching song in {blue_highlighted('musicbrainz')}") - # https://python-musicbrainzngs.readthedocs.io/en/v0.7.1/usage/#searching +@dataclass +class SongInfo: + title: str + artist: str + year: Optional[str] = None + genres: Optional[str] = None + cover_image_data: Optional[bytes] = None + cover_url: Optional[str] = None + + +title_filter = [ + "official video", + "official music video", + "Offizielles Musikvideo", +] + + +def __clean_string(s: str) -> str: + return s.translate(str.maketrans('', '', string.punctuation)).lower().strip() - musicbrainzngs.set_useragent("UltraSinger", "0.1", "https://github.com/rakuri255/UltraSinger") - # search for artist and titel to get release on the first place - artist = None - artists = musicbrainzngs.search_artists(search_string) - if len(artists['artist-list']) != 0: - artist = artists['artist-list'][0]['name'].strip() +def search_musicbrainz(title: str, artist) -> SongInfo: + # Musicbrainz API documentation + # https://python-musicbrainzngs.readthedocs.io/en/latest/api/ + + musicbrainzngs.set_useragent("UltraSinger", Settings.APP_VERSION, "https://github.com/rakuri255/UltraSinger") + + # remove from search_string "official video" + # todo: do we need filter? + origin_title = title + for filter in title_filter: + title = title.lower().replace(filter.lower(), "").strip() + if artist is not None: + artist = artist.lower().replace(filter.lower(), "").strip() + + if artist is None: + recording = __single_line_search(title) else: + recording = __multi_line_search(artist, title) + + if recording is None: print(f"{ULTRASINGER_HEAD} {red_highlighted('No match found')}") - return None, None, None, None + return SongInfo(title=origin_title, artist="Unknown Artist") + + artist = recording['artist-credit-phrase'] + title = recording['title'] + print( + f"{ULTRASINGER_HEAD} Found data on Musicbrainz: Artist={blue_highlighted(artist)} Title={blue_highlighted(title)}") + + year = __get_year(recording) + genres = __get_genres(recording) + image_data, image_url = __get_image(recording) + + return SongInfo(title=title, artist=artist, year=year, genres=genres, cover_image_data=image_data, cover_url=image_url) + + +def __single_line_search(search_string): + search_string = __clean_string(search_string) + search_string = __filter_words(search_string) + + artists = musicbrainzngs.search_artists(search_string, limit=10, artist=search_string) + recordings = musicbrainzngs.search_recordings(search_string, limit=100, artistname=search_string) + found_artist = None + + for record in recordings['recording-list']: + if found_artist is not None: + break + + for artist_credit in record['artist-credit']: + if found_artist is not None: + break + if isinstance(artist_credit, str): + continue + # todo: there is also an "alias-list". Maybe search also there? + + for artist in artists['artist-list']: + if artist_credit['artist'] and artist_credit['artist']['id'] == artist['id']: + found_artist = record['artist-credit-phrase'] + break + + if found_artist is None: + return None + + recordings = [x for x in recordings['recording-list'] if + __clean_string(x['artist-credit-phrase']) == __clean_string(found_artist)] - release = None - release_groups = musicbrainzngs.search_release_groups(search_string, artist=artist) - if len(release_groups['release-group-list']) != 0: - release = release_groups['release-group-list'][0] + recording = None - if release is not None and 'artist-credit-phrase' in release: - artist = release['artist-credit-phrase'].strip() + for record in recordings: + if __clean_string(record['title']) in __clean_string(search_string): + recording = record - title = None - if release is not None and 'title' in release: - clean_search_string = search_string.translate(str.maketrans('', '', string.punctuation)).lower().strip() - clean_release_title = release['title'].translate(str.maketrans('', '', string.punctuation)).lower().strip() - clean_artist = artist.translate(str.maketrans('', '', string.punctuation)).lower().strip() + return recording - # prepare search string when title and artist are the same - if clean_release_title == clean_artist: - # remove the first appearance of the artist - clean_search_string = clean_search_string.replace(clean_artist, "", 1) - if clean_release_title in clean_search_string: - title = release['title'].strip() +def __filter_words(search_string): + for filter in title_filter: + search_string = search_string.lower().replace(filter.lower(), "").strip() + return search_string + + +def __multi_line_search(artist: str, title: str): + # Try both combinations since we don't know which one is the artist and which one is the title + artist1, title1 = artist, title + artist2, title2 = title, artist + + result1 = musicbrainzngs.search_recordings(recording=title1, limit=10, artist=artist1, artistname=artist1) + result2 = musicbrainzngs.search_recordings(recording=title2, limit=10, artist=artist2, artistname=artist2) + + # Filter result to ['artist-credit-phrase'] == artist + record1 = [x for x in result1['recording-list'] if + __clean_string(x['artist-credit-phrase']) == __clean_string(artist1)] + record2 = [x for x in result2['recording-list'] if + __clean_string(x['artist-credit-phrase']) == __clean_string(artist2)] + + if len(record1) > 0 and len(record2) > 0: + best_match1 = max(record1, key=lambda x: ratio(__clean_string(x['title']), __clean_string(title1))) + best_match2 = max(record2, key=lambda x: ratio(__clean_string(x['title']), __clean_string(title2))) + + is_match1 = ratio(__clean_string(title1), __clean_string(best_match1['title'])) > ratio(__clean_string(title2), + __clean_string( + best_match2[ + 'title'])) + + if is_match1: + recording = record1[0] else: - print( - f"{ULTRASINGER_HEAD} cant find title {red_highlighted(clean_release_title)} in {red_highlighted(clean_search_string)}") + recording = record2[0] - if title is None or artist is None: - print(f"{ULTRASINGER_HEAD} {red_highlighted('No match found')}") - return None, None, None, None + elif len(record1) > 0: + recording = record1[0] + elif len(record2) > 0: + recording = record2[0] + elif result1['recording-count'] > 0: # Artist = Title + recording = result1['recording-list'][0] + elif result2['recording-count'] > 0: # Artist = Title + recording = result2['recording-list'][0] + else: + recording = None + + return recording - print(f"{ULTRASINGER_HEAD} Found Titel and Artist {blue_highlighted(title)} by {blue_highlighted(artist)}") +def __get_image(recording) -> (bytes, str): + image_data = None + image_url = None + if 'release-list' in recording: + for release in recording['release-list']: + try: + image_data = musicbrainzngs.get_image_front(release['id']) + image_list = musicbrainzngs.get_image_list(release['id']) + for image in image_list['images']: + if image['front']: + image_url = image['image'] + break + break + except musicbrainzngs.ResponseError: + continue + if image_data is not None: + print(f"{ULTRASINGER_HEAD} Found cover image") + + return image_data, image_url + + +def __get_year(recording): year = None - if 'first-release-date' in release: - year = release['first-release-date'].strip() - print(f"{ULTRASINGER_HEAD} Found release year: {blue_highlighted(year)}") + if 'release-list' not in recording: + return year + + release_group_id = recording['release-list'][0]['release-group']['id'] + release_group = musicbrainzngs.get_release_group_by_id(release_group_id) + if 'first-release-date' not in release_group['release-group']: + return year + + year = release_group['release-group']['first-release-date'].strip() + year = year.split('-')[0] + + if year is not None: + print(f"{ULTRASINGER_HEAD} Found year: {blue_highlighted(year)}") + + return year + + +def __get_genres(recording) -> str: + # todo secondary-type-list ?? genres = None - if 'tag-list' in release: + if 'tag-list' in recording: genres = "" - for tag in release['tag-list']: + for tag in recording['tag-list']: genres += f"{tag['name'].strip()}," + if genres is not None: print(f"{ULTRASINGER_HEAD} Found genres: {blue_highlighted(genres)}") - - return title, artist, year, genres + return genres