diff --git a/README.md b/README.md index 66133b0..fa70eb9 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,16 @@ If you run it on a Photo Database file, you'll see the list of all images stored Equalizer settings CSV: ![Equalizer settings CSV screenshot](./docs/20241126_equalizer-csv-example.png) +# Extras + +There's 2 extra utilities that may be of use: + +Spotify logo + +* Spotify integration: This creates a Spotify playlist out of the songs that were found on your iPod. See the README in that directory for more. + +* Song renaming functionality: iPods (generally ?) store the song files on their hard drive, however, the filenames are usually just a generic unique ID. I wrote a Python script that lets you rename the songs to have the song title and artist name instead, using the data that is in the iTunesDB file. See the README in that directory for more information. + # Future roadmap This project is a very early work-in-progress. The next major feature to come is [iThumb file decoding](https://github.com/raleighlittles/iTunesDB-Parser/issues/4) diff --git a/docs/Spotify_Primary_Logo_RGB_Black.png b/docs/Spotify_Primary_Logo_RGB_Black.png new file mode 100644 index 0000000..1fbb218 Binary files /dev/null and b/docs/Spotify_Primary_Logo_RGB_Black.png differ diff --git a/parser/src/parsers/itunesdb_parser.rs b/parser/src/parsers/itunesdb_parser.rs index d0c7edd..e0e7bd9 100644 --- a/parser/src/parsers/itunesdb_parser.rs +++ b/parser/src/parsers/itunesdb_parser.rs @@ -6,9 +6,7 @@ use crate::itunesdb; use crate::helpers::helpers; use crate::helpers::itunesdb_helpers; - -pub fn parse_itunesdb_file(itunesdb_file_as_bytes : Vec) { - +pub fn parse_itunesdb_file(itunesdb_file_as_bytes: Vec) { let mut music_csv_writer = helpers::init_csv_writer("music.csv"); let mut podcast_csv_writer = helpers::init_csv_writer("podcasts.csv"); @@ -23,7 +21,8 @@ pub fn parse_itunesdb_file(itunesdb_file_as_bytes : Vec) { let mut idx = 0; while idx < (itunesdb_file_as_bytes.len() - itunesdb_constants::DEFAULT_SUBSTRUCTURE_SIZE) { - let potential_section_heading = &itunesdb_file_as_bytes[idx..idx + itunesdb_constants::DEFAULT_SUBSTRUCTURE_SIZE]; + let potential_section_heading = + &itunesdb_file_as_bytes[idx..idx + itunesdb_constants::DEFAULT_SUBSTRUCTURE_SIZE]; // Parse Database Object if potential_section_heading == itunesdb_constants::DATABASE_OBJECT_KEY.as_bytes() { @@ -52,7 +51,6 @@ pub fn parse_itunesdb_file(itunesdb_file_as_bytes : Vec) { } // Parse DataSet else if potential_section_heading == itunesdb_constants::DATASET_KEY.as_bytes() { - let dataset_type_raw = helpers::get_slice_from_offset_with_len( idx, &itunesdb_file_as_bytes, @@ -138,8 +136,7 @@ pub fn parse_itunesdb_file(itunesdb_file_as_bytes : Vec) { if helpers::build_le_u32_from_bytes(track_filetype_raw) == 0 { println!("Track Item file type missing. Is this is a 1st - 4th gen iPod?"); } else { - let track_item_extension = - itunesdb::decode_track_item_filetype(track_filetype_raw); + let track_item_extension = itunesdb::decode_track_item_filetype(track_filetype_raw); write!( track_item_info, "Track extension: '{}' | ", @@ -202,7 +199,6 @@ pub fn parse_itunesdb_file(itunesdb_file_as_bytes : Vec) { track_media_type_enum, itunesdb::HandleableMediaType::SongLike ) { - curr_media_type = track_media_type_enum; let track_advanced_audio_type = helpers::get_slice_as_le_u32( @@ -281,7 +277,6 @@ pub fn parse_itunesdb_file(itunesdb_file_as_bytes : Vec) { curr_song.bitrate_kbps = track_bitrate; curr_song.sample_rate_hz = track_sample_rate_hz; - let track_size_bytes = helpers::get_slice_as_le_u32( idx, &itunesdb_file_as_bytes, @@ -375,7 +370,11 @@ pub fn parse_itunesdb_file(itunesdb_file_as_bytes : Vec) { itunesdb_constants::TRACK_ITEM_TRACK_LAST_SKIPPED_TIMESTAMP_LEN, ); - let track_skip_when_shuffle_setting = &itunesdb_file_as_bytes[idx + itunesdb_constants::TRACK_ITEM_TRACK_SKIP_WHEN_SHUFFLING_SETTING_OFFSET .. idx + itunesdb_constants::TRACK_ITEM_TRACK_SKIP_WHEN_SHUFFLING_SETTING_OFFSET + itunesdb_constants::TRACK_ITEM_TRACK_SKIP_WHEN_SHUFFLING_SETTING_LEN]; + let track_skip_when_shuffle_setting = &itunesdb_file_as_bytes[idx + + itunesdb_constants::TRACK_ITEM_TRACK_SKIP_WHEN_SHUFFLING_SETTING_OFFSET + ..idx + + itunesdb_constants::TRACK_ITEM_TRACK_SKIP_WHEN_SHUFFLING_SETTING_OFFSET + + itunesdb_constants::TRACK_ITEM_TRACK_SKIP_WHEN_SHUFFLING_SETTING_LEN]; write!(track_item_info, "Play/Skip statistics: # of plays: {} , Last played on: {} | # of skips: {}, Last skipped on: {} (Skip when shuffling? {}) ", track_play_count, track_last_played_timestamp, track_skipped_count, track_last_skipped_timestamp, track_skip_when_shuffle_setting[0] ).unwrap(); @@ -432,7 +431,12 @@ pub fn parse_itunesdb_file(itunesdb_file_as_bytes : Vec) { ); if gapless_playback_setting_for_track == 1 { - let num_beginning_silence_samples = helpers::get_slice_as_le_u32(idx, &itunesdb_file_as_bytes, itunesdb_constants::TRACK_ITEM_TRACK_BEGINNING_SILENCE_SAMPLE_COUNT_OFFSET, itunesdb_constants::TRACK_ITEM_TRACK_BEGINNING_SILENCE_SAMPLE_COUNT_LEN); + let num_beginning_silence_samples = helpers::get_slice_as_le_u32( + idx, + &itunesdb_file_as_bytes, + itunesdb_constants::TRACK_ITEM_TRACK_BEGINNING_SILENCE_SAMPLE_COUNT_OFFSET, + itunesdb_constants::TRACK_ITEM_TRACK_BEGINNING_SILENCE_SAMPLE_COUNT_LEN, + ); let num_ending_silence_samples = helpers::get_slice_as_le_u32( idx, @@ -514,13 +518,6 @@ pub fn parse_itunesdb_file(itunesdb_file_as_bytes : Vec) { curr_song.song_year = track_year_released as u16; } - // let track_added_timestamp = helpers::get_slice_as_mac_timestamp( - // idx, - // &itunesdb_file_as_bytes, - // itunesdb_constants::TRACK_ITEM_TRACK_ADDED_TIMESTAMP_OFFSET, - // itunesdb_constants::TRACK_ITEM_TRACK_ADDED_TIMESTAMP_LEN, - // ); - let track_added_epoch = helpers::get_slice_as_le_u32( idx, &itunesdb_file_as_bytes, @@ -542,40 +539,50 @@ pub fn parse_itunesdb_file(itunesdb_file_as_bytes : Vec) { .unwrap(); } - let track_modified_timestamp = helpers::get_slice_as_mac_timestamp( + let track_modified_epoch = helpers::get_slice_as_le_u32( idx, &itunesdb_file_as_bytes, itunesdb_constants::TRACK_ITEM_TRACK_MODIFIED_TIME_OFFSET, itunesdb_constants::TRACK_ITEM_TRACK_MODIFIED_TIME_LEN, ); - let track_published_to_store_timestamp: chrono::DateTime = - helpers::get_slice_as_mac_timestamp( - idx, - &itunesdb_file_as_bytes, - itunesdb_constants::TRACK_ITEM_TRACK_RELEASED_TIMESTAMP_OFFSET, - itunesdb_constants::TRACK_ITEM_TRACK_RELEASED_TIMESTAMP_LEN, - ); - - write!( - track_item_info, - "Last modified: {} Published to iTunes store: {}", - track_modified_timestamp, track_published_to_store_timestamp - ) - .unwrap(); + if track_modified_epoch > 0 { + let track_modified_timestamp = + helpers::get_timestamp_as_mac(track_modified_epoch as u64); + write!( + track_item_info, + "Track last modified: {} | ", + track_modified_timestamp + ).unwrap(); + } - //println!("{} \n", track_item_info); - } + let track_published_to_store_epoch = helpers::get_slice_as_le_u32( + idx, + &itunesdb_file_as_bytes, + itunesdb_constants::TRACK_ITEM_TRACK_RELEASED_TIMESTAMP_OFFSET, + itunesdb_constants::TRACK_ITEM_TRACK_RELEASED_TIMESTAMP_LEN, + ); - else if matches!( - track_media_type_enum, - itunesdb::HandleableMediaType::Podcast) { + if track_published_to_store_epoch > 0 { + let track_published_to_store_timestamp: chrono::DateTime = + helpers::get_timestamp_as_mac(track_published_to_store_epoch as u64); - println!("TrackItem: Podcast found"); + write!( + track_item_info, + "Date published on iTunes: {}", + track_published_to_store_timestamp + ).unwrap(); + } - curr_media_type = track_media_type_enum; + println!("{} \n", track_item_info); + } else if matches!( + track_media_type_enum, + itunesdb::HandleableMediaType::Podcast + ) { + println!("TrackItem: Podcast found"); - } + curr_media_type = track_media_type_enum; + } idx += itunesdb_constants::TRACK_ITEM_LAST_OFFSET; } else if potential_section_heading == itunesdb_constants::PLAYLIST_KEY.as_bytes() { @@ -624,8 +631,7 @@ pub fn parse_itunesdb_file(itunesdb_file_as_bytes : Vec) { //println!("{} ====", playlist_info); idx += itunesdb_constants::PLAYLIST_LAST_OFFSET; - } else if potential_section_heading == itunesdb_constants::PLAYLIST_ITEM_KEY.as_bytes() - { + } else if potential_section_heading == itunesdb_constants::PLAYLIST_ITEM_KEY.as_bytes() { let mut playlist_item_info: String = "-----".to_string(); let playlist_item_added_timestamp = helpers::get_slice_as_mac_timestamp( @@ -711,10 +717,9 @@ pub fn parse_itunesdb_file(itunesdb_file_as_bytes : Vec) { ); // let data_object_str = std::str::from_utf8(&data_object_str_bytes).expect("Can't parse string data object!"); - let data_object_str = String::from_utf16(&helpers::return_utf16_from_utf8( - &data_object_str_bytes, - )) - .expect("Can't decode string to UTF-16"); + let data_object_str = + String::from_utf16(&helpers::return_utf16_from_utf8(&data_object_str_bytes)) + .expect("Can't decode string to UTF-16"); write!( data_object_info, @@ -724,59 +729,40 @@ pub fn parse_itunesdb_file(itunesdb_file_as_bytes : Vec) { .unwrap(); // We've found a title, now, use the TrackItem info to determine if the title is for a song or for a podcast - if data_object_type_raw == itunesdb::HandleableDataObjectType::Title as u32 - { + if data_object_type_raw == itunesdb::HandleableDataObjectType::Title as u32 { if curr_media_type == itunesdb::HandleableMediaType::SongLike { curr_song.song_title = data_object_str; - } - else if curr_media_type == itunesdb::HandleableMediaType::Podcast { + } else if curr_media_type == itunesdb::HandleableMediaType::Podcast { curr_podcast.podcast_title = data_object_str; } - } - else if data_object_type_raw - == itunesdb::HandleableDataObjectType::Album as u32 - { + } else if data_object_type_raw == itunesdb::HandleableDataObjectType::Album as u32 { curr_song.song_album = data_object_str; - - } else if data_object_type_raw - == itunesdb::HandleableDataObjectType::Artist as u32 + } else if data_object_type_raw == itunesdb::HandleableDataObjectType::Artist as u32 { if curr_media_type == itunesdb::HandleableMediaType::SongLike { curr_song.song_artist = data_object_str; - } - else if curr_media_type == itunesdb::HandleableMediaType::Podcast { + } else if curr_media_type == itunesdb::HandleableMediaType::Podcast { curr_podcast.podcast_publisher = data_object_str; } - - } else if data_object_type_raw - == itunesdb::HandleableDataObjectType::Genre as u32 - { + } else if data_object_type_raw == itunesdb::HandleableDataObjectType::Genre as u32 { if curr_media_type == itunesdb::HandleableMediaType::SongLike { curr_song.song_genre = data_object_str; - } - - else if curr_media_type == itunesdb::HandleableMediaType::Podcast { + } else if curr_media_type == itunesdb::HandleableMediaType::Podcast { if curr_podcast.podcast_genre.is_empty() { curr_podcast.podcast_genre = data_object_str; } } - - } else if data_object_type_raw - == itunesdb::HandleableDataObjectType::Comment as u32 + } else if data_object_type_raw == itunesdb::HandleableDataObjectType::Comment as u32 { if curr_media_type == itunesdb::HandleableMediaType::SongLike { - curr_song.song_comment = data_object_str; - } else if curr_media_type == itunesdb::HandleableMediaType::Podcast { curr_podcast.podcast_subtitle = data_object_str; } - } else if data_object_type_raw == itunesdb::HandleableDataObjectType::Composer as u32 { curr_song.song_composer = data_object_str; - } else if data_object_type_raw == itunesdb::HandleableDataObjectType::FileLocation as u32 { @@ -785,26 +771,21 @@ pub fn parse_itunesdb_file(itunesdb_file_as_bytes : Vec) { if curr_song.are_enough_fields_valid() { songs_found.push(curr_song); curr_song = itunesdb::Song::default(); - } - } - else if data_object_type_raw == itunesdb::HandleableDataObjectType::FileType as u32 { - + } else if data_object_type_raw + == itunesdb::HandleableDataObjectType::FileType as u32 + { if curr_media_type == itunesdb::HandleableMediaType::Podcast { - curr_podcast.podcast_file_type = data_object_str; } - - } - else if data_object_type_raw == itunesdb::HandleableDataObjectType::PodcastDescription as u32 { - + } else if data_object_type_raw + == itunesdb::HandleableDataObjectType::PodcastDescription as u32 + { if curr_media_type == itunesdb::HandleableMediaType::Podcast { - curr_podcast.podcast_description = data_object_str; } if !curr_podcast.podcast_title.is_empty() { - podcasts_found.push(curr_podcast); curr_podcast = itunesdb::Podcast::default(); } @@ -840,25 +821,28 @@ pub fn parse_itunesdb_file(itunesdb_file_as_bytes : Vec) { println!("{} songs found", songs_found.len()); - - podcast_csv_writer.write_record(&[ - "Episode Title", - "Publisher", - "Genre", - "Subtitle", - "Description", - "File Type" - ]).expect("Error can't create CSV file headers for podcast file"); + podcast_csv_writer + .write_record(&[ + "Episode Title", + "Publisher", + "Genre", + "Subtitle", + "Description", + "File Type", + ]) + .expect("Error can't create CSV file headers for podcast file"); for episode in podcasts_found.iter() { - podcast_csv_writer.write_record(&[ - episode.podcast_title.to_string(), - episode.podcast_publisher.to_string(), - episode.podcast_genre.to_string(), - episode.podcast_subtitle.to_string(), - episode.podcast_description.to_string().replace("\n", ""), - episode.podcast_file_type.to_string() - ]).expect("Can't write row to podcast CSV file"); + podcast_csv_writer + .write_record(&[ + episode.podcast_title.to_string(), + episode.podcast_publisher.to_string(), + episode.podcast_genre.to_string(), + episode.podcast_subtitle.to_string(), + episode.podcast_description.to_string().replace("\n", ""), + episode.podcast_file_type.to_string(), + ]) + .expect("Can't write row to podcast CSV file"); } music_csv_writer @@ -914,4 +898,4 @@ pub fn parse_itunesdb_file(itunesdb_file_as_bytes : Vec) { ]) .expect("Can't write row to CSV"); } -} \ No newline at end of file +} diff --git a/song_file_renamer/.gitignore b/song_file_renamer/.gitignore new file mode 100644 index 0000000..fbae690 --- /dev/null +++ b/song_file_renamer/.gitignore @@ -0,0 +1,166 @@ +# Don't check in audio files +*.mp3 +*.m4a + +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/latest/usage/project/#working-with-version-control +.pdm.toml +.pdm-python +.pdm-build/ + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ \ No newline at end of file diff --git a/song_file_renamer/README.md b/song_file_renamer/README.md new file mode 100644 index 0000000..dd4b2fa --- /dev/null +++ b/song_file_renamer/README.md @@ -0,0 +1,62 @@ +# About + +This is a Python script that allows you to automatically rename songs on your iPod according to the info in the iTunesDB file, for recovery purposes. + +To start, you must've run the parser tool on your iTunesDB file (see README in repository root) and had it produce a CSV file. + +You will then copy the song files on your iPod to a separate directory (do NOT override/rename files on your actual iPod itself) on your host PC. + +```bash +$ find -name "*.mp3|*.m4a" -type f -exec cp {} ./ \; +``` + +Then run this script, passing in the path to the CSV file you generated, and the directory where the song files are. + +This script will rename those song files using the song title and song artist in the CSV file. + +ie.. + +``` +./RBCG.mp4 +./REXL.mp3 +./LVXD.mp3 +./NNMT.mp3 +./SEMY.mp3 +./MKIV.mp3 +./QPMR.mp3 +./NEWF.mp3 +./KSMR.mp3 +./TWZE.mp3 +``` + +can become: + +``` +./'Spit It Out - Slipknot.m4a' +./'Storming The Gates Of Hell - Impending Doom.mp3' +./'Strife (Chug Chug) - As Blood Runs Black.mp3' +./'Sulfur - Slipknot.mp3' +./'The Darkest Day Of Man - Whitechapel.mp3' +./'The Day Of Justice - Whitechapel.mp3' +./'There Is No Business To Be Done On A Dead Planet - As Blood Runs Black.mp3' +./'The Takeover - Born of Osiris.mp3' +./'The Tragic Truth - Five Finger Death Punch.mp3' +./'The True Beast - All Shall Perish.mp3' + +``` + +# Usage info + +``` +usage: Apply recovered song data to music files on an iPod [-h] -i SONG_DIRECTORY -c ITUNES_CSV -o OUTPUT_DIRECTORY + +options: + -h, --help show this help message and exit + -i SONG_DIRECTORY, --song-directory SONG_DIRECTORY + Directory to look for songs + -c ITUNES_CSV, --itunes-csv ITUNES_CSV + Path to iTunesDB CSV file + -o OUTPUT_DIRECTORY, --output-directory OUTPUT_DIRECTORY + Directory where to put renamed songs + +``` \ No newline at end of file diff --git a/song_file_renamer/rename_songs_from_itunesdb.py b/song_file_renamer/rename_songs_from_itunesdb.py new file mode 100644 index 0000000..5541fb4 --- /dev/null +++ b/song_file_renamer/rename_songs_from_itunesdb.py @@ -0,0 +1,57 @@ +import argparse +import os +import csv + +if __name__ == "__main__": + + argparse_parser = argparse.ArgumentParser( + "Apply recovered song data to music files on an iPod") + argparse_parser.add_argument( + "-i", "--song-directory", help="Directory to look for songs", required=True, type=str) + argparse_parser.add_argument( + "-c", "--itunes-csv", help="Path to iTunesDB CSV file", required=True) + argparse_parser.add_argument( + "-o", "--output-directory", help="Directory where to put renamed songs", required=True) + + argparse_args = argparse_parser.parse_args() + + if not os.path.exists(argparse_args.song_directory): + raise FileNotFoundError(f"Error! Can't find song directory '{ + argparse_args.song_directory}'") + + if not os.path.exists(argparse_args.itunes_csv): + raise FileNotFoundError(f"Error! Can't find iTunesDB csv file '{ + argparse_args.itunes_csv}', make sure you ran the parser") + + song_filenames_and_titles = dict() + + with open(argparse_args.itunes_csv, mode='r') as csv_file_obj: + csv_reader = csv.DictReader(csv_file_obj) + + for csv_row in csv_reader: + song_title = csv_row["Song Title"] + if song_title is None or song_title == "": + song_title = "UKNOWN_SONG_TITLE" + song_artist = csv_row["Artist"] + if song_artist is None or song_artist == "": + song_artist = "UNKNOWN_ARTIST" + song_filename = csv_row["Filename"] + + # Only care about the filename matching, not the whole directory path + song_filenames_and_titles[song_filename.split( + "/")[-1]] = f"{song_title} - {song_artist}.{song_filename.split(".")[-1]}" + + song_idx = 0 + + for candidate_file in os.listdir(argparse_args.song_directory): + candidate_filename = os.fsdecode(candidate_file) + + if candidate_filename.endswith(".m4a") or candidate_filename.endswith(".mp3"): + if candidate_filename in song_filenames_and_titles: + songs_new_filename = song_filenames_and_titles[candidate_filename] + # print(f"Renaming {candidate_filename} to {songs_new_filename}") + os.rename(os.path.join(argparse_args.song_directory, candidate_filename), os.path.join( + argparse_args.output_directory, songs_new_filename)) + song_idx += 1 + + print(f"[DEBUG] Finished renaming {song_idx} songs")