From 7d07168c0d866ce6de7b26a78c76725947e0e359 Mon Sep 17 00:00:00 2001 From: Thomas Hori / Harriet Riddle Date: Sun, 20 Mar 2016 14:43:27 +0000 Subject: [PATCH 01/55] Add support for using espeak when pico2wave not available. --- README.md | 4 +++- shuffle.py | 35 +++++++++++++++++++++++++++++------ 2 files changed, 32 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 8e1d133..3d3067f 100644 --- a/README.md +++ b/README.md @@ -133,8 +133,10 @@ Original data can be found via [wayback machine](https://web.archive.org/web/201 # License and Copyright ``` -Copyright (c) 2012-2016 ikelos, nims11, NicoHood +Copyright (c) 2012-2016 ikelos, nims11, ahippo, NicoHood, Thomas Hori See the readme for credit to other people. +This software falls at least partly under the GNU GPL v2. Certain portions +fall under the following terms: Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/shuffle.py b/shuffle.py index d1cc202..748c143 100755 --- a/shuffle.py +++ b/shuffle.py @@ -51,13 +51,14 @@ def validate_unicode(path): def exec_exists_in_path(command): with open(os.devnull, 'w') as FNULL: try: - subprocess.call([command], stdout=FNULL, stderr=subprocess.STDOUT) - return True + with open(os.devnull, 'r') as RFNULL: + subprocess.call([command], stdout=FNULL, stderr=subprocess.STDOUT, stdin=RFNULL) + return True except OSError as e: return False class Text2Speech(object): - valid_tts = {'pico2wave': True, 'RHVoice': True} + valid_tts = {'pico2wave': True, 'RHVoice': True, 'espeak': True} @staticmethod def check_support(): @@ -70,6 +71,13 @@ class Text2Speech(object): else: voiceoverAvailable = True + # Check for espeak voiceover + if not exec_exists_in_path("espeak"): + Text2Speech.valid_tts['espeak'] = False + print "Error executing espeak, voicever won't be generated using it." + else: + voiceoverAvailable = True + # Check for Russian RHVoice voiceover if not exec_exists_in_path("RHVoice"): Text2Speech.valid_tts['RHVoice'] = False @@ -94,9 +102,17 @@ class Text2Speech(object): text = unicode(text, 'utf-8') lang = Text2Speech.guess_lang(text) if lang == "ru-RU": - return Text2Speech.rhvoice(out_wav_path, text) + if Text2Speech.valid_tts['RHVoice']: + return Text2Speech.rhvoice(out_wav_path, text) + else: + return False else: - return Text2Speech.pico2wave(out_wav_path, text) + if Text2Speech.valid_tts['pico2wave']: + return Text2Speech.pico2wave(out_wav_path, text) + elif Text2Speech.valid_tts['espeak']: + return Text2Speech.espeak(out_wav_path, text) + else: + return False # guess-language seems like an overkill for now @staticmethod @@ -113,6 +129,13 @@ class Text2Speech(object): subprocess.call(["pico2wave", "-l", "en-GB", "-w", out_wav_path, unicodetext]) return True + @staticmethod + def espeak(out_wav_path, unicodetext): + if not Text2Speech.valid_tts['espeak']: + return False + subprocess.call(["espeak", "-v", "english_rp", "-w", out_wav_path, unicodetext]) + return True + @staticmethod def rhvoice(out_wav_path, unicodetext): if not Text2Speech.valid_tts['RHVoice']: @@ -399,7 +422,7 @@ class Playlist(Record): def set_master(self, tracks): # By default use "All Songs" builtin voiceover (dbid all zero) # Else generate alternative "All Songs" to fit the speaker voice of other playlists - if self.voiceover and Text2Speech.valid_tts['pico2wave']: + if self.voiceover and (Text2Speech.valid_tts['pico2wave'] or Text2Speech.valid_tts['espeak']): self["dbid"] = hashlib.md5("masterlist").digest()[:8] #pylint: disable-msg=E1101 self.text_to_speech("All songs", self["dbid"], True) self["listtype"] = 1 From dd368a52661e631977683e6bbc3bcdc9cc091ff2 Mon Sep 17 00:00:00 2001 From: Nimesh Ghelani Date: Thu, 24 Mar 2016 21:45:50 +0530 Subject: [PATCH 02/55] redundant conditions removed --- shuffle.py | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/shuffle.py b/shuffle.py index 748c143..19c8af2 100755 --- a/shuffle.py +++ b/shuffle.py @@ -102,15 +102,12 @@ class Text2Speech(object): text = unicode(text, 'utf-8') lang = Text2Speech.guess_lang(text) if lang == "ru-RU": - if Text2Speech.valid_tts['RHVoice']: - return Text2Speech.rhvoice(out_wav_path, text) - else: - return False + return Text2Speech.rhvoice(out_wav_path, text) else: - if Text2Speech.valid_tts['pico2wave']: - return Text2Speech.pico2wave(out_wav_path, text) - elif Text2Speech.valid_tts['espeak']: - return Text2Speech.espeak(out_wav_path, text) + if Text2Speech.pico2wave(out_wav_path, text): + return True + elif Text2Speech.espeak(out_wav_path, text): + return True else: return False From d3c01a569334ad7304b30967be865a1a44fc10c5 Mon Sep 17 00:00:00 2001 From: Nimesh Ghelani Date: Fri, 25 Mar 2016 04:51:00 +0530 Subject: [PATCH 03/55] espeak: lowered wpm, default wpm too fast for voiceover --- shuffle.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shuffle.py b/shuffle.py index 19c8af2..883a8b2 100755 --- a/shuffle.py +++ b/shuffle.py @@ -130,7 +130,7 @@ class Text2Speech(object): def espeak(out_wav_path, unicodetext): if not Text2Speech.valid_tts['espeak']: return False - subprocess.call(["espeak", "-v", "english_rp", "-w", out_wav_path, unicodetext]) + subprocess.call(["espeak", "-v", "english_rp", "-s", "150", "-w", out_wav_path, unicodetext]) return True @staticmethod From f66741f622323d7184e67d922ae543682c31c919 Mon Sep 17 00:00:00 2001 From: Nimesh Ghelani Date: Fri, 25 Mar 2016 04:54:23 +0530 Subject: [PATCH 04/55] Better warning messages --- shuffle.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/shuffle.py b/shuffle.py index 883a8b2..274f0e0 100755 --- a/shuffle.py +++ b/shuffle.py @@ -67,21 +67,21 @@ class Text2Speech(object): # Check for pico2wave voiceover if not exec_exists_in_path("pico2wave"): Text2Speech.valid_tts['pico2wave'] = False - print "Error executing pico2wave, voicever won't be generated using it." + print "Warning: pico2wave not found, voicever won't be generated using it." else: voiceoverAvailable = True # Check for espeak voiceover if not exec_exists_in_path("espeak"): Text2Speech.valid_tts['espeak'] = False - print "Error executing espeak, voicever won't be generated using it." + print "Warning: espeak not found, voicever won't be generated using it." else: voiceoverAvailable = True # Check for Russian RHVoice voiceover if not exec_exists_in_path("RHVoice"): Text2Speech.valid_tts['RHVoice'] = False - print "Warning: Error executing RHVoice, Russian voicever won't be generated." + print "Warning: RHVoice not found, Russian voicever won't be generated." else: voiceoverAvailable = True From 5570692175b3d3d04474d80aa9da864799fe6cdd Mon Sep 17 00:00:00 2001 From: Nimesh Ghelani Date: Fri, 25 Mar 2016 05:31:28 +0530 Subject: [PATCH 05/55] Removed MIT license, since there was already a GPL one --- README.md | 27 --------------------------- 1 file changed, 27 deletions(-) diff --git a/README.md b/README.md index 3d3067f..175bd76 100644 --- a/README.md +++ b/README.md @@ -129,30 +129,3 @@ Original data can be found via [wayback machine](https://web.archive.org/web/201 1.0 Release (15.08.2012 - 17.10.2012) * Original release by ikelos ``` - -# License and Copyright - -``` -Copyright (c) 2012-2016 ikelos, nims11, ahippo, NicoHood, Thomas Hori -See the readme for credit to other people. -This software falls at least partly under the GNU GPL v2. Certain portions -fall under the following terms: - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. -``` From 64617c4d8ccf7a27d61491f384c480539b966ddd Mon Sep 17 00:00:00 2001 From: NicoHood Date: Tue, 5 Apr 2016 20:55:44 +0200 Subject: [PATCH 06/55] Added script description --- shuffle.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/shuffle.py b/shuffle.py index 274f0e0..d2f94f3 100755 --- a/shuffle.py +++ b/shuffle.py @@ -606,7 +606,9 @@ def handle_interrupt(signal, frame): if __name__ == '__main__': signal.signal(signal.SIGINT, handle_interrupt) - parser = argparse.ArgumentParser() + parser = argparse.ArgumentParser(description= + 'Python script for building the Track and Playlist database ' + 'for the newer gen IPod Shuffle.') parser.add_argument('--disable-voiceover', action='store_true', help='Disable voiceover feature') parser.add_argument('--rename-unicode', action='store_true', help='Rename files causing unicode errors, will do minimal required renaming') parser.add_argument('--track-gain', type=nonnegative_int, default=0, help='Specify volume gain (0-99) for all tracks; 0 (default) means no gain and is usually fine; e.g. 60 is very loud even on minimal player volume') From df97b876b83808ea8aa4ce2ea5bd78f6f556ca0e Mon Sep 17 00:00:00 2001 From: NicoHood Date: Tue, 5 Apr 2016 20:57:34 +0200 Subject: [PATCH 07/55] Made argument parser functions better readable in script --- shuffle.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/shuffle.py b/shuffle.py index d2f94f3..e02a879 100755 --- a/shuffle.py +++ b/shuffle.py @@ -609,9 +609,14 @@ if __name__ == '__main__': parser = argparse.ArgumentParser(description= 'Python script for building the Track and Playlist database ' 'for the newer gen IPod Shuffle.') - parser.add_argument('--disable-voiceover', action='store_true', help='Disable voiceover feature') - parser.add_argument('--rename-unicode', action='store_true', help='Rename files causing unicode errors, will do minimal required renaming') - parser.add_argument('--track-gain', type=nonnegative_int, default=0, help='Specify volume gain (0-99) for all tracks; 0 (default) means no gain and is usually fine; e.g. 60 is very loud even on minimal player volume') + parser.add_argument('--disable-voiceover', action='store_true', + help='Disable voiceover feature') + parser.add_argument('--rename-unicode', action='store_true', + help='Rename files causing unicode errors, will do minimal required renaming') + parser.add_argument('--track-gain', type=nonnegative_int, default='0', + help='Specify volume gain (0-99) for all tracks; ' + '0 (default) means no gain and is usually fine; ' + 'e.g. 60 is very loud even on minimal player volume') parser.add_argument('path', help='Path to the IPod\'s root directory') result = parser.parse_args() From bcc374df130f2830c0fc04138f7a0d4b17116675 Mon Sep 17 00:00:00 2001 From: NicoHood Date: Tue, 5 Apr 2016 20:58:35 +0200 Subject: [PATCH 08/55] Added Auto Playlists --- shuffle.py | 74 +++++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 62 insertions(+), 12 deletions(-) diff --git a/shuffle.py b/shuffle.py index e02a879..6d5c57b 100755 --- a/shuffle.py +++ b/shuffle.py @@ -450,6 +450,33 @@ class Playlist(Record): listtracks = [ x for (_, x) in sorted(sorttracks) ] return listtracks + def populate_directory(self, playlistpath, recursive = True): + # Add all tracks inside the folder and its subfolders recursively. + # Folders containing no music and only a single Album + # would generate duplicated playlists. That is intended and "wont fix". + # Empty folders (inside the music path) will generate an error -> "wont fix". + listtracks = [] + for (dirpath, dirnames, filenames) in os.walk(playlistpath): + dirnames.sort() + + for filename in sorted(filenames, key = lambda x: x.lower()): + # Only add valid music files to playlist + if os.path.splitext(filename)[1].lower() in (".mp3", ".m4a", ".m4b", ".m4p", ".aa", ".wav"): + # Reformat fullPath so that the basepath is lower/upper and the rest lower. + # This is required to get the correct position (track index) inside Playlist.construct() + # /media/username/USER'S IPOD/IPod_Control/Music/Artist/Album/Track.mp3 + fullPath = os.path.abspath(os.path.join(dirpath, filename)) + # /media/username/USER'S IPOD/ + basepath = self.base + # ipod_control/music/artist/album/track.mp3 + ipodpath = self.path_to_ipod(fullPath)[1:].lower() + # /media/username/USER'S IPOD/ipod_control/music/artist/album/track.mp3 + fullPath = os.path.abspath(os.path.join(basepath, ipodpath)) + listtracks.append(fullPath) + if not recursive: + break + return listtracks + def remove_relatives(self, relative, filename): base = os.path.dirname(os.path.abspath(filename)) if not os.path.exists(relative): @@ -461,17 +488,26 @@ class Playlist(Record): return fullPath def populate(self, filename): - with open(filename, 'rb') as f: - data = f.readlines() + # Create a playlist of the folder and all subfolders + if os.path.isdir(filename): + self.listtracks = self.populate_directory(filename) - extension = os.path.splitext(filename)[1].lower() - if extension == '.pls': - self.listtracks = self.populate_pls(data) - elif extension == '.m3u': - self.listtracks = self.populate_m3u(data) - # Ensure all paths are not relative to the playlist file - for i in range(len(self.listtracks)): - self.listtracks[i] = self.remove_relatives(self.listtracks[i], filename) + # Read the playlist file + else: + with open(filename, 'rb') as f: + data = f.readlines() + + extension = os.path.splitext(filename)[1].lower() + if extension == '.pls': + self.listtracks = self.populate_pls(data) + elif extension == '.m3u': + self.listtracks = self.populate_m3u(data) + else: + raise + + # Ensure all paths are not relative to the playlist file + for i in range(len(self.listtracks)): + self.listtracks[i] = self.remove_relatives(self.listtracks[i], filename) # Handle the VoiceOverData text = os.path.splitext(os.path.basename(filename))[0] @@ -502,7 +538,7 @@ class Playlist(Record): return output + chunks class Shuffler(object): - def __init__(self, path, voiceover=True, rename=False, trackgain=0): + def __init__(self, path, voiceover=True, rename=False, trackgain=0, auto_playlists=None): self.path, self.base = self.determine_base(path) self.tracks = [] self.albums = [] @@ -512,6 +548,7 @@ class Shuffler(object): self.voiceover = voiceover self.rename = rename self.trackgain = trackgain + self.auto_playlists = auto_playlists def initialize(self): # remove existing voiceover files (they are either useless or will be overwritten anyway) @@ -548,6 +585,15 @@ class Shuffler(object): if os.path.splitext(filename)[1].lower() in (".pls", ".m3u"): self.lists.append(os.path.abspath(os.path.join(dirpath, filename))) + # Create automatic playlists in music directory. + # Ignore the (music) root and any hidden directories. + if self.auto_playlists and "ipod_control/music/" in dirpath.lower() and "/." not in dirpath.lower(): + # Only go to a specific depth. -1 is unlimted, 0 is ignored as there is already a master playlist. + depth = dirpath[len(self.path) + len(os.path.sep):].count(os.path.sep) - 1 + if self.auto_playlists < 0 or depth <= self.auto_playlists: + print "Adding folder", depth, " ", dirpath + self.lists.append(os.path.abspath(dirpath)) + def write_database(self): with open(os.path.join(self.base, "iPod_Control", "iTunes", "iTunesSD"), "wb") as f: f.write(self.tunessd.construct()) @@ -617,6 +663,10 @@ if __name__ == '__main__': help='Specify volume gain (0-99) for all tracks; ' '0 (default) means no gain and is usually fine; ' 'e.g. 60 is very loud even on minimal player volume') + parser.add_argument('--auto-playlists', type=int, default=None, const=-1, nargs='?', + help='Generate automatic playlists for each folder recursively inside ' + '"IPod_Control/Music/". You can optionally limit the depth: ' + '0=root, 1=artist, 2=album, n=subfoldername, default=-1 (No Limit).') parser.add_argument('path', help='Path to the IPod\'s root directory') result = parser.parse_args() @@ -629,7 +679,7 @@ if __name__ == '__main__': print "Error: Did not find any voiceover program. Voiceover disabled." result.disable_voiceover = True - shuffle = Shuffler(result.path, voiceover=not result.disable_voiceover, rename=result.rename_unicode, trackgain=result.track_gain) + shuffle = Shuffler(result.path, voiceover=not result.disable_voiceover, rename=result.rename_unicode, trackgain=result.track_gain, auto_playlists=result.auto_playlists) shuffle.initialize() shuffle.populate() shuffle.write_database() From 6e919eca3db7e4fee341425333bd3ff22747832b Mon Sep 17 00:00:00 2001 From: NicoHood Date: Tue, 5 Apr 2016 20:58:52 +0200 Subject: [PATCH 09/55] Minor typo --- shuffle.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shuffle.py b/shuffle.py index 6d5c57b..4e95d43 100755 --- a/shuffle.py +++ b/shuffle.py @@ -579,7 +579,7 @@ class Shuffler(object): for filename in sorted(filenames, key = lambda x: x.lower()): fullPath = os.path.abspath(os.path.join(dirpath, filename)) relPath = fullPath[fullPath.index(self.path)+len(self.path)+1:].lower() - fullPath = os.path.abspath(os.path.join(self.path, relPath)); + fullPath = os.path.abspath(os.path.join(self.path, relPath)) if os.path.splitext(filename)[1].lower() in (".mp3", ".m4a", ".m4b", ".m4p", ".aa", ".wav"): self.tracks.append(fullPath) if os.path.splitext(filename)[1].lower() in (".pls", ".m3u"): From 8dff7e8d5e15521e41a02b91d346f81e2b30441d Mon Sep 17 00:00:00 2001 From: NicoHood Date: Tue, 5 Apr 2016 22:03:10 +0200 Subject: [PATCH 10/55] Skip hidden directories for auto playlists --- shuffle.py | 30 ++++++++++++++++-------------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/shuffle.py b/shuffle.py index 4e95d43..acb347c 100755 --- a/shuffle.py +++ b/shuffle.py @@ -459,20 +459,22 @@ class Playlist(Record): for (dirpath, dirnames, filenames) in os.walk(playlistpath): dirnames.sort() - for filename in sorted(filenames, key = lambda x: x.lower()): - # Only add valid music files to playlist - if os.path.splitext(filename)[1].lower() in (".mp3", ".m4a", ".m4b", ".m4p", ".aa", ".wav"): - # Reformat fullPath so that the basepath is lower/upper and the rest lower. - # This is required to get the correct position (track index) inside Playlist.construct() - # /media/username/USER'S IPOD/IPod_Control/Music/Artist/Album/Track.mp3 - fullPath = os.path.abspath(os.path.join(dirpath, filename)) - # /media/username/USER'S IPOD/ - basepath = self.base - # ipod_control/music/artist/album/track.mp3 - ipodpath = self.path_to_ipod(fullPath)[1:].lower() - # /media/username/USER'S IPOD/ipod_control/music/artist/album/track.mp3 - fullPath = os.path.abspath(os.path.join(basepath, ipodpath)) - listtracks.append(fullPath) + # Ignore any hidden directories + if "/." not in dirpath.lower(): + for filename in sorted(filenames, key = lambda x: x.lower()): + # Only add valid music files to playlist + if os.path.splitext(filename)[1].lower() in (".mp3", ".m4a", ".m4b", ".m4p", ".aa", ".wav"): + # Reformat fullPath so that the basepath is lower/upper and the rest lower. + # This is required to get the correct position (track index) inside Playlist.construct() + # /media/username/USER'S IPOD/IPod_Control/Music/Artist/Album/Track.mp3 + fullPath = os.path.abspath(os.path.join(dirpath, filename)) + # /media/username/USER'S IPOD/ + basepath = self.base + # ipod_control/music/artist/album/track.mp3 + ipodpath = self.path_to_ipod(fullPath)[1:].lower() + # /media/username/USER'S IPOD/ipod_control/music/artist/album/track.mp3 + fullPath = os.path.abspath(os.path.join(basepath, ipodpath)) + listtracks.append(fullPath) if not recursive: break return listtracks From d71be4f9fb46c3290fc4879ee4313b14e4cc02cf Mon Sep 17 00:00:00 2001 From: NicoHood Date: Tue, 5 Apr 2016 22:06:53 +0200 Subject: [PATCH 11/55] Removed debug output --- shuffle.py | 1 - 1 file changed, 1 deletion(-) diff --git a/shuffle.py b/shuffle.py index acb347c..c4cff6c 100755 --- a/shuffle.py +++ b/shuffle.py @@ -593,7 +593,6 @@ class Shuffler(object): # Only go to a specific depth. -1 is unlimted, 0 is ignored as there is already a master playlist. depth = dirpath[len(self.path) + len(os.path.sep):].count(os.path.sep) - 1 if self.auto_playlists < 0 or depth <= self.auto_playlists: - print "Adding folder", depth, " ", dirpath self.lists.append(os.path.abspath(dirpath)) def write_database(self): From 5b2a4a2a3637aed2ec0bc4ed58e10345ae968951 Mon Sep 17 00:00:00 2001 From: NicoHood Date: Tue, 5 Apr 2016 23:11:48 +0200 Subject: [PATCH 12/55] Fix hyphen in filename #4 --- shuffle.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/shuffle.py b/shuffle.py index c4cff6c..502cbc6 100755 --- a/shuffle.py +++ b/shuffle.py @@ -327,7 +327,11 @@ class Track(Record): self["filetype"] = 2 text = os.path.splitext(os.path.basename(filename))[0] - audio = mutagen.File(filename, easy = True) + audio = None + try: + audio = mutagen.File(filename, easy = True) + except: + print "Error calling mutagen. Possible invalid filename/ID3Tags (hyphen in filename?)" if audio: # Note: Rythmbox IPod plugin sets this value always 0. self["stop_at_pos_ms"] = int(audio.info.length * 1000) From 4134e93cd3c56680a893140bcd24898781bfb467 Mon Sep 17 00:00:00 2001 From: NicoHood Date: Wed, 6 Apr 2016 18:10:24 +0200 Subject: [PATCH 13/55] Removed lower case from script (fix issue #5) --- shuffle.py | 22 ++++------------------ 1 file changed, 4 insertions(+), 18 deletions(-) diff --git a/shuffle.py b/shuffle.py index 502cbc6..809a26d 100755 --- a/shuffle.py +++ b/shuffle.py @@ -464,20 +464,11 @@ class Playlist(Record): dirnames.sort() # Ignore any hidden directories - if "/." not in dirpath.lower(): + if "/." not in dirpath: for filename in sorted(filenames, key = lambda x: x.lower()): # Only add valid music files to playlist if os.path.splitext(filename)[1].lower() in (".mp3", ".m4a", ".m4b", ".m4p", ".aa", ".wav"): - # Reformat fullPath so that the basepath is lower/upper and the rest lower. - # This is required to get the correct position (track index) inside Playlist.construct() - # /media/username/USER'S IPOD/IPod_Control/Music/Artist/Album/Track.mp3 fullPath = os.path.abspath(os.path.join(dirpath, filename)) - # /media/username/USER'S IPOD/ - basepath = self.base - # ipod_control/music/artist/album/track.mp3 - ipodpath = self.path_to_ipod(fullPath)[1:].lower() - # /media/username/USER'S IPOD/ipod_control/music/artist/album/track.mp3 - fullPath = os.path.abspath(os.path.join(basepath, ipodpath)) listtracks.append(fullPath) if not recursive: break @@ -488,9 +479,6 @@ class Playlist(Record): if not os.path.exists(relative): relative = os.path.join(base, relative) fullPath = relative - ipodpath = self.parent.parent.parent.path - relPath = fullPath[fullPath.index(ipodpath)+len(ipodpath)+1:].lower() - fullPath = os.path.abspath(os.path.join(ipodpath, relPath)) return fullPath def populate(self, filename): @@ -581,19 +569,17 @@ class Shuffler(object): for (dirpath, dirnames, filenames) in os.walk(self.path): dirnames.sort() # Ignore the speakable directory and any hidden directories - if "ipod_control/speakable" not in dirpath.lower() and "/." not in dirpath.lower(): + if "iPod_Control/Speakable" not in dirpath and "/." not in dirpath: for filename in sorted(filenames, key = lambda x: x.lower()): fullPath = os.path.abspath(os.path.join(dirpath, filename)) - relPath = fullPath[fullPath.index(self.path)+len(self.path)+1:].lower() - fullPath = os.path.abspath(os.path.join(self.path, relPath)) if os.path.splitext(filename)[1].lower() in (".mp3", ".m4a", ".m4b", ".m4p", ".aa", ".wav"): self.tracks.append(fullPath) if os.path.splitext(filename)[1].lower() in (".pls", ".m3u"): - self.lists.append(os.path.abspath(os.path.join(dirpath, filename))) + self.lists.append(fullPath) # Create automatic playlists in music directory. # Ignore the (music) root and any hidden directories. - if self.auto_playlists and "ipod_control/music/" in dirpath.lower() and "/." not in dirpath.lower(): + if self.auto_playlists and "iPod_Control/Music/" in dirpath and "/." not in dirpath: # Only go to a specific depth. -1 is unlimted, 0 is ignored as there is already a master playlist. depth = dirpath[len(self.path) + len(os.path.sep):].count(os.path.sep) - 1 if self.auto_playlists < 0 or depth <= self.auto_playlists: From 68c678b84469bcc05fdb60919ece04dd83850ed9 Mon Sep 17 00:00:00 2001 From: Nimesh Ghelani Date: Thu, 7 Apr 2016 01:16:59 +0530 Subject: [PATCH 14/55] Optional voiceover dependency section --- README.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 175bd76..1a21b8e 100644 --- a/README.md +++ b/README.md @@ -32,9 +32,11 @@ This script requires: * [Python 2.7](http://www.python.org/download/releases/2.7/) * [Mutagen](https://code.google.com/p/mutagen/) -* [PicoSpeaker](http://picospeaker.tk/readme.php) -- for non-Russian files -* [RHVoice (master branch, 3e31edced402a08771d2c48c73213982cbe9333e)](https://github.com/Olga-Yakovleva/RHVoice) -- for Russian files only -* [SoX](http://sox.sourceforge.net) -- for Russian files only + +Optional Voiceover support +* [PicoSpeaker](http://picospeaker.tk/readme.php) or espeak -- (English files) +* [RHVoice (master branch, 3e31edced402a08771d2c48c73213982cbe9333e)](https://github.com/Olga-Yakovleva/RHVoice) -- (Russian files only) +* [SoX](http://sox.sourceforge.net) -- (Russian files) ##### Ubuntu From 96a0d35dc86e5eb57d632f04624296135e486fb6 Mon Sep 17 00:00:00 2001 From: NicoHood Date: Wed, 6 Apr 2016 22:02:54 +0200 Subject: [PATCH 15/55] Use switch to enable voiceover --- shuffle.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/shuffle.py b/shuffle.py index 809a26d..e5b4db5 100755 --- a/shuffle.py +++ b/shuffle.py @@ -646,8 +646,8 @@ if __name__ == '__main__': parser = argparse.ArgumentParser(description= 'Python script for building the Track and Playlist database ' 'for the newer gen IPod Shuffle.') - parser.add_argument('--disable-voiceover', action='store_true', - help='Disable voiceover feature') + parser.add_argument('--voiceover', action='store_true', + help='Enable voiceover feature') parser.add_argument('--rename-unicode', action='store_true', help='Rename files causing unicode errors, will do minimal required renaming') parser.add_argument('--track-gain', type=nonnegative_int, default='0', @@ -666,11 +666,11 @@ if __name__ == '__main__': if result.rename_unicode: check_unicode(result.path) - if not result.disable_voiceover and not Text2Speech.check_support(): + if result.voiceover and not Text2Speech.check_support(): print "Error: Did not find any voiceover program. Voiceover disabled." - result.disable_voiceover = True + result.voiceover = False - shuffle = Shuffler(result.path, voiceover=not result.disable_voiceover, rename=result.rename_unicode, trackgain=result.track_gain, auto_playlists=result.auto_playlists) + shuffle = Shuffler(result.path, voiceover=result.voiceover, rename=result.rename_unicode, trackgain=result.track_gain, auto_playlists=result.auto_playlists) shuffle.initialize() shuffle.populate() shuffle.write_database() From 7129c05e99a48bd0dfbd4cb6cc6a5b436097f13b Mon Sep 17 00:00:00 2001 From: NicoHood Date: Wed, 6 Apr 2016 22:03:18 +0200 Subject: [PATCH 16/55] Add version number to description --- shuffle.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shuffle.py b/shuffle.py index e5b4db5..9fd7f92 100755 --- a/shuffle.py +++ b/shuffle.py @@ -645,7 +645,7 @@ if __name__ == '__main__': signal.signal(signal.SIGINT, handle_interrupt) parser = argparse.ArgumentParser(description= 'Python script for building the Track and Playlist database ' - 'for the newer gen IPod Shuffle.') + 'for the newer gen IPod Shuffle. Version 1.3') parser.add_argument('--voiceover', action='store_true', help='Enable voiceover feature') parser.add_argument('--rename-unicode', action='store_true', From a1cebe9d0beaab17c025601b47c3f0c4d4d35711 Mon Sep 17 00:00:00 2001 From: NicoHood Date: Wed, 6 Apr 2016 22:08:28 +0200 Subject: [PATCH 17/55] Differentiate track and playlist voiceover --- shuffle.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/shuffle.py b/shuffle.py index 9fd7f92..4880802 100755 --- a/shuffle.py +++ b/shuffle.py @@ -157,6 +157,7 @@ class Record(object): self._struct = collections.OrderedDict([]) self._fields = {} self.voiceover = parent.voiceover + self.playlist_voiceover = parent.playlist_voiceover self.rename = parent.rename self.trackgain = parent.trackgain @@ -178,7 +179,7 @@ class Record(object): return output def text_to_speech(self, text, dbid, playlist = False): - if self.voiceover: + if self.voiceover and not playlist or self.playlist_voiceover and playlist: # Create the voiceover wav file fn = "".join(["{0:02X}".format(ord(x)) for x in reversed(dbid)]) path = os.path.join(self.base, "iPod_Control", "Speakable", "Tracks" if not playlist else "Playlists", fn + ".wav") @@ -423,7 +424,7 @@ class Playlist(Record): def set_master(self, tracks): # By default use "All Songs" builtin voiceover (dbid all zero) # Else generate alternative "All Songs" to fit the speaker voice of other playlists - if self.voiceover and (Text2Speech.valid_tts['pico2wave'] or Text2Speech.valid_tts['espeak']): + if self.playlist_voiceover and (Text2Speech.valid_tts['pico2wave'] or Text2Speech.valid_tts['espeak']): self["dbid"] = hashlib.md5("masterlist").digest()[:8] #pylint: disable-msg=E1101 self.text_to_speech("All songs", self["dbid"], True) self["listtype"] = 1 @@ -532,7 +533,7 @@ class Playlist(Record): return output + chunks class Shuffler(object): - def __init__(self, path, voiceover=True, rename=False, trackgain=0, auto_playlists=None): + def __init__(self, path, voiceover=False, playlist_voiceover=False, rename=False, trackgain=0, auto_playlists=None): self.path, self.base = self.determine_base(path) self.tracks = [] self.albums = [] @@ -540,6 +541,7 @@ class Shuffler(object): self.lists = [] self.tunessd = None self.voiceover = voiceover + self.playlist_voiceover = playlist_voiceover self.rename = rename self.trackgain = trackgain self.auto_playlists = auto_playlists @@ -647,7 +649,9 @@ if __name__ == '__main__': 'Python script for building the Track and Playlist database ' 'for the newer gen IPod Shuffle. Version 1.3') parser.add_argument('--voiceover', action='store_true', - help='Enable voiceover feature') + help='Enable track voiceover feature') + parser.add_argument('--playlist-voiceover', action='store_true', + help='Enable playlist voiceover feature') parser.add_argument('--rename-unicode', action='store_true', help='Rename files causing unicode errors, will do minimal required renaming') parser.add_argument('--track-gain', type=nonnegative_int, default='0', @@ -670,7 +674,7 @@ if __name__ == '__main__': print "Error: Did not find any voiceover program. Voiceover disabled." result.voiceover = False - shuffle = Shuffler(result.path, voiceover=result.voiceover, rename=result.rename_unicode, trackgain=result.track_gain, auto_playlists=result.auto_playlists) + shuffle = Shuffler(result.path, voiceover=result.voiceover, playlist_voiceover=result.playlist_voiceover, rename=result.rename_unicode, trackgain=result.track_gain, auto_playlists=result.auto_playlists) shuffle.initialize() shuffle.populate() shuffle.write_database() From e6303ad9648bd5702df234c447f7d2ef873c1695 Mon Sep 17 00:00:00 2001 From: Nimesh Ghelani Date: Wed, 8 Jun 2016 02:57:04 +0530 Subject: [PATCH 18/55] Remove redundant self.base --- shuffle.py | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/shuffle.py b/shuffle.py index 4880802..7409f1d 100755 --- a/shuffle.py +++ b/shuffle.py @@ -207,7 +207,7 @@ class Record(object): @property def base(self): - return self.shuffledb.base + return self.shuffledb.path @property def tracks(self): @@ -534,7 +534,7 @@ class Playlist(Record): class Shuffler(object): def __init__(self, path, voiceover=False, playlist_voiceover=False, rename=False, trackgain=0, auto_playlists=None): - self.path, self.base = self.determine_base(path) + self.path = os.path.abspath(path) self.tracks = [] self.albums = [] self.artists = [] @@ -560,12 +560,6 @@ class Shuffler(object): print "Artists", self.artists print "Playlists", self.lists - def determine_base(self, path): - base = os.path.abspath(path) - # while not os.path.ismount(base): - # base = os.path.dirname(base) - return base, base - def populate(self): self.tunessd = TunesSD(self) for (dirpath, dirnames, filenames) in os.walk(self.path): @@ -588,7 +582,7 @@ class Shuffler(object): self.lists.append(os.path.abspath(dirpath)) def write_database(self): - with open(os.path.join(self.base, "iPod_Control", "iTunes", "iTunesSD"), "wb") as f: + with open(os.path.join(self.path, "iPod_Control", "iTunes", "iTunesSD"), "wb") as f: f.write(self.tunessd.construct()) # From 06ce8cb4033bfad072c55ea8822bfa2502614027 Mon Sep 17 00:00:00 2001 From: Nimesh Ghelani Date: Wed, 8 Jun 2016 03:43:32 +0530 Subject: [PATCH 19/55] Add failsafe path operations --- shuffle.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/shuffle.py b/shuffle.py index 7409f1d..a2c632d 100755 --- a/shuffle.py +++ b/shuffle.py @@ -57,6 +57,16 @@ def exec_exists_in_path(command): except OSError as e: return False +def splitpath(path): + return path.split(os.sep) + +def get_relpath(path, basepath): + commonprefix = os.sep.join(os.path.commonprefix(map(splitpath, [path, basepath]))) + return os.path.relpath(path, commonprefix) + +def is_path_prefix(prefix, path): + return prefix == os.sep.join(os.path.commonprefix(map(splitpath, [prefix, path]))) + class Text2Speech(object): valid_tts = {'pico2wave': True, 'RHVoice': True, 'espeak': True} @@ -564,8 +574,9 @@ class Shuffler(object): self.tunessd = TunesSD(self) for (dirpath, dirnames, filenames) in os.walk(self.path): dirnames.sort() + relpath = get_relpath(dirpath, self.path) # Ignore the speakable directory and any hidden directories - if "iPod_Control/Speakable" not in dirpath and "/." not in dirpath: + if not is_path_prefix("iPod_Control/Speakable", relpath) and "/." not in dirpath: for filename in sorted(filenames, key = lambda x: x.lower()): fullPath = os.path.abspath(os.path.join(dirpath, filename)) if os.path.splitext(filename)[1].lower() in (".mp3", ".m4a", ".m4b", ".m4p", ".aa", ".wav"): From 44fc42a2e42094cd57430278f49f7347150ee35f Mon Sep 17 00:00:00 2001 From: Nimesh Ghelani Date: Wed, 8 Jun 2016 05:14:03 +0530 Subject: [PATCH 20/55] Update README.md for v1.3 --- README.md | 43 ++++++++++++++++++++++++++++++++++++------- 1 file changed, 36 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 1a21b8e..19cbf81 100644 --- a/README.md +++ b/README.md @@ -8,22 +8,42 @@ Forked from the [shuffle-db-ng project](https://code.google.com/p/shuffle-db-ng/ Just put your audio files into the mass storage of your IPod and shuffle.py will do the rest ```bash $ python shuffle.py -h -usage: shuffle.py [-h] [--disable-voiceover] [--rename-unicode] +usage: shuffle.py [-h] [--voiceover] [--playlist-voiceover] [--rename-unicode] [--track-gain TRACK_GAIN] + [--auto-dir-playlists [AUTO_DIR_PLAYLISTS]] + [--auto-id3-playlists [ID3_TEMPLATE]] path +Python script for building the Track and Playlist database for the newer gen +IPod Shuffle. Version 1.3 + positional arguments: - path + path Path to the IPod's root directory optional arguments: -h, --help show this help message and exit - --disable-voiceover Disable Voiceover Feature - --rename-unicode Rename Files Causing Unicode Errors, will do minimal + --voiceover Enable track voiceover feature + --playlist-voiceover Enable playlist voiceover feature + --rename-unicode Rename files causing unicode errors, will do minimal required renaming --track-gain TRACK_GAIN - Store this volume gain (0-99) for all tracks; 0 - (default) means no gain and is usually fine; e.g. 60 - is very loud even on minimal player volume + Specify volume gain (0-99) for all tracks; 0 (default) + means no gain and is usually fine; e.g. 60 is very + loud even on minimal player volume + --auto-dir-playlists [AUTO_DIR_PLAYLISTS] + Generate automatic playlists for each folder + recursively inside "IPod_Control/Music/". You can + optionally limit the depth: 0=root, 1=artist, 2=album, + n=subfoldername, default=-1 (No Limit). + --auto-id3-playlists [ID3_TEMPLATE] + Generate automatic playlists based on the id3 tags of + any music added to the iPod. You can optionally + specify a template string based on which id3 tags are + used to generate playlists. For eg. '{artist} - + {album}' will use the pair of artist and album to + group tracks under one playlist. Similarly '{genre}' + will group tracks based on their genre tag. Default + template used is '{artist}' ``` #### Dependencies @@ -106,6 +126,15 @@ Original data can be found via [wayback machine](https://web.archive.org/web/201 # Version History ``` +1.3 Release (08.06.2016) +* Directory based auto playlist building (--auto-dir-playlists) (#13) +* ID3 tags based auto playlist building (--auto-id3-playlists) +* Added short program description +* Fix hyphen in filename #4 +* Fixed mutagen bug #5 +* Voiceover disabled by default #26 (Playlist voiceover enabled with auto playlist generation) +* Differentiate track and playlist voiceover #26 + 1.2 Release (04.02.2016) * Additional fixes from NicoHood * Fixed "All Songs" and "Playlist N" sounds when voiceover is disabled #17 From f22fdee04266e27efb8de17c7a1600e6933dad1f Mon Sep 17 00:00:00 2001 From: Nimesh Ghelani Date: Wed, 8 Jun 2016 05:14:58 +0530 Subject: [PATCH 21/55] Add id3 based auto playlist generation --- shuffle.py | 98 +++++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 74 insertions(+), 24 deletions(-) diff --git a/shuffle.py b/shuffle.py index a2c632d..58db65c 100755 --- a/shuffle.py +++ b/shuffle.py @@ -67,6 +67,30 @@ def get_relpath(path, basepath): def is_path_prefix(prefix, path): return prefix == os.sep.join(os.path.commonprefix(map(splitpath, [prefix, path]))) +def group_tracks_by_id3_template(tracks, template): + grouped_tracks_dict = {} + template_vars = set(re.findall(r'{.*?}', template)) + for track in tracks: + try: + id3_dict = mutagen.File(track, easy=True) + except: + id3_dict = {} + + key = template + single_var_present = False + for var in template_vars: + val = id3_dict.get(var[1:-1], [''])[0] + if len(val) > 0: + single_var_present = True + key = key.replace(var, val) + + if single_var_present: + if key not in grouped_tracks_dict: + grouped_tracks_dict[key] = [] + grouped_tracks_dict[key].append(track) + + return sorted(grouped_tracks_dict.items()) + class Text2Speech(object): valid_tts = {'pico2wave': True, 'RHVoice': True, 'espeak': True} @@ -395,7 +419,7 @@ class PlaylistHeader(Record): playlistcount = 1 for i in self.lists: playlist = Playlist(self) - print "[+] Adding playlist", i + print "[+] Adding playlist", (i[0] if type(i) == type(()) else i) playlist.populate(i) construction = playlist.construct(tracks) if playlist["number_of_songs"] > 0: @@ -492,30 +516,35 @@ class Playlist(Record): fullPath = relative return fullPath - def populate(self, filename): + def populate(self, obj): # Create a playlist of the folder and all subfolders - if os.path.isdir(filename): - self.listtracks = self.populate_directory(filename) - - # Read the playlist file + if type(obj) == type(()): + self.listtracks = obj[1] + text = obj[0] else: - with open(filename, 'rb') as f: - data = f.readlines() - - extension = os.path.splitext(filename)[1].lower() - if extension == '.pls': - self.listtracks = self.populate_pls(data) - elif extension == '.m3u': - self.listtracks = self.populate_m3u(data) + filename = obj + if os.path.isdir(filename): + self.listtracks = self.populate_directory(filename) + text = os.path.splitext(os.path.basename(filename))[0] else: - raise + # Read the playlist file + with open(filename, 'rb') as f: + data = f.readlines() - # Ensure all paths are not relative to the playlist file - for i in range(len(self.listtracks)): - self.listtracks[i] = self.remove_relatives(self.listtracks[i], filename) + extension = os.path.splitext(filename)[1].lower() + if extension == '.pls': + self.listtracks = self.populate_pls(data) + elif extension == '.m3u': + self.listtracks = self.populate_m3u(data) + else: + raise + + # Ensure all paths are not relative to the playlist file + for i in range(len(self.listtracks)): + self.listtracks[i] = self.remove_relatives(self.listtracks[i], filename) + text = os.path.splitext(os.path.basename(filename))[0] # Handle the VoiceOverData - text = os.path.splitext(os.path.basename(filename))[0] self["dbid"] = hashlib.md5(text).digest()[:8] #pylint: disable-msg=E1101 self.text_to_speech(text, self["dbid"], True) @@ -543,7 +572,7 @@ class Playlist(Record): return output + chunks class Shuffler(object): - def __init__(self, path, voiceover=False, playlist_voiceover=False, rename=False, trackgain=0, auto_playlists=None): + def __init__(self, path, voiceover=False, playlist_voiceover=False, rename=False, trackgain=0, auto_dir_playlists=None, auto_id3_playlists=None): self.path = os.path.abspath(path) self.tracks = [] self.albums = [] @@ -554,7 +583,8 @@ class Shuffler(object): self.playlist_voiceover = playlist_voiceover self.rename = rename self.trackgain = trackgain - self.auto_playlists = auto_playlists + self.auto_dir_playlists = auto_dir_playlists + self.auto_id3_playlists = auto_id3_playlists def initialize(self): # remove existing voiceover files (they are either useless or will be overwritten anyway) @@ -650,24 +680,40 @@ def handle_interrupt(signal, frame): if __name__ == '__main__': signal.signal(signal.SIGINT, handle_interrupt) + parser = argparse.ArgumentParser(description= 'Python script for building the Track and Playlist database ' 'for the newer gen IPod Shuffle. Version 1.3') + parser.add_argument('--voiceover', action='store_true', help='Enable track voiceover feature') + parser.add_argument('--playlist-voiceover', action='store_true', help='Enable playlist voiceover feature') + parser.add_argument('--rename-unicode', action='store_true', help='Rename files causing unicode errors, will do minimal required renaming') + parser.add_argument('--track-gain', type=nonnegative_int, default='0', help='Specify volume gain (0-99) for all tracks; ' '0 (default) means no gain and is usually fine; ' 'e.g. 60 is very loud even on minimal player volume') - parser.add_argument('--auto-playlists', type=int, default=None, const=-1, nargs='?', + + parser.add_argument('--auto-dir-playlists', type=int, default=None, const=-1, nargs='?', help='Generate automatic playlists for each folder recursively inside ' '"IPod_Control/Music/". You can optionally limit the depth: ' '0=root, 1=artist, 2=album, n=subfoldername, default=-1 (No Limit).') + + parser.add_argument('--auto-id3-playlists', type=str, default=None, metavar='ID3_TEMPLATE', const='{artist}', nargs='?', + help='Generate automatic playlists based on the id3 tags of any music ' + 'added to the iPod. You can optionally specify a template string ' + 'based on which id3 tags are used to generate playlists. For eg. ' + '\'{artist} - {album}\' will use the pair of artist and album to group ' + 'tracks under one playlist. Similarly \'{genre}\' will group tracks based ' + 'on their genre tag. Default template used is \'{artist}\'') + parser.add_argument('path', help='Path to the IPod\'s root directory') + result = parser.parse_args() checkPathValidity(result.path) @@ -675,11 +721,15 @@ if __name__ == '__main__': if result.rename_unicode: check_unicode(result.path) - if result.voiceover and not Text2Speech.check_support(): + if result.auto_id3_playlists != None or result.auto_dir_playlists != None: + result.playlist_voiceover = True + + if (result.voiceover or result.playlist_voiceover) and not Text2Speech.check_support(): print "Error: Did not find any voiceover program. Voiceover disabled." result.voiceover = False + result.playlist_voiceover = False - shuffle = Shuffler(result.path, voiceover=result.voiceover, playlist_voiceover=result.playlist_voiceover, rename=result.rename_unicode, trackgain=result.track_gain, auto_playlists=result.auto_playlists) + shuffle = Shuffler(result.path, voiceover=result.voiceover, playlist_voiceover=result.playlist_voiceover, rename=result.rename_unicode, trackgain=result.track_gain, auto_dir_playlists=result.auto_dir_playlists, auto_id3_playlists=result.auto_id3_playlists) shuffle.initialize() shuffle.populate() shuffle.write_database() From d3e5c767be7b0a34e065812883d988a4a6d8e311 Mon Sep 17 00:00:00 2001 From: Nimesh Ghelani Date: Wed, 8 Jun 2016 05:15:16 +0530 Subject: [PATCH 22/55] Add better handling of filenames and directories --- shuffle.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/shuffle.py b/shuffle.py index 58db65c..29ea499 100755 --- a/shuffle.py +++ b/shuffle.py @@ -616,12 +616,16 @@ class Shuffler(object): # Create automatic playlists in music directory. # Ignore the (music) root and any hidden directories. - if self.auto_playlists and "iPod_Control/Music/" in dirpath and "/." not in dirpath: + if self.auto_dir_playlists and "iPod_Control/Music/" in dirpath and "/." not in dirpath: # Only go to a specific depth. -1 is unlimted, 0 is ignored as there is already a master playlist. depth = dirpath[len(self.path) + len(os.path.sep):].count(os.path.sep) - 1 - if self.auto_playlists < 0 or depth <= self.auto_playlists: + if self.auto_dir_playlists < 0 or depth <= self.auto_dir_playlists: self.lists.append(os.path.abspath(dirpath)) + if self.auto_id3_playlists != None: + for grouped_list in group_tracks_by_id3_template(self.tracks, self.auto_id3_playlists): + self.lists.append(grouped_list) + def write_database(self): with open(os.path.join(self.path, "iPod_Control", "iTunes", "iTunesSD"), "wb") as f: f.write(self.tunessd.construct()) From dfbc65ca59fe2184eee01275477fd06d4325a144 Mon Sep 17 00:00:00 2001 From: Nimesh Ghelani Date: Wed, 8 Jun 2016 05:21:00 +0530 Subject: [PATCH 23/55] Escape quotes --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 19cbf81..3bf3385 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ Python script for building the Track and Playlist database for the newer gen IPod Shuffle. Version 1.3 positional arguments: - path Path to the IPod's root directory + path Path to the IPod\'s root directory optional arguments: -h, --help show this help message and exit From 1f28bbd2a5388364be0ab75396d7519f20da2f06 Mon Sep 17 00:00:00 2001 From: Nico Date: Wed, 29 Jun 2016 21:27:26 +0200 Subject: [PATCH 24/55] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 3bf3385..bd17d22 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,7 @@ Optional Voiceover support ##### Arch Linux -From the **Extra** repository: `pacman -S python2 mutagen` and from the AUR: `svox-pico-bin` ([link](https://aur.archlinux.org/packages/svox-pico-bin/)) +From the **Extra** repository: `pacman -S python2 mutagen` and optional `pacman -S espeak` or from the AUR: `svox-pico-bin` ([link](https://aur.archlinux.org/packages/svox-pico-bin/)) ##### Gentoo Linux From e1bc1c980385838575599c8f47c25a820fcec999 Mon Sep 17 00:00:00 2001 From: Nico Date: Sat, 2 Jul 2016 22:00:40 +0200 Subject: [PATCH 25/55] Added AUR package link --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index bd17d22..3dcbfa4 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,8 @@ Optional Voiceover support From the **Extra** repository: `pacman -S python2 mutagen` and optional `pacman -S espeak` or from the AUR: `svox-pico-bin` ([link](https://aur.archlinux.org/packages/svox-pico-bin/)) +You can also [install the script from AUR](https://aur.archlinux.org/packages/ipod-shuffle-4g/). + ##### Gentoo Linux ```bash From 48909db089b45df6599c0a34b36db3e8aa29622b Mon Sep 17 00:00:00 2001 From: Nimesh Ghelani Date: Sun, 21 Aug 2016 02:43:41 +0530 Subject: [PATCH 26/55] Add Tips for conversion/compression, resolves #11 --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index 3dcbfa4..8386764 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,9 @@ To avoid that linux moves deleted files into trash you can create an empty file This forces linux to delete the files permanently instead of moving them to the trash. Of course you can also use `shift + delete` to permanently delete files without this trick. +#### Compress/Convert your music files +([#11](https://github.com/nims11/IPod-Shuffle-4g/issues/11)) Shuffle is short on storage, and you might want to squeeze in more of your collection by sacrificing some bitrate off your files. In rarer cases, you might also possess music in formats not supported by your ipod. Although `ffmpeg` can handle almost all your needs, if you are looking for a friendly alternative, try [Soundconverter](http://soundconverter.org/). + #### Use Rhythmbox to manage your music and playlists As described [in the blog post](https://nims11.wordpress.com/2013/10/12/ipod-shuffle-4g-under-linux/) you can use Rythmbox to sync your personal music library to your IPod From c0c676c05d09be09807eceea5fbe5daab398232b Mon Sep 17 00:00:00 2001 From: NicoHood Date: Sat, 27 Aug 2016 13:16:45 +0200 Subject: [PATCH 27/55] Catch 'no space left' error #30 --- shuffle.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/shuffle.py b/shuffle.py index 29ea499..6fefb6b 100755 --- a/shuffle.py +++ b/shuffle.py @@ -628,7 +628,13 @@ class Shuffler(object): def write_database(self): with open(os.path.join(self.path, "iPod_Control", "iTunes", "iTunesSD"), "wb") as f: - f.write(self.tunessd.construct()) + try: + f.write(self.tunessd.construct()) + except IOError as e: + print "I/O error({0}): {1}".format(e.errno, e.strerror) + print "Error: Writing iPod database failed." + sys.exit(1) + print "Database written sucessful." # # Read all files from the directory From 7dbc15b91bdc15eec3a1e7d47a5792cb6fc33aed Mon Sep 17 00:00:00 2001 From: NicoHood Date: Sat, 27 Aug 2016 13:18:51 +0200 Subject: [PATCH 28/55] Version bump to 1.4 --- shuffle.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shuffle.py b/shuffle.py index 6fefb6b..9a90b72 100755 --- a/shuffle.py +++ b/shuffle.py @@ -693,7 +693,7 @@ if __name__ == '__main__': parser = argparse.ArgumentParser(description= 'Python script for building the Track and Playlist database ' - 'for the newer gen IPod Shuffle. Version 1.3') + 'for the newer gen IPod Shuffle. Version 1.4') parser.add_argument('--voiceover', action='store_true', help='Enable track voiceover feature') From e1894778701e23941c0f47766cd7ff9ebd8152d1 Mon Sep 17 00:00:00 2001 From: NicoHood Date: Sat, 27 Aug 2016 13:19:21 +0200 Subject: [PATCH 29/55] Renamed --voiceover to --track--voiceover --- shuffle.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/shuffle.py b/shuffle.py index 9a90b72..8d91fe5 100755 --- a/shuffle.py +++ b/shuffle.py @@ -190,7 +190,7 @@ class Record(object): self.parent = parent self._struct = collections.OrderedDict([]) self._fields = {} - self.voiceover = parent.voiceover + self.track_voiceover = parent.track_voiceover self.playlist_voiceover = parent.playlist_voiceover self.rename = parent.rename self.trackgain = parent.trackgain @@ -213,7 +213,7 @@ class Record(object): return output def text_to_speech(self, text, dbid, playlist = False): - if self.voiceover and not playlist or self.playlist_voiceover and playlist: + if self.track_voiceover and not playlist or self.playlist_voiceover and playlist: # Create the voiceover wav file fn = "".join(["{0:02X}".format(ord(x)) for x in reversed(dbid)]) path = os.path.join(self.base, "iPod_Control", "Speakable", "Tracks" if not playlist else "Playlists", fn + ".wav") @@ -272,7 +272,7 @@ class TunesSD(Record): ("total_number_of_playlists", ("I", 0)), ("unknown2", ("Q", 0)), ("max_volume", ("B", 0)), - ("voiceover_enabled", ("B", int(self.voiceover))), + ("voiceover_enabled", ("B", int(self.track_voiceover))), ("unknown3", ("H", 0)), ("total_tracks_without_podcasts", ("I", 0)), ("track_header_offset", ("I", 64)), @@ -572,14 +572,14 @@ class Playlist(Record): return output + chunks class Shuffler(object): - def __init__(self, path, voiceover=False, playlist_voiceover=False, rename=False, trackgain=0, auto_dir_playlists=None, auto_id3_playlists=None): + def __init__(self, path, track_voiceover=False, playlist_voiceover=False, rename=False, trackgain=0, auto_dir_playlists=None, auto_id3_playlists=None): self.path = os.path.abspath(path) self.tracks = [] self.albums = [] self.artists = [] self.lists = [] self.tunessd = None - self.voiceover = voiceover + self.track_voiceover = track_voiceover self.playlist_voiceover = playlist_voiceover self.rename = rename self.trackgain = trackgain @@ -695,7 +695,7 @@ if __name__ == '__main__': 'Python script for building the Track and Playlist database ' 'for the newer gen IPod Shuffle. Version 1.4') - parser.add_argument('--voiceover', action='store_true', + parser.add_argument('--track-voiceover', action='store_true', help='Enable track voiceover feature') parser.add_argument('--playlist-voiceover', action='store_true', @@ -734,12 +734,12 @@ if __name__ == '__main__': if result.auto_id3_playlists != None or result.auto_dir_playlists != None: result.playlist_voiceover = True - if (result.voiceover or result.playlist_voiceover) and not Text2Speech.check_support(): + if (result.track_voiceover or result.playlist_voiceover) and not Text2Speech.check_support(): print "Error: Did not find any voiceover program. Voiceover disabled." - result.voiceover = False + result.track_voiceover = False result.playlist_voiceover = False - shuffle = Shuffler(result.path, voiceover=result.voiceover, playlist_voiceover=result.playlist_voiceover, rename=result.rename_unicode, trackgain=result.track_gain, auto_dir_playlists=result.auto_dir_playlists, auto_id3_playlists=result.auto_id3_playlists) + shuffle = Shuffler(result.path, track_voiceover=result.track_voiceover, playlist_voiceover=result.playlist_voiceover, rename=result.rename_unicode, trackgain=result.track_gain, auto_dir_playlists=result.auto_dir_playlists, auto_id3_playlists=result.auto_id3_playlists) shuffle.initialize() shuffle.populate() shuffle.write_database() From d8eb3871bfbec4fe5a29692bad60225733412b28 Mon Sep 17 00:00:00 2001 From: NicoHood Date: Sat, 27 Aug 2016 14:18:15 +0200 Subject: [PATCH 30/55] Ignore hidden filenames --- shuffle.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/shuffle.py b/shuffle.py index 8d91fe5..a76c491 100755 --- a/shuffle.py +++ b/shuffle.py @@ -608,11 +608,13 @@ class Shuffler(object): # Ignore the speakable directory and any hidden directories if not is_path_prefix("iPod_Control/Speakable", relpath) and "/." not in dirpath: for filename in sorted(filenames, key = lambda x: x.lower()): - fullPath = os.path.abspath(os.path.join(dirpath, filename)) - if os.path.splitext(filename)[1].lower() in (".mp3", ".m4a", ".m4b", ".m4p", ".aa", ".wav"): - self.tracks.append(fullPath) - if os.path.splitext(filename)[1].lower() in (".pls", ".m3u"): - self.lists.append(fullPath) + # Ignore hidden files + if not filename.startswith("."): + fullPath = os.path.abspath(os.path.join(dirpath, filename)) + if os.path.splitext(filename)[1].lower() in (".mp3", ".m4a", ".m4b", ".m4p", ".aa", ".wav"): + self.tracks.append(fullPath) + if os.path.splitext(filename)[1].lower() in (".pls", ".m3u"): + self.lists.append(fullPath) # Create automatic playlists in music directory. # Ignore the (music) root and any hidden directories. From 1d7c3e0ab0b28ba97cf0960625f8721dc2298e16 Mon Sep 17 00:00:00 2001 From: NicoHood Date: Sat, 27 Aug 2016 14:18:31 +0200 Subject: [PATCH 31/55] typo --- shuffle.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shuffle.py b/shuffle.py index a76c491..ff9cf62 100755 --- a/shuffle.py +++ b/shuffle.py @@ -636,7 +636,7 @@ class Shuffler(object): print "I/O error({0}): {1}".format(e.errno, e.strerror) print "Error: Writing iPod database failed." sys.exit(1) - print "Database written sucessful." + print "Database written sucessfully." # # Read all files from the directory From 957912e64d3653814025f65c2972dab4e7553a5f Mon Sep 17 00:00:00 2001 From: NicoHood Date: Sat, 27 Aug 2016 14:21:27 +0200 Subject: [PATCH 32/55] Added files in extras folder --- extras/.Trash-1000 | 0 extras/.is_audio_player | 2 ++ 2 files changed, 2 insertions(+) create mode 100644 extras/.Trash-1000 create mode 100644 extras/.is_audio_player diff --git a/extras/.Trash-1000 b/extras/.Trash-1000 new file mode 100644 index 0000000..e69de29 diff --git a/extras/.is_audio_player b/extras/.is_audio_player new file mode 100644 index 0000000..e419f33 --- /dev/null +++ b/extras/.is_audio_player @@ -0,0 +1,2 @@ +name="iPod Shuffle" +audio_folders=iPod_Control/Music/ From 08684825b05cadc69c81577e55939b51eb9168f7 Mon Sep 17 00:00:00 2001 From: NicoHood Date: Sat, 27 Aug 2016 14:39:25 +0200 Subject: [PATCH 33/55] Added verbose debug output --- shuffle.py | 30 +++++++++++++++++++++++++----- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/shuffle.py b/shuffle.py index ff9cf62..4b1a1dd 100755 --- a/shuffle.py +++ b/shuffle.py @@ -128,7 +128,7 @@ class Text2Speech(object): # Skip voiceover generation if a track with the same name is used. # This might happen with "Track001" or "01. Intro" names for example. if os.path.isfile(out_wav_path): - print "Using existing", out_wav_path + verboseprint("Using existing", out_wav_path) return True # ensure we deal with unicode later @@ -317,7 +317,7 @@ class TrackHeader(Record): track_chunk = "" for i in self.tracks: track = Track(self) - print "[*] Adding track", i + verboseprint("[*] Adding track", i) track.populate(i) output += struct.pack("I", self.base_offset + self["total_length"] + len(track_chunk)) track_chunk += track.construct() @@ -411,7 +411,7 @@ class PlaylistHeader(Record): def construct(self, tracks): #pylint: disable-msg=W0221 # Build the master list masterlist = Playlist(self) - print "[+] Adding master playlist" + verboseprint("[+] Adding master playlist") masterlist.set_master(tracks) chunks = [masterlist.construct(tracks)] @@ -419,7 +419,7 @@ class PlaylistHeader(Record): playlistcount = 1 for i in self.lists: playlist = Playlist(self) - print "[+] Adding playlist", (i[0] if type(i) == type(()) else i) + verboseprint("[+] Adding playlist", (i[0] if type(i) == type(()) else i)) playlist.populate(i) construction = playlist.construct(tracks) if playlist["number_of_songs"] > 0: @@ -636,7 +636,11 @@ class Shuffler(object): print "I/O error({0}): {1}".format(e.errno, e.strerror) print "Error: Writing iPod database failed." sys.exit(1) - print "Database written sucessfully." + print "Database written successfully:" + print "Tracks", len(self.tracks) + print "Albums", len(self.albums) + print "Artists", len(self.artists) + print "Playlists", len(self.lists) # # Read all files from the directory @@ -724,10 +728,26 @@ if __name__ == '__main__': 'tracks under one playlist. Similarly \'{genre}\' will group tracks based ' 'on their genre tag. Default template used is \'{artist}\'') + parser.add_argument('--verbose', action='store_true', + help='Show verbose output of database generation.') + parser.add_argument('path', help='Path to the IPod\'s root directory') result = parser.parse_args() + # Enable verbose printing if desired + # Smaller version for python3 available. + # See https://stackoverflow.com/questions/5980042/how-to-implement-the-verbose-or-v-option-into-a-script + if result.verbose: + def verboseprint(*args): + # Print each argument separately so caller doesn't need to + # stuff everything to be printed into a single string + for arg in args: + print arg, + print + else: + verboseprint = lambda *a: None # do-nothing function + checkPathValidity(result.path) if result.rename_unicode: From 1b023af3fb9f2019e74059afa4925f12ef3a97e1 Mon Sep 17 00:00:00 2001 From: NicoHood Date: Sat, 27 Aug 2016 14:39:56 +0200 Subject: [PATCH 34/55] Do not force playlist voiceover with auto playlists --- shuffle.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/shuffle.py b/shuffle.py index 4b1a1dd..7233259 100755 --- a/shuffle.py +++ b/shuffle.py @@ -753,13 +753,15 @@ if __name__ == '__main__': if result.rename_unicode: check_unicode(result.path) - if result.auto_id3_playlists != None or result.auto_dir_playlists != None: - result.playlist_voiceover = True - - if (result.track_voiceover or result.playlist_voiceover) and not Text2Speech.check_support(): + verboseprint("Playlist voiceover requested:", result.playlist_voiceover) + verboseprint("Track voiceover requested:", result.track_voiceover) + if (result.track_voiceover or result.playlist_voiceover): + if not Text2Speech.check_support(): print "Error: Did not find any voiceover program. Voiceover disabled." result.track_voiceover = False result.playlist_voiceover = False + else: + verboseprint("Voiceover available.") shuffle = Shuffler(result.path, track_voiceover=result.track_voiceover, playlist_voiceover=result.playlist_voiceover, rename=result.rename_unicode, trackgain=result.track_gain, auto_dir_playlists=result.auto_dir_playlists, auto_id3_playlists=result.auto_id3_playlists) shuffle.initialize() From 30c68e9b416cd09481de3656ad4ad616d9c6eaf4 Mon Sep 17 00:00:00 2001 From: NicoHood Date: Sat, 27 Aug 2016 14:41:06 +0200 Subject: [PATCH 35/55] Renamed script from shuffle.py to ipod-shuffle-4g.py --- shuffle.py => ipod-shuffle-4g.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename shuffle.py => ipod-shuffle-4g.py (100%) diff --git a/shuffle.py b/ipod-shuffle-4g.py similarity index 100% rename from shuffle.py rename to ipod-shuffle-4g.py From 938405eb29d5f604461d9762e4e04197c2cd0d8e Mon Sep 17 00:00:00 2001 From: NicoHood Date: Sat, 27 Aug 2016 14:49:15 +0200 Subject: [PATCH 36/55] Added shortcut parameters (-p, -t, -d, etc.) --- ipod-shuffle-4g.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/ipod-shuffle-4g.py b/ipod-shuffle-4g.py index 7233259..0f0b773 100755 --- a/ipod-shuffle-4g.py +++ b/ipod-shuffle-4g.py @@ -701,26 +701,26 @@ if __name__ == '__main__': 'Python script for building the Track and Playlist database ' 'for the newer gen IPod Shuffle. Version 1.4') - parser.add_argument('--track-voiceover', action='store_true', + parser.add_argument('-t', '--track-voiceover', action='store_true', help='Enable track voiceover feature') - parser.add_argument('--playlist-voiceover', action='store_true', + parser.add_argument('-p', '--playlist-voiceover', action='store_true', help='Enable playlist voiceover feature') - parser.add_argument('--rename-unicode', action='store_true', + parser.add_argument('-u', '--rename-unicode', action='store_true', help='Rename files causing unicode errors, will do minimal required renaming') - parser.add_argument('--track-gain', type=nonnegative_int, default='0', + parser.add_argument('-g', '--track-gain', type=nonnegative_int, default='0', help='Specify volume gain (0-99) for all tracks; ' '0 (default) means no gain and is usually fine; ' 'e.g. 60 is very loud even on minimal player volume') - parser.add_argument('--auto-dir-playlists', type=int, default=None, const=-1, nargs='?', + parser.add_argument('-d', '--auto-dir-playlists', type=int, default=None, const=-1, nargs='?', help='Generate automatic playlists for each folder recursively inside ' '"IPod_Control/Music/". You can optionally limit the depth: ' '0=root, 1=artist, 2=album, n=subfoldername, default=-1 (No Limit).') - parser.add_argument('--auto-id3-playlists', type=str, default=None, metavar='ID3_TEMPLATE', const='{artist}', nargs='?', + parser.add_argument('-i', '--auto-id3-playlists', type=str, default=None, metavar='ID3_TEMPLATE', const='{artist}', nargs='?', help='Generate automatic playlists based on the id3 tags of any music ' 'added to the iPod. You can optionally specify a template string ' 'based on which id3 tags are used to generate playlists. For eg. ' @@ -728,7 +728,7 @@ if __name__ == '__main__': 'tracks under one playlist. Similarly \'{genre}\' will group tracks based ' 'on their genre tag. Default template used is \'{artist}\'') - parser.add_argument('--verbose', action='store_true', + parser.add_argument('-v', '--verbose', action='store_true', help='Show verbose output of database generation.') parser.add_argument('path', help='Path to the IPod\'s root directory') From e5322ce9f874b46c5efb79b11e44c0c295a245f9 Mon Sep 17 00:00:00 2001 From: NicoHood Date: Sat, 27 Aug 2016 14:57:26 +0200 Subject: [PATCH 37/55] Updated readme --- README.md | 53 +++++++++++++++++++++++++++++++++-------------------- 1 file changed, 33 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 8386764..567feaa 100644 --- a/README.md +++ b/README.md @@ -1,41 +1,41 @@ -# IPod Shuffle 4g Scripts +# IPod Shuffle 4g Script -##shuffle.py +## ipod-shuffle-4g.py Python script for building the Track and Playlist database for the newer gen IPod Shuffle. Forked from the [shuffle-db-ng project](https://code.google.com/p/shuffle-db-ng/) -Just put your audio files into the mass storage of your IPod and shuffle.py will do the rest -```bash -$ python shuffle.py -h -usage: shuffle.py [-h] [--voiceover] [--playlist-voiceover] [--rename-unicode] - [--track-gain TRACK_GAIN] - [--auto-dir-playlists [AUTO_DIR_PLAYLISTS]] - [--auto-id3-playlists [ID3_TEMPLATE]] - path +Just put your audio files into the mass storage of your IPod and shuffle.py will do the rest. +``` +$ ./ipod-shuffle-4g.py --help +usage: ipod-shuffle-4g.py [-h] [-t] [-p] [-u] [-g TRACK_GAIN] + [-d [AUTO_DIR_PLAYLISTS]] [-i [ID3_TEMPLATE]] [-v] + path Python script for building the Track and Playlist database for the newer gen -IPod Shuffle. Version 1.3 +IPod Shuffle. Version 1.4 positional arguments: - path Path to the IPod\'s root directory + path Path to the IPod's root directory optional arguments: -h, --help show this help message and exit - --voiceover Enable track voiceover feature - --playlist-voiceover Enable playlist voiceover feature - --rename-unicode Rename files causing unicode errors, will do minimal + -t, --track-voiceover + Enable track voiceover feature + -p, --playlist-voiceover + Enable playlist voiceover feature + -u, --rename-unicode Rename files causing unicode errors, will do minimal required renaming - --track-gain TRACK_GAIN + -g TRACK_GAIN, --track-gain TRACK_GAIN Specify volume gain (0-99) for all tracks; 0 (default) means no gain and is usually fine; e.g. 60 is very loud even on minimal player volume - --auto-dir-playlists [AUTO_DIR_PLAYLISTS] + -d [AUTO_DIR_PLAYLISTS], --auto-dir-playlists [AUTO_DIR_PLAYLISTS] Generate automatic playlists for each folder recursively inside "IPod_Control/Music/". You can optionally limit the depth: 0=root, 1=artist, 2=album, n=subfoldername, default=-1 (No Limit). - --auto-id3-playlists [ID3_TEMPLATE] + -i [ID3_TEMPLATE], --auto-id3-playlists [ID3_TEMPLATE] Generate automatic playlists based on the id3 tags of any music added to the iPod. You can optionally specify a template string based on which id3 tags are @@ -44,17 +44,18 @@ optional arguments: group tracks under one playlist. Similarly '{genre}' will group tracks based on their genre tag. Default template used is '{artist}' + -v, --verbose Show verbose output of database generation. ``` #### Dependencies This script requires: - * [Python 2.7](http://www.python.org/download/releases/2.7/) * [Mutagen](https://code.google.com/p/mutagen/) Optional Voiceover support -* [PicoSpeaker](http://picospeaker.tk/readme.php) or espeak -- (English files) +* [eSpeak](http://espeak.sourceforge.net/) +* [PicoSpeaker](http://picospeaker.tk/readme.php) * [RHVoice (master branch, 3e31edced402a08771d2c48c73213982cbe9333e)](https://github.com/Olga-Yakovleva/RHVoice) -- (Russian files only) * [SoX](http://sox.sourceforge.net) -- (Russian files) @@ -84,6 +85,7 @@ References to the overlays above: [ikelos](http://git.overlays.gentoo.org/gitweb To avoid that linux moves deleted files into trash you can create an empty file `.Trash-1000`. This forces linux to delete the files permanently instead of moving them to the trash. Of course you can also use `shift + delete` to permanently delete files without this trick. +The file can be found in the [extras](extras) folder. #### Compress/Convert your music files ([#11](https://github.com/nims11/IPod-Shuffle-4g/issues/11)) Shuffle is short on storage, and you might want to squeeze in more of your collection by sacrificing some bitrate off your files. In rarer cases, you might also possess music in formats not supported by your ipod. Although `ffmpeg` can handle almost all your needs, if you are looking for a friendly alternative, try [Soundconverter](http://soundconverter.org/). @@ -98,6 +100,7 @@ Simply place a file called `.is_audio_player` into the root directory of your IP name="Name's IPOD" audio_folders=iPod_Control/Music/ ``` +The file can be found in the [extras](extras) folder. Now disable the IPod plugin of Rhythmbox and enable the MTP plugin instead. You can use Rythmbox now to generate playlists and sync them to your IPod. @@ -131,6 +134,16 @@ Original data can be found via [wayback machine](https://web.archive.org/web/201 # Version History ``` +1.4 Release (27.08.2016) +* Catch "no space left" error #30 +* Renamed --voiceover to --track-voiceover +* Added optional --verbose output +* Renamed script from shuffle.py to ipod-shuffle-4g.py +* Added files to `extras` folder +* Ignore hidden filenames +* Do not force playlist voiceover with auto playlists +* Added shortcut parameters (-p, -t, -d, etc.) + 1.3 Release (08.06.2016) * Directory based auto playlist building (--auto-dir-playlists) (#13) * ID3 tags based auto playlist building (--auto-id3-playlists) From 406050c3820e2380e84c14db0a3c47d37fb850c2 Mon Sep 17 00:00:00 2001 From: NicoHood Date: Sat, 27 Aug 2016 15:34:20 +0200 Subject: [PATCH 38/55] Inlined inverting of header bytes --- ipod-shuffle-4g.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/ipod-shuffle-4g.py b/ipod-shuffle-4g.py index 0f0b773..54a15cc 100755 --- a/ipod-shuffle-4g.py +++ b/ipod-shuffle-4g.py @@ -207,8 +207,6 @@ class Record(object): output = "" for i in self._struct.keys(): (fmt, default) = self._struct[i] - if fmt == "4s": - fmt, default = "I", int(binascii.hexlify(default), 16) output += struct.pack("<" + fmt, self._fields.get(i, default)) return output @@ -265,7 +263,7 @@ class TunesSD(Record): self.track_header = TrackHeader(self) self.play_header = PlaylistHeader(self) self._struct = collections.OrderedDict([ - ("header_id", ("4s", "shdb")), + ("header_id", ("4s", "bdhs")), # shdb ("unknown1", ("I", 0x02000003)), ("total_length", ("I", 64)), ("total_number_of_tracks", ("I", 0)), @@ -302,7 +300,7 @@ class TrackHeader(Record): self.base_offset = 0 Record.__init__(self, parent) self._struct = collections.OrderedDict([ - ("header_id", ("4s", "shth")), + ("header_id", ("4s", "hths")), # shth ("total_length", ("I", 0)), ("number_of_tracks", ("I", 0)), ("unknown1", ("Q", 0)), @@ -328,7 +326,7 @@ class Track(Record): def __init__(self, parent): Record.__init__(self, parent) self._struct = collections.OrderedDict([ - ("header_id", ("4s", "shtr")), + ("header_id", ("4s", "rths")), # shtr ("header_length", ("I", 0x174)), ("start_at_pos_ms", ("I", 0)), ("stop_at_pos_ms", ("I", 0)), @@ -399,7 +397,7 @@ class PlaylistHeader(Record): self.base_offset = 0 Record.__init__(self, parent) self._struct = collections.OrderedDict([ - ("header_id", ("4s", "shph")), + ("header_id", ("4s", "hphs")), #shph ("total_length", ("I", 0)), ("number_of_playlists", ("I", 0)), ("number_of_non_podcast_lists", ("2s", "\xFF\xFF")), @@ -446,7 +444,7 @@ class Playlist(Record): self.listtracks = [] Record.__init__(self, parent) self._struct = collections.OrderedDict([ - ("header_id", ("4s", "shpl")), + ("header_id", ("4s", "lphs")), # shpl ("total_length", ("I", 0)), ("number_of_songs", ("I", 0)), ("number_of_nonaudio", ("I", 0)), From bfb4d11027f66bbbc596943ef0b73b1c8eb84442 Mon Sep 17 00:00:00 2001 From: NicoHood Date: Sat, 27 Aug 2016 17:42:01 +0200 Subject: [PATCH 39/55] 2to3 autoconvert --- ipod-shuffle-4g.py | 82 +++++++++++++++++++++++----------------------- 1 file changed, 41 insertions(+), 41 deletions(-) diff --git a/ipod-shuffle-4g.py b/ipod-shuffle-4g.py index 54a15cc..895cae8 100755 --- a/ipod-shuffle-4g.py +++ b/ipod-shuffle-4g.py @@ -2,7 +2,7 @@ # -*- coding: utf-8 -*- import sys import struct -import urllib +import urllib.request, urllib.parse, urllib.error import os import hashlib import mutagen @@ -39,7 +39,7 @@ def hash_error_unicode(item): def validate_unicode(path): path_list = path.split('/') last_raise = False - for i in xrange(len(path_list)): + for i in range(len(path_list)): if raises_unicode_error(path_list[i]): path_list[i] = hash_error_unicode(path_list[i]) last_raise = True @@ -61,11 +61,11 @@ def splitpath(path): return path.split(os.sep) def get_relpath(path, basepath): - commonprefix = os.sep.join(os.path.commonprefix(map(splitpath, [path, basepath]))) + commonprefix = os.sep.join(os.path.commonprefix(list(map(splitpath, [path, basepath])))) return os.path.relpath(path, commonprefix) def is_path_prefix(prefix, path): - return prefix == os.sep.join(os.path.commonprefix(map(splitpath, [prefix, path]))) + return prefix == os.sep.join(os.path.commonprefix(list(map(splitpath, [prefix, path])))) def group_tracks_by_id3_template(tracks, template): grouped_tracks_dict = {} @@ -101,21 +101,21 @@ class Text2Speech(object): # Check for pico2wave voiceover if not exec_exists_in_path("pico2wave"): Text2Speech.valid_tts['pico2wave'] = False - print "Warning: pico2wave not found, voicever won't be generated using it." + print("Warning: pico2wave not found, voicever won't be generated using it.") else: voiceoverAvailable = True # Check for espeak voiceover if not exec_exists_in_path("espeak"): Text2Speech.valid_tts['espeak'] = False - print "Warning: espeak not found, voicever won't be generated using it." + print("Warning: espeak not found, voicever won't be generated using it.") else: voiceoverAvailable = True # Check for Russian RHVoice voiceover if not exec_exists_in_path("RHVoice"): Text2Speech.valid_tts['RHVoice'] = False - print "Warning: RHVoice not found, Russian voicever won't be generated." + print("Warning: RHVoice not found, Russian voicever won't be generated.") else: voiceoverAvailable = True @@ -132,8 +132,8 @@ class Text2Speech(object): return True # ensure we deal with unicode later - if not isinstance(text, unicode): - text = unicode(text, 'utf-8') + if not isinstance(text, str): + text = str(text, 'utf-8') lang = Text2Speech.guess_lang(text) if lang == "ru-RU": return Text2Speech.rhvoice(out_wav_path, text) @@ -149,7 +149,7 @@ class Text2Speech(object): @staticmethod def guess_lang(unicodetext): lang = 'en-GB' - if re.search(u"[А-Яа-я]", unicodetext) is not None: + if re.search("[А-Яа-я]", unicodetext) is not None: lang = 'ru-RU' return lang @@ -196,7 +196,7 @@ class Record(object): self.trackgain = parent.trackgain def __getitem__(self, item): - if item not in self._struct.keys(): + if item not in list(self._struct.keys()): raise KeyError return self._fields.get(item, self._struct[item][1]) @@ -205,7 +205,7 @@ class Record(object): def construct(self): output = "" - for i in self._struct.keys(): + for i in list(self._struct.keys()): (fmt, default) = self._struct[i] output += struct.pack("<" + fmt, self._fields.get(i, default)) return output @@ -364,19 +364,19 @@ class Track(Record): try: audio = mutagen.File(filename, easy = True) except: - print "Error calling mutagen. Possible invalid filename/ID3Tags (hyphen in filename?)" + print("Error calling mutagen. Possible invalid filename/ID3Tags (hyphen in filename?)") if audio: # Note: Rythmbox IPod plugin sets this value always 0. self["stop_at_pos_ms"] = int(audio.info.length * 1000) - artist = audio.get("artist", [u"Unknown"])[0] + artist = audio.get("artist", ["Unknown"])[0] if artist in self.artists: self["artistid"] = self.artists.index(artist) else: self["artistid"] = len(self.artists) self.artists.append(artist) - album = audio.get("album", [u"Unknown"])[0] + album = audio.get("album", ["Unknown"])[0] if album in self.albums: self["albumid"] = self.albums.index(album) else: @@ -384,10 +384,10 @@ class Track(Record): self.albums.append(album) if audio.get("title", "") and audio.get("artist", ""): - text = u" - ".join(audio.get("title", u"") + audio.get("artist", u"")) + text = " - ".join(audio.get("title", "") + audio.get("artist", "")) # Handle the VoiceOverData - if isinstance(text, unicode): + if isinstance(text, str): text = text.encode('utf-8', 'ignore') self["dbid"] = hashlib.md5(text).digest()[:8] #pylint: disable-msg=E1101 self.text_to_speech(text, self["dbid"]) @@ -424,7 +424,7 @@ class PlaylistHeader(Record): playlistcount += 1 chunks += [construction] else: - print "Error: Playlist does not contain a single track. Skipping playlist." + print("Error: Playlist does not contain a single track. Skipping playlist.") self["number_of_playlists"] = playlistcount self["total_length"] = 0x14 + (self["number_of_playlists"] * 4) @@ -478,7 +478,7 @@ class Playlist(Record): dataarr = i.strip().split("=", 1) if dataarr[0].lower().startswith("file"): num = int(dataarr[0][4:]) - filename = urllib.unquote(dataarr[1]).strip() + filename = urllib.parse.unquote(dataarr[1]).strip() if filename.lower().startswith('file://'): filename = filename[7:] if self.rename: @@ -559,8 +559,8 @@ class Playlist(Record): except: # Print an error if no track was found. # Empty playlists are handeled in the PlaylistHeader class. - print "Error: Could not find track \"" + path + "\"." - print "Maybe its an invalid FAT filesystem name. Please fix your playlist. Skipping track." + print("Error: Could not find track \"" + path + "\".") + print("Maybe its an invalid FAT filesystem name. Please fix your playlist. Skipping track.") if position > -1: chunks += struct.pack("I", position) self["number_of_songs"] += 1 @@ -592,11 +592,11 @@ class Shuffler(object): make_dir_if_absent(os.path.join(self.path, dirname)) def dump_state(self): - print "Shuffle DB state" - print "Tracks", self.tracks - print "Albums", self.albums - print "Artists", self.artists - print "Playlists", self.lists + print("Shuffle DB state") + print("Tracks", self.tracks) + print("Albums", self.albums) + print("Artists", self.artists) + print("Playlists", self.lists) def populate(self): self.tunessd = TunesSD(self) @@ -631,14 +631,14 @@ class Shuffler(object): try: f.write(self.tunessd.construct()) except IOError as e: - print "I/O error({0}): {1}".format(e.errno, e.strerror) - print "Error: Writing iPod database failed." + print("I/O error({0}): {1}".format(e.errno, e.strerror)) + print("Error: Writing iPod database failed.") sys.exit(1) - print "Database written successfully:" - print "Tracks", len(self.tracks) - print "Albums", len(self.albums) - print "Artists", len(self.artists) - print "Playlists", len(self.lists) + print("Database written successfully:") + print("Tracks", len(self.tracks)) + print("Albums", len(self.albums)) + print("Artists", len(self.artists)) + print("Playlists", len(self.lists)) # # Read all files from the directory @@ -657,7 +657,7 @@ def check_unicode(path): if raises_unicode_error(item): src = os.path.join(path, item) dest = os.path.join(path, hash_error_unicode(item)) + os.path.splitext(item)[1].lower() - print 'Renaming %s -> %s' % (src, dest) + print('Renaming %s -> %s' % (src, dest)) os.rename(src, dest) else: ret_flag = (check_unicode(os.path.join(path, item)) or ret_flag) @@ -665,7 +665,7 @@ def check_unicode(path): src = os.path.join(path, item) new_name = hash_error_unicode(item) dest = os.path.join(path, new_name) - print 'Renaming %s -> %s' % (src, dest) + print('Renaming %s -> %s' % (src, dest)) os.rename(src, dest) return ret_flag @@ -681,15 +681,15 @@ def nonnegative_int(string): def checkPathValidity(path): if not os.path.isdir(result.path): - print "Error finding IPod directory. Maybe it is not connected or mounted?" + print("Error finding IPod directory. Maybe it is not connected or mounted?") sys.exit(1) if not os.access(result.path, os.W_OK): - print 'Unable to get write permissions in the IPod directory' + print('Unable to get write permissions in the IPod directory') sys.exit(1) def handle_interrupt(signal, frame): - print "Interrupt detected, exiting..." + print("Interrupt detected, exiting...") sys.exit(1) if __name__ == '__main__': @@ -741,8 +741,8 @@ if __name__ == '__main__': # Print each argument separately so caller doesn't need to # stuff everything to be printed into a single string for arg in args: - print arg, - print + print(arg, end=' ') + print() else: verboseprint = lambda *a: None # do-nothing function @@ -755,7 +755,7 @@ if __name__ == '__main__': verboseprint("Track voiceover requested:", result.track_voiceover) if (result.track_voiceover or result.playlist_voiceover): if not Text2Speech.check_support(): - print "Error: Did not find any voiceover program. Voiceover disabled." + print("Error: Did not find any voiceover program. Voiceover disabled.") result.track_voiceover = False result.playlist_voiceover = False else: From a21b0a2fe1868c19dbd0b7bee018d5161ceb8cf7 Mon Sep 17 00:00:00 2001 From: NicoHood Date: Sat, 27 Aug 2016 18:08:16 +0200 Subject: [PATCH 40/55] Fixed 2to3 conversion errors --- ipod-shuffle-4g.py | 59 +++++++++++++++++++--------------------------- 1 file changed, 24 insertions(+), 35 deletions(-) diff --git a/ipod-shuffle-4g.py b/ipod-shuffle-4g.py index 895cae8..0c9f6c9 100755 --- a/ipod-shuffle-4g.py +++ b/ipod-shuffle-4g.py @@ -1,5 +1,4 @@ -#!/usr/bin/env python2.7 -# -*- coding: utf-8 -*- +#!/usr/bin/env python3 import sys import struct import urllib.request, urllib.parse, urllib.error @@ -204,7 +203,7 @@ class Record(object): self._fields[item] = value def construct(self): - output = "" + output = bytes() for i in list(self._struct.keys()): (fmt, default) = self._struct[i] output += struct.pack("<" + fmt, self._fields.get(i, default)) @@ -213,7 +212,7 @@ class Record(object): def text_to_speech(self, text, dbid, playlist = False): if self.track_voiceover and not playlist or self.playlist_voiceover and playlist: # Create the voiceover wav file - fn = "".join(["{0:02X}".format(ord(x)) for x in reversed(dbid)]) + fn = ''.join(format(x, '02x') for x in reversed(dbid)) path = os.path.join(self.base, "iPod_Control", "Speakable", "Tracks" if not playlist else "Playlists", fn + ".wav") return Text2Speech.text2speech(path, text) return False @@ -263,7 +262,7 @@ class TunesSD(Record): self.track_header = TrackHeader(self) self.play_header = PlaylistHeader(self) self._struct = collections.OrderedDict([ - ("header_id", ("4s", "bdhs")), # shdb + ("header_id", ("4s", b"bdhs")), # shdb ("unknown1", ("I", 0x02000003)), ("total_length", ("I", 64)), ("total_number_of_tracks", ("I", 0)), @@ -275,7 +274,7 @@ class TunesSD(Record): ("total_tracks_without_podcasts", ("I", 0)), ("track_header_offset", ("I", 64)), ("playlist_header_offset", ("I", 0)), - ("unknown4", ("20s", "\x00" * 20)), + ("unknown4", ("20s", b"\x00" * 20)), ]) def construct(self): @@ -300,7 +299,7 @@ class TrackHeader(Record): self.base_offset = 0 Record.__init__(self, parent) self._struct = collections.OrderedDict([ - ("header_id", ("4s", "hths")), # shth + ("header_id", ("4s", b"hths")), # shth ("total_length", ("I", 0)), ("number_of_tracks", ("I", 0)), ("unknown1", ("Q", 0)), @@ -312,7 +311,7 @@ class TrackHeader(Record): output = Record.construct(self) # Construct the underlying tracks - track_chunk = "" + track_chunk = bytes() for i in self.tracks: track = Track(self) verboseprint("[*] Adding track", i) @@ -326,13 +325,13 @@ class Track(Record): def __init__(self, parent): Record.__init__(self, parent) self._struct = collections.OrderedDict([ - ("header_id", ("4s", "rths")), # shtr + ("header_id", ("4s", b"rths")), # shtr ("header_length", ("I", 0x174)), ("start_at_pos_ms", ("I", 0)), ("stop_at_pos_ms", ("I", 0)), ("volume_gain", ("I", int(self.trackgain))), ("filetype", ("I", 1)), - ("filename", ("256s", "\x00" * 256)), + ("filename", ("256s", b"\x00" * 256)), ("bookmark", ("I", 0)), ("dontskip", ("B", 1)), ("remember", ("B", 0)), @@ -350,11 +349,11 @@ class Track(Record): ("unknown4", ("Q", 0)), ("dbid", ("8s", 0)), ("artistid", ("I", 0)), - ("unknown5", ("32s", "\x00" * 32)), + ("unknown5", ("32s", b"\x00" * 32)), ]) def populate(self, filename): - self["filename"] = self.path_to_ipod(filename) + self["filename"] = self.path_to_ipod(filename).encode('utf-8') if os.path.splitext(filename)[1].lower() in (".m4a", ".m4b", ".m4p", ".aa"): self["filetype"] = 2 @@ -397,13 +396,13 @@ class PlaylistHeader(Record): self.base_offset = 0 Record.__init__(self, parent) self._struct = collections.OrderedDict([ - ("header_id", ("4s", "hphs")), #shph + ("header_id", ("4s", b"hphs")), #shph ("total_length", ("I", 0)), ("number_of_playlists", ("I", 0)), - ("number_of_non_podcast_lists", ("2s", "\xFF\xFF")), - ("number_of_master_lists", ("2s", "\x01\x00")), - ("number_of_non_audiobook_lists", ("2s", "\xFF\xFF")), - ("unknown2", ("2s", "\x00" * 2)), + ("number_of_non_podcast_lists", ("2s", b"\xFF\xFF")), + ("number_of_master_lists", ("2s", b"\x01\x00")), + ("number_of_non_audiobook_lists", ("2s", b"\xFF\xFF")), + ("unknown2", ("2s", b"\x00" * 2)), ]) def construct(self, tracks): #pylint: disable-msg=W0221 @@ -437,27 +436,27 @@ class PlaylistHeader(Record): output += struct.pack("I", offset) offset += len(chunks[i]) - return output + "".join(chunks) + return output + b"".join(chunks) class Playlist(Record): def __init__(self, parent): self.listtracks = [] Record.__init__(self, parent) self._struct = collections.OrderedDict([ - ("header_id", ("4s", "lphs")), # shpl + ("header_id", ("4s", b"lphs")), # shpl ("total_length", ("I", 0)), ("number_of_songs", ("I", 0)), ("number_of_nonaudio", ("I", 0)), - ("dbid", ("8s", "\x00" * 8)), + ("dbid", ("8s", b"\x00" * 8)), ("listtype", ("I", 2)), - ("unknown1", ("16s", "\x00" * 16)) + ("unknown1", ("16s", b"\x00" * 16)) ]) def set_master(self, tracks): # By default use "All Songs" builtin voiceover (dbid all zero) # Else generate alternative "All Songs" to fit the speaker voice of other playlists if self.playlist_voiceover and (Text2Speech.valid_tts['pico2wave'] or Text2Speech.valid_tts['espeak']): - self["dbid"] = hashlib.md5("masterlist").digest()[:8] #pylint: disable-msg=E1101 + self["dbid"] = hashlib.md5(b"masterlist").digest()[:8] #pylint: disable-msg=E1101 self.text_to_speech("All songs", self["dbid"], True) self["listtype"] = 1 self.listtracks = tracks @@ -543,14 +542,14 @@ class Playlist(Record): text = os.path.splitext(os.path.basename(filename))[0] # Handle the VoiceOverData - self["dbid"] = hashlib.md5(text).digest()[:8] #pylint: disable-msg=E1101 + self["dbid"] = hashlib.md5(text.encode('utf-8')).digest()[:8] #pylint: disable-msg=E1101 self.text_to_speech(text, self["dbid"], True) def construct(self, tracks): #pylint: disable-msg=W0221 self["total_length"] = 44 + (4 * len(self.listtracks)) self["number_of_songs"] = 0 - chunks = "" + chunks = bytes() for i in self.listtracks: path = self.ipod_to_path(i) position = -1 @@ -734,17 +733,7 @@ if __name__ == '__main__': result = parser.parse_args() # Enable verbose printing if desired - # Smaller version for python3 available. - # See https://stackoverflow.com/questions/5980042/how-to-implement-the-verbose-or-v-option-into-a-script - if result.verbose: - def verboseprint(*args): - # Print each argument separately so caller doesn't need to - # stuff everything to be printed into a single string - for arg in args: - print(arg, end=' ') - print() - else: - verboseprint = lambda *a: None # do-nothing function + verboseprint = print if result.verbose else lambda *a, **k: None checkPathValidity(result.path) From 3ff22a49504df9158ce085718781616421fa974f Mon Sep 17 00:00:00 2001 From: NicoHood Date: Sat, 27 Aug 2016 21:02:59 +0200 Subject: [PATCH 41/55] Made mutagen import optional --- ipod-shuffle-4g.py | 70 +++++++++++++++++++++++++++++----------------- 1 file changed, 44 insertions(+), 26 deletions(-) diff --git a/ipod-shuffle-4g.py b/ipod-shuffle-4g.py index 0c9f6c9..e61f642 100755 --- a/ipod-shuffle-4g.py +++ b/ipod-shuffle-4g.py @@ -1,11 +1,11 @@ #!/usr/bin/env python3 + +# Builtin libraries import sys import struct import urllib.request, urllib.parse, urllib.error import os import hashlib -import mutagen -import binascii import subprocess import collections import errno @@ -15,6 +15,12 @@ import re import tempfile import signal +# External libraries +try: + import mutagen +except ImportError: + mutagen = None + audio_ext = (".mp3", ".m4a", ".m4b", ".m4p", ".aa", ".wav") list_ext = (".pls", ".m3u") def make_dir_if_absent(path): @@ -359,31 +365,34 @@ class Track(Record): self["filetype"] = 2 text = os.path.splitext(os.path.basename(filename))[0] - audio = None - try: - audio = mutagen.File(filename, easy = True) - except: - print("Error calling mutagen. Possible invalid filename/ID3Tags (hyphen in filename?)") - if audio: - # Note: Rythmbox IPod plugin sets this value always 0. - self["stop_at_pos_ms"] = int(audio.info.length * 1000) - artist = audio.get("artist", ["Unknown"])[0] - if artist in self.artists: - self["artistid"] = self.artists.index(artist) - else: - self["artistid"] = len(self.artists) - self.artists.append(artist) + # Try to get album and artist information with mutagen + if mutagen: + audio = None + try: + audio = mutagen.File(filename, easy = True) + except: + print("Error calling mutagen. Possible invalid filename/ID3Tags (hyphen in filename?)") + if audio: + # Note: Rythmbox IPod plugin sets this value always 0. + self["stop_at_pos_ms"] = int(audio.info.length * 1000) - album = audio.get("album", ["Unknown"])[0] - if album in self.albums: - self["albumid"] = self.albums.index(album) - else: - self["albumid"] = len(self.albums) - self.albums.append(album) + artist = audio.get("artist", ["Unknown"])[0] + if artist in self.artists: + self["artistid"] = self.artists.index(artist) + else: + self["artistid"] = len(self.artists) + self.artists.append(artist) - if audio.get("title", "") and audio.get("artist", ""): - text = " - ".join(audio.get("title", "") + audio.get("artist", "")) + album = audio.get("album", ["Unknown"])[0] + if album in self.albums: + self["albumid"] = self.albums.index(album) + else: + self["albumid"] = len(self.albums) + self.albums.append(album) + + if audio.get("title", "") and audio.get("artist", ""): + text = " - ".join(audio.get("title", "") + audio.get("artist", "")) # Handle the VoiceOverData if isinstance(text, str): @@ -622,10 +631,15 @@ class Shuffler(object): self.lists.append(os.path.abspath(dirpath)) if self.auto_id3_playlists != None: - for grouped_list in group_tracks_by_id3_template(self.tracks, self.auto_id3_playlists): - self.lists.append(grouped_list) + if mutagen: + for grouped_list in group_tracks_by_id3_template(self.tracks, self.auto_id3_playlists): + self.lists.append(grouped_list) + else: + print("Error: No mutagen found. Cannot generate auto-id3-playlists.") + sys.exit(1) def write_database(self): + print("Writing database. This may take a while...") with open(os.path.join(self.path, "iPod_Control", "iTunes", "iTunesSD"), "wb") as f: try: f.write(self.tunessd.construct()) @@ -633,6 +647,7 @@ class Shuffler(object): print("I/O error({0}): {1}".format(e.errno, e.strerror)) print("Error: Writing iPod database failed.") sys.exit(1) + print("Database written successfully:") print("Tracks", len(self.tracks)) print("Albums", len(self.albums)) @@ -740,6 +755,9 @@ if __name__ == '__main__': if result.rename_unicode: check_unicode(result.path) + if not mutagen: + print("Warning: No mutagen found. Database will not contain any album nor artist information.") + verboseprint("Playlist voiceover requested:", result.playlist_voiceover) verboseprint("Track voiceover requested:", result.track_voiceover) if (result.track_voiceover or result.playlist_voiceover): From c22837ba40a22082ea7adb33764df30b0acee5e7 Mon Sep 17 00:00:00 2001 From: NicoHood Date: Sat, 27 Aug 2016 21:03:25 +0200 Subject: [PATCH 42/55] Some style cleanup --- ipod-shuffle-4g.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/ipod-shuffle-4g.py b/ipod-shuffle-4g.py index e61f642..210b07b 100755 --- a/ipod-shuffle-4g.py +++ b/ipod-shuffle-4g.py @@ -768,7 +768,13 @@ if __name__ == '__main__': else: verboseprint("Voiceover available.") - shuffle = Shuffler(result.path, track_voiceover=result.track_voiceover, playlist_voiceover=result.playlist_voiceover, rename=result.rename_unicode, trackgain=result.track_gain, auto_dir_playlists=result.auto_dir_playlists, auto_id3_playlists=result.auto_id3_playlists) + shuffle = Shuffler(result.path, + track_voiceover=result.track_voiceover, + playlist_voiceover=result.playlist_voiceover, + rename=result.rename_unicode, + trackgain=result.track_gain, + auto_dir_playlists=result.auto_dir_playlists, + auto_id3_playlists=result.auto_id3_playlists) shuffle.initialize() shuffle.populate() shuffle.write_database() From 8bfea0be3fc5327bc37b85d36f99d2829dd3ffbc Mon Sep 17 00:00:00 2001 From: NicoHood Date: Sat, 27 Aug 2016 21:16:14 +0200 Subject: [PATCH 43/55] removed pylint comments --- ipod-shuffle-4g.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/ipod-shuffle-4g.py b/ipod-shuffle-4g.py index 210b07b..62e6052 100755 --- a/ipod-shuffle-4g.py +++ b/ipod-shuffle-4g.py @@ -397,7 +397,7 @@ class Track(Record): # Handle the VoiceOverData if isinstance(text, str): text = text.encode('utf-8', 'ignore') - self["dbid"] = hashlib.md5(text).digest()[:8] #pylint: disable-msg=E1101 + self["dbid"] = hashlib.md5(text).digest()[:8] self.text_to_speech(text, self["dbid"]) class PlaylistHeader(Record): @@ -414,7 +414,7 @@ class PlaylistHeader(Record): ("unknown2", ("2s", b"\x00" * 2)), ]) - def construct(self, tracks): #pylint: disable-msg=W0221 + def construct(self, tracks): # Build the master list masterlist = Playlist(self) verboseprint("[+] Adding master playlist") @@ -465,7 +465,7 @@ class Playlist(Record): # By default use "All Songs" builtin voiceover (dbid all zero) # Else generate alternative "All Songs" to fit the speaker voice of other playlists if self.playlist_voiceover and (Text2Speech.valid_tts['pico2wave'] or Text2Speech.valid_tts['espeak']): - self["dbid"] = hashlib.md5(b"masterlist").digest()[:8] #pylint: disable-msg=E1101 + self["dbid"] = hashlib.md5(b"masterlist").digest()[:8] self.text_to_speech("All songs", self["dbid"], True) self["listtype"] = 1 self.listtracks = tracks @@ -551,10 +551,10 @@ class Playlist(Record): text = os.path.splitext(os.path.basename(filename))[0] # Handle the VoiceOverData - self["dbid"] = hashlib.md5(text.encode('utf-8')).digest()[:8] #pylint: disable-msg=E1101 + self["dbid"] = hashlib.md5(text.encode('utf-8')).digest()[:8] self.text_to_speech(text, self["dbid"], True) - def construct(self, tracks): #pylint: disable-msg=W0221 + def construct(self, tracks): self["total_length"] = 44 + (4 * len(self.listtracks)) self["number_of_songs"] = 0 From acf53b5c8f445477a4e112c324fd8562f04e2ad0 Mon Sep 17 00:00:00 2001 From: NicoHood Date: Sat, 27 Aug 2016 21:17:37 +0200 Subject: [PATCH 44/55] Updated readme instructions --- README.md | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 567feaa..7e5335a 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,7 @@ Python script for building the Track and Playlist database for the newer gen IPod Shuffle. Forked from the [shuffle-db-ng project](https://code.google.com/p/shuffle-db-ng/) +and improved my nims11 and NicoHood. Just put your audio files into the mass storage of your IPod and shuffle.py will do the rest. ``` @@ -50,7 +51,9 @@ optional arguments: #### Dependencies This script requires: -* [Python 2.7](http://www.python.org/download/releases/2.7/) +* [Python 3](https://www.python.org/download/releases/3.0/) + +Optional album/artist and auto-id3-playlists support: * [Mutagen](https://code.google.com/p/mutagen/) Optional Voiceover support @@ -61,18 +64,18 @@ Optional Voiceover support ##### Ubuntu -`apt-get install python-mutagen libttspico*` +`apt-get install python3 python-mutagen libttspico*` ##### Arch Linux -From the **Extra** repository: `pacman -S python2 mutagen` and optional `pacman -S espeak` or from the AUR: `svox-pico-bin` ([link](https://aur.archlinux.org/packages/svox-pico-bin/)) +From the **Extra** repository: `pacman -S python` and optional `pacman -S mutagen espeak` or from the AUR: `svox-pico-bin` ([link](https://aur.archlinux.org/packages/svox-pico-bin/)) You can also [install the script from AUR](https://aur.archlinux.org/packages/ipod-shuffle-4g/). ##### Gentoo Linux ```bash -PYTHON_TARGETS="python2_7" emerge -av media-libs/mutagen +PYTHON_TARGETS="python3" emerge -av media-libs/mutagen layman --add=ikelos layman --overlays="https://raw.githubusercontent.com/ahippo/rhvoice-gentoo-overlay/master/repositories.xml" --fetch --add=ahippo-rhvoice-overlay ACCEPT_KEYWORDS="~amd64" emerge -av app-accessibility/svox app-accessibility/rhvoice From 78ecedd8d5333d11053ccfd0aac4fb490362b194 Mon Sep 17 00:00:00 2001 From: Nimesh Ghelani Date: Sun, 28 Aug 2016 03:47:57 +0530 Subject: [PATCH 45/55] utf encode playlist names, fixes #35 --- README.md | 1 + ipod-shuffle-4g.py | 2 ++ 2 files changed, 3 insertions(+) diff --git a/README.md b/README.md index 567feaa..8af7eee 100644 --- a/README.md +++ b/README.md @@ -143,6 +143,7 @@ Original data can be found via [wayback machine](https://web.archive.org/web/201 * Ignore hidden filenames * Do not force playlist voiceover with auto playlists * Added shortcut parameters (-p, -t, -d, etc.) +* Fix UnicodeEncodeError for non-ascii playlist names (#35) 1.3 Release (08.06.2016) * Directory based auto playlist building (--auto-dir-playlists) (#13) diff --git a/ipod-shuffle-4g.py b/ipod-shuffle-4g.py index 0f0b773..717a4d4 100755 --- a/ipod-shuffle-4g.py +++ b/ipod-shuffle-4g.py @@ -545,6 +545,8 @@ class Playlist(Record): text = os.path.splitext(os.path.basename(filename))[0] # Handle the VoiceOverData + if isinstance(text, unicode): + text = text.encode('utf-8', 'ignore') self["dbid"] = hashlib.md5(text).digest()[:8] #pylint: disable-msg=E1101 self.text_to_speech(text, self["dbid"], True) From fa6e3f15d72665c6dcba96edf626cc07e4d83493 Mon Sep 17 00:00:00 2001 From: Nico Date: Tue, 7 Feb 2017 22:08:14 +0100 Subject: [PATCH 46/55] Add #41 to Tips and Tricks Readme section --- README.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/README.md b/README.md index 8af7eee..8aff1c6 100644 --- a/README.md +++ b/README.md @@ -116,6 +116,17 @@ In all cases you can try to update Rythmbox to the latest version, sync again or If you want to use this script on different computers it makes sense to simply copy the script into the IPod's root directory. +#### Format/Restore/Recover IPod +([#41](https://github.com/nims11/IPod-Shuffle-4g/issues/41)) If you formatted your IPod wrong and lost all data you can still recover it. +It is important to **not use MBR/GPT**. You need to directly create a **Fat16 Filesystem**: + +```bash +mkfs.vfat -F 16 /dev/sdX +``` + +Run this script to generate the new database. All missing sound files should be regenrated by the IPod on next use. +Your IPod should work and play music again now. + ## TODO * Last.fm Scrobbler * Qt frontend From 84a3dbf6e61bed469329112016f9952e4e068272 Mon Sep 17 00:00:00 2001 From: Nico Date: Tue, 7 Feb 2017 22:11:25 +0100 Subject: [PATCH 47/55] Better mkfs command --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 8aff1c6..bcf1c80 100644 --- a/README.md +++ b/README.md @@ -121,7 +121,7 @@ to simply copy the script into the IPod's root directory. It is important to **not use MBR/GPT**. You need to directly create a **Fat16 Filesystem**: ```bash -mkfs.vfat -F 16 /dev/sdX +sudo mkfs.vfat -I -F 16 -n IPOD /dev/sdX ``` Run this script to generate the new database. All missing sound files should be regenrated by the IPod on next use. From 052979949580c1063ae6ae74b6b7c6b512739142 Mon Sep 17 00:00:00 2001 From: NicoHood Date: Tue, 9 Jun 2020 23:20:56 +0200 Subject: [PATCH 48/55] Update Readme for 1.5 --- README.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index a1c3bf0..b3df597 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ usage: ipod-shuffle-4g.py [-h] [-t] [-p] [-u] [-g TRACK_GAIN] path Python script for building the Track and Playlist database for the newer gen -IPod Shuffle. Version 1.4 +IPod Shuffle. Version 1.5 positional arguments: path Path to the IPod's root directory @@ -68,7 +68,7 @@ Optional Voiceover support ##### Arch Linux -From the **Extra** repository: `pacman -S python` and optional `pacman -S mutagen espeak` or from the AUR: `svox-pico-bin` ([link](https://aur.archlinux.org/packages/svox-pico-bin/)) +From the **Extra** repository: `pacman -S python` and optional `pacman -S python-mutagen espeak` or from the AUR: `svox-pico-bin` ([link](https://aur.archlinux.org/packages/svox-pico-bin/)) You can also [install the script from AUR](https://aur.archlinux.org/packages/ipod-shuffle-4g/). @@ -82,7 +82,7 @@ ACCEPT_KEYWORDS="~amd64" emerge -av app-accessibility/svox app-accessibility/rhv ``` References to the overlays above: [ikelos](http://git.overlays.gentoo.org/gitweb/?p=dev/ikelos.git;a=summary), [ahippo-rhvoice-overlay](https://github.com/ahippo/rhvoice-gentoo-overlay) -##Tips and Tricks +## Tips and Tricks #### Disable trash for IPod To avoid that linux moves deleted files into trash you can create an empty file `.Trash-1000`. @@ -137,6 +137,10 @@ Original data can be found via [wayback machine](https://web.archive.org/web/201 # Version History ``` +1.5 Release (09.06.2020) +* Port Script to Python3 +* Mutagen support is now optional + 1.4 Release (27.08.2016) * Catch "no space left" error #30 * Renamed --voiceover to --track-voiceover From ddf4f5be777632906457643ebf8017a078f6535c Mon Sep 17 00:00:00 2001 From: NicoHood Date: Tue, 9 Jun 2020 23:21:38 +0200 Subject: [PATCH 49/55] Apply fixed suggested by @nims11 review --- ipod-shuffle-4g.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/ipod-shuffle-4g.py b/ipod-shuffle-4g.py index 62e6052..f3aca4d 100755 --- a/ipod-shuffle-4g.py +++ b/ipod-shuffle-4g.py @@ -32,14 +32,14 @@ def make_dir_if_absent(path): def raises_unicode_error(str): try: - str.decode('utf-8').encode('latin-1') + str.encode('latin-1') return False except (UnicodeEncodeError, UnicodeDecodeError): return True def hash_error_unicode(item): - return "".join(["{0:02X}".format(ord(x)) for x in reversed(hashlib.md5(item).digest()[:8])]) - pass + item_bytes = item.encode('utf-8') + return "".join(["{0:02X}".format(ord(x)) for x in reversed(hashlib.md5(item_bytes).hexdigest()[:8])]) def validate_unicode(path): path_list = path.split('/') @@ -711,7 +711,7 @@ if __name__ == '__main__': parser = argparse.ArgumentParser(description= 'Python script for building the Track and Playlist database ' - 'for the newer gen IPod Shuffle. Version 1.4') + 'for the newer gen IPod Shuffle. Version 1.5') parser.add_argument('-t', '--track-voiceover', action='store_true', help='Enable track voiceover feature') From 64414b61588c22d1d6861335adaeac68d708632c Mon Sep 17 00:00:00 2001 From: HarJIT Date: Sat, 13 Mar 2021 13:03:19 +0000 Subject: [PATCH 50/55] Fix TypeError when reading playlist files. --- ipod-shuffle-4g.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ipod-shuffle-4g.py b/ipod-shuffle-4g.py index f3aca4d..12d1860 100755 --- a/ipod-shuffle-4g.py +++ b/ipod-shuffle-4g.py @@ -534,7 +534,7 @@ class Playlist(Record): text = os.path.splitext(os.path.basename(filename))[0] else: # Read the playlist file - with open(filename, 'rb') as f: + with open(filename, 'r', errors="replace") as f: data = f.readlines() extension = os.path.splitext(filename)[1].lower() From 4fd26bf51c5196c4aa00216ecd29f4496aa07280 Mon Sep 17 00:00:00 2001 From: NicoHood Date: Sat, 5 Jun 2021 08:51:57 +0200 Subject: [PATCH 51/55] Moved Changelog to a separate file with KeepAChangelog format --- CHANGELOG.md | 111 +++++++++++++++++++++++++++++++++++++++++++++++++++ README.md | 54 ------------------------- 2 files changed, 111 insertions(+), 54 deletions(-) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..0665084 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,111 @@ +# Changelog +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +This changlog uses the [ISO 8601 date format](https://www.iso.org/iso-8601-date-and-time-format.html) of (YYYY-MM-DD). + +## [Unreleased] + +## [1.5.1] - 2021-06-05 + +### Changed + +* Moved Changelog from Readme to Changelog.md file + +### Fixed + +* Fix TypeError when reading playlist files [#50](https://github.com/nims11/IPod-Shuffle-4g/pull/50) + +## [1.5.0] - 2020-06-10 + +### Changed + +* Port Script to Python3 +* Mutagen support is now optional + +## [1.4.0] - 2016-08-28 + +### Added + +* Added optional `--verbose` output +* Added files to `extras` folder +* Added shortcut parameters (`-p`, `-t`, `-d`, etc.) + +### Changed + +* Renamed `--voiceover` to `--track-voiceover` +* Renamed script from `shuffle.py` to `ipod-shuffle-4g.py` +* Ignore hidden filenames +* Do not force playlist voiceover with auto playlists + +### Fixed + +* Catch "no space left" error [#30](https://github.com/nims11/IPod-Shuffle-4g/issues/30) +* Fix UnicodeEncodeError for non-ascii playlist names [#35](https://github.com/nims11/IPod-Shuffle-4g/issues/35) + +## [1.3.0] - 2016-06-08 + +### Added + +* Directory based auto playlist building (`--auto-dir-playlists`) [#13](https://github.com/nims11/IPod-Shuffle-4g/issues/13) +* ID3 tags based auto playlist building (`--auto-id3-playlists`) +* Added short program description +* Differentiate track and playlist voiceover [#26](https://github.com/nims11/IPod-Shuffle-4g/issues/26) + +### Changed + +* Voiceover disabled by default [#26](https://github.com/nims11/IPod-Shuffle-4g/issues/26) (Playlist voiceover enabled with auto playlist generation) + + +### Fixed + +* Fix hyphen in filename [#4](https://github.com/nims11/IPod-Shuffle-4g/issues/4) +* Fixed mutagen bug [#5](https://github.com/nims11/IPod-Shuffle-4g/issues/5) + +## [1.2.0] - 2016-02-04 + +### Added + +* Added Path help entry +* Added MIT License +* Added this changelog + +### Changed + +* Skip existing voiceover files with the same name (e.g. "Track 1.mp3") +* Made help message lower case +* Improved Readme +* Improved docs + +### Fixed + +* Additional fixes from NicoHood +* Fixed "All Songs" and "Playlist N" sounds when voiceover is disabled [#17](https://github.com/nims11/IPod-Shuffle-4g/issues/17) +* Better handle broken playlist paths [#16](https://github.com/nims11/IPod-Shuffle-4g/issues/16) +* Only use voiceover if dependencies are installed + +## [1.1.0] - 2016-01-23 + +### Added + +* Fixes from nims11 fork +* Option to disable voiceover +* Initialize the IPod Directory tree +* Using the `--rename-unicode` flag filenames with strange characters and different language are renamed which avoids the script to crash with a Unicode Error +* Other small fixes + +## [1.0.0] - 2012-10-17 + +### Added + +* Original release by ikelos + +[Unreleased]: https://github.com/nims11/IPod-Shuffle-4g/compare/1.5.1...HEAD +[1.5.1]: https://github.com/nims11/IPod-Shuffle-4g/compare/v1.5...1.5.1 +[1.5.0]: https://github.com/nims11/IPod-Shuffle-4g/compare/v1.4...v1.5 +[1.4.0]: https://github.com/nims11/IPod-Shuffle-4g/compare/v1.3...v1.4 +[1.3.0]: https://github.com/nims11/IPod-Shuffle-4g/compare/v1.2...v1.3 +[1.2.0]: https://github.com/nims11/IPod-Shuffle-4g/compare/v1.1...v1.2 +[1.1.0]: https://github.com/nims11/IPod-Shuffle-4g/compare/646b7def4c498c59b063e535a5b64695d8d87e6b...v1.1 +[1.0.0]: https://github.com/nims11/IPod-Shuffle-4g/commit/646b7def4c498c59b063e535a5b64695d8d87e6b diff --git a/README.md b/README.md index 89ad287..c0445db 100644 --- a/README.md +++ b/README.md @@ -143,57 +143,3 @@ Your IPod should work and play music again now. The original shuffle3db website went offline. This repository contains a copy of the information inside the `docs` folder. Original data can be found via [wayback machine](https://web.archive.org/web/20131016014401/http://shuffle3db.wikispaces.com/iTunesSD3gen). - - -# Version History - -``` -1.5 Release (09.06.2020) -* Port Script to Python3 -* Mutagen support is now optional - -1.4 Release (27.08.2016) -* Catch "no space left" error #30 -* Renamed --voiceover to --track-voiceover -* Added optional --verbose output -* Renamed script from shuffle.py to ipod-shuffle-4g.py -* Added files to `extras` folder -* Ignore hidden filenames -* Do not force playlist voiceover with auto playlists -* Added shortcut parameters (-p, -t, -d, etc.) -* Fix UnicodeEncodeError for non-ascii playlist names (#35) - -1.3 Release (08.06.2016) -* Directory based auto playlist building (--auto-dir-playlists) (#13) -* ID3 tags based auto playlist building (--auto-id3-playlists) -* Added short program description -* Fix hyphen in filename #4 -* Fixed mutagen bug #5 -* Voiceover disabled by default #26 (Playlist voiceover enabled with auto playlist generation) -* Differentiate track and playlist voiceover #26 - -1.2 Release (04.02.2016) -* Additional fixes from NicoHood -* Fixed "All Songs" and "Playlist N" sounds when voiceover is disabled #17 -* Better handle broken playlist paths #16 -* Skip existing voiceover files with the same name (e.g. "Track 1.mp3") -* Only use voiceover if dependencies are installed -* Added Path help entry -* Made help message lower case -* Improved Readme -* Improved docs -* Added MIT License -* Added this changelog - -1.1 Release (11.10.2013 - 23.01.2016) -* Fixes from nims11 fork -* Option to disable voiceover -* Initialize the IPod Directory tree -* Using the --rename-unicode flag - filenames with strange characters and different language are renamed - which avoids the script to crash with a Unicode Error -* Other small fixes - -1.0 Release (15.08.2012 - 17.10.2012) -* Original release by ikelos -``` From 8863026157235630fd023a2a2e02ab7fdbbbeac6 Mon Sep 17 00:00:00 2001 From: Arno Hautala Date: Tue, 24 Aug 2021 22:46:05 -0400 Subject: [PATCH 52/55] support for using "say" to generate voiceover files --- ipod-shuffle-4g.py | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/ipod-shuffle-4g.py b/ipod-shuffle-4g.py index 12d1860..9fb69ee 100755 --- a/ipod-shuffle-4g.py +++ b/ipod-shuffle-4g.py @@ -97,12 +97,19 @@ def group_tracks_by_id3_template(tracks, template): return sorted(grouped_tracks_dict.items()) class Text2Speech(object): - valid_tts = {'pico2wave': True, 'RHVoice': True, 'espeak': True} + valid_tts = {'pico2wave': True, 'RHVoice': True, 'espeak': True, 'say': True} @staticmethod def check_support(): voiceoverAvailable = False + # Check for say voiceover + if not exec_exists_in_path("say"): + Text2Speech.valid_tts['say'] = False + print("Warning: say not found, voicever won't be generated using it.") + else: + voiceoverAvailable = True + # Check for pico2wave voiceover if not exec_exists_in_path("pico2wave"): Text2Speech.valid_tts['pico2wave'] = False @@ -147,6 +154,8 @@ class Text2Speech(object): return True elif Text2Speech.espeak(out_wav_path, text): return True + elif Text2Speech.say(out_wav_path, text): + return True else: return False @@ -165,6 +174,13 @@ class Text2Speech(object): subprocess.call(["pico2wave", "-l", "en-GB", "-w", out_wav_path, unicodetext]) return True + @staticmethod + def say(out_wav_path, unicodetext): + if not Text2Speech.valid_tts['say']: + return False + subprocess.call(["say", "-o", out_wav_path, '--data-format=LEI16', '--file-format=WAVE', unicodetext]) + return True + @staticmethod def espeak(out_wav_path, unicodetext): if not Text2Speech.valid_tts['espeak']: @@ -464,7 +480,7 @@ class Playlist(Record): def set_master(self, tracks): # By default use "All Songs" builtin voiceover (dbid all zero) # Else generate alternative "All Songs" to fit the speaker voice of other playlists - if self.playlist_voiceover and (Text2Speech.valid_tts['pico2wave'] or Text2Speech.valid_tts['espeak']): + if self.playlist_voiceover and (Text2Speech.valid_tts['pico2wave'] or Text2Speech.valid_tts['espeak'] or Text2Speech.valid_tts['say']): self["dbid"] = hashlib.md5(b"masterlist").digest()[:8] self.text_to_speech("All songs", self["dbid"], True) self["listtype"] = 1 From 6186600e4cbea520becdda2e7856a2761622234e Mon Sep 17 00:00:00 2001 From: Arno Hautala Date: Wed, 25 Aug 2021 17:05:11 -0400 Subject: [PATCH 53/55] use double dash (--) to indicate start of voiceover text parameter --- ipod-shuffle-4g.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ipod-shuffle-4g.py b/ipod-shuffle-4g.py index 9fb69ee..70f3687 100755 --- a/ipod-shuffle-4g.py +++ b/ipod-shuffle-4g.py @@ -171,21 +171,21 @@ class Text2Speech(object): def pico2wave(out_wav_path, unicodetext): if not Text2Speech.valid_tts['pico2wave']: return False - subprocess.call(["pico2wave", "-l", "en-GB", "-w", out_wav_path, unicodetext]) + subprocess.call(["pico2wave", "-l", "en-GB", "-w", out_wav_path, '--', unicodetext]) return True @staticmethod def say(out_wav_path, unicodetext): if not Text2Speech.valid_tts['say']: return False - subprocess.call(["say", "-o", out_wav_path, '--data-format=LEI16', '--file-format=WAVE', unicodetext]) + subprocess.call(["say", "-o", out_wav_path, '--data-format=LEI16', '--file-format=WAVE', '--', unicodetext]) return True @staticmethod def espeak(out_wav_path, unicodetext): if not Text2Speech.valid_tts['espeak']: return False - subprocess.call(["espeak", "-v", "english_rp", "-s", "150", "-w", out_wav_path, unicodetext]) + subprocess.call(["espeak", "-v", "english_rp", "-s", "150", "-w", out_wav_path, '--', unicodetext]) return True @staticmethod From 37ba988b737910fcace3f836277b0c8fd6c5255d Mon Sep 17 00:00:00 2001 From: Arno Hautala Date: Sat, 28 Aug 2021 10:13:03 -0400 Subject: [PATCH 54/55] clarify macOS say provenance --- ipod-shuffle-4g.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ipod-shuffle-4g.py b/ipod-shuffle-4g.py index 70f3687..8225a56 100755 --- a/ipod-shuffle-4g.py +++ b/ipod-shuffle-4g.py @@ -103,10 +103,10 @@ class Text2Speech(object): def check_support(): voiceoverAvailable = False - # Check for say voiceover + # Check for macOS say voiceover if not exec_exists_in_path("say"): Text2Speech.valid_tts['say'] = False - print("Warning: say not found, voicever won't be generated using it.") + print("Warning: macOS say not found, voicever won't be generated using it.") else: voiceoverAvailable = True From 4a2031a821635eaa335e2440f180a4bc24c2774e Mon Sep 17 00:00:00 2001 From: Arno Hautala Date: Sun, 29 Aug 2021 10:13:24 -0400 Subject: [PATCH 55/55] mentions macOS say in readme --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 89ad287..6d96eb5 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,7 @@ Optional Voiceover support * [PicoSpeaker](http://picospeaker.tk/readme.php) * [RHVoice (master branch, 3e31edced402a08771d2c48c73213982cbe9333e)](https://github.com/Olga-Yakovleva/RHVoice) -- (Russian files only) * [SoX](http://sox.sourceforge.net) -- (Russian files) +* say (macOS) ##### Ubuntu