fixed pico2wave functionality

This commit is contained in:
Avi Herman 2024-07-07 10:05:01 -04:00
parent a97a99ab86
commit 01f8b235e7

View file

@ -1,19 +1,21 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# Builtin libraries # Builtin libraries
import sys import argparse
import struct
import urllib.request, urllib.parse, urllib.error
import os
import hashlib
import subprocess
import collections import collections
import errno import errno
import argparse import hashlib
import shutil import os
import re import re
import tempfile import shutil
import signal import signal
import struct
import subprocess
import sys
import tempfile
import urllib.error
import urllib.parse
import urllib.request
# External libraries # External libraries
try: try:
@ -23,6 +25,8 @@ except ImportError:
audio_ext = (".mp3", ".m4a", ".m4b", ".m4p", ".aa", ".wav") audio_ext = (".mp3", ".m4a", ".m4b", ".m4p", ".aa", ".wav")
list_ext = (".pls", ".m3u") list_ext = (".pls", ".m3u")
def make_dir_if_absent(path): def make_dir_if_absent(path):
try: try:
os.makedirs(path) os.makedirs(path)
@ -30,19 +34,27 @@ def make_dir_if_absent(path):
if exc.errno != errno.EEXIST: if exc.errno != errno.EEXIST:
raise raise
def raises_unicode_error(str): def raises_unicode_error(str):
try: try:
str.encode('latin-1') str.encode("latin-1")
return False return False
except (UnicodeEncodeError, UnicodeDecodeError): except (UnicodeEncodeError, UnicodeDecodeError):
return True return True
def hash_error_unicode(item): def hash_error_unicode(item):
item_bytes = item.encode('utf-8') item_bytes = item.encode("utf-8")
return "".join(["{0:02X}".format(ord(x)) for x in reversed(hashlib.md5(item_bytes).hexdigest()[:8])]) return "".join(
[
"{0:02X}".format(ord(x))
for x in reversed(hashlib.md5(item_bytes).hexdigest()[:8])
]
)
def validate_unicode(path): def validate_unicode(path):
path_list = path.split('/') path_list = path.split("/")
last_raise = False last_raise = False
for i in range(len(path_list)): for i in range(len(path_list)):
if raises_unicode_error(path_list[i]): if raises_unicode_error(path_list[i]):
@ -51,30 +63,43 @@ def validate_unicode(path):
else: else:
last_raise = False last_raise = False
extension = os.path.splitext(path)[1].lower() extension = os.path.splitext(path)[1].lower()
return "/".join(path_list) + (extension if last_raise and extension in audio_ext else '') return "/".join(path_list) + (
extension if last_raise and extension in audio_ext else ""
)
def exec_exists_in_path(command): def exec_exists_in_path(command):
with open(os.devnull, 'w') as FNULL: with open(os.devnull, "w") as FNULL:
try: try:
with open(os.devnull, 'r') as RFNULL: with open(os.devnull, "r") as RFNULL:
subprocess.call([command], stdout=FNULL, stderr=subprocess.STDOUT, stdin=RFNULL) subprocess.call(
[command], stdout=FNULL, stderr=subprocess.STDOUT, stdin=RFNULL
)
return True return True
except OSError as e: except OSError as e:
return False return False
def splitpath(path): def splitpath(path):
return path.split(os.sep) return path.split(os.sep)
def get_relpath(path, basepath): def get_relpath(path, basepath):
commonprefix = os.sep.join(os.path.commonprefix(list(map(splitpath, [path, basepath])))) commonprefix = os.sep.join(
os.path.commonprefix(list(map(splitpath, [path, basepath])))
)
return os.path.relpath(path, commonprefix) return os.path.relpath(path, commonprefix)
def is_path_prefix(prefix, path): def is_path_prefix(prefix, path):
return prefix == os.sep.join(os.path.commonprefix(list(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): def group_tracks_by_id3_template(tracks, template):
grouped_tracks_dict = {} grouped_tracks_dict = {}
template_vars = set(re.findall(r'{.*?}', template)) template_vars = set(re.findall(r"{.*?}", template))
for track in tracks: for track in tracks:
try: try:
id3_dict = mutagen.File(track, easy=True) id3_dict = mutagen.File(track, easy=True)
@ -84,7 +109,7 @@ def group_tracks_by_id3_template(tracks, template):
key = template key = template
single_var_present = False single_var_present = False
for var in template_vars: for var in template_vars:
val = id3_dict.get(var[1:-1], [''])[0] val = id3_dict.get(var[1:-1], [""])[0]
if len(val) > 0: if len(val) > 0:
single_var_present = True single_var_present = True
key = key.replace(var, val) key = key.replace(var, val)
@ -96,8 +121,9 @@ def group_tracks_by_id3_template(tracks, template):
return sorted(grouped_tracks_dict.items()) return sorted(grouped_tracks_dict.items())
class Text2Speech(object): class Text2Speech(object):
valid_tts = {'pico2wave': True, 'RHVoice': True, 'espeak': True, 'say': True} valid_tts = {"pico2wave": True, "RHVoice": True, "espeak": True, "say": True}
@staticmethod @staticmethod
def check_support(): def check_support():
@ -105,28 +131,28 @@ class Text2Speech(object):
# Check for macOS say voiceover # Check for macOS say voiceover
if not exec_exists_in_path("say"): if not exec_exists_in_path("say"):
Text2Speech.valid_tts['say'] = False Text2Speech.valid_tts["say"] = False
print("Warning: macOS say not found, voicever won't be generated using it.") print("Warning: macOS say not found, voicever won't be generated using it.")
else: else:
voiceoverAvailable = True voiceoverAvailable = True
# Check for pico2wave voiceover # Check for pico2wave voiceover
if not exec_exists_in_path("pico2wave"): if not exec_exists_in_path("pico2wave"):
Text2Speech.valid_tts['pico2wave'] = False 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: else:
voiceoverAvailable = True voiceoverAvailable = True
# Check for espeak voiceover # Check for espeak voiceover
if not exec_exists_in_path("espeak"): if not exec_exists_in_path("espeak"):
Text2Speech.valid_tts['espeak'] = False 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: else:
voiceoverAvailable = True voiceoverAvailable = True
# Check for Russian RHVoice voiceover # Check for Russian RHVoice voiceover
if not exec_exists_in_path("RHVoice"): if not exec_exists_in_path("RHVoice"):
Text2Speech.valid_tts['RHVoice'] = False 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: else:
voiceoverAvailable = True voiceoverAvailable = True
@ -145,7 +171,7 @@ class Text2Speech(object):
# ensure we deal with unicode later # ensure we deal with unicode later
if not isinstance(text, str): if not isinstance(text, str):
text = str(text, 'utf-8') text = str(text, "utf-8")
lang = Text2Speech.guess_lang(text) lang = Text2Speech.guess_lang(text)
if lang == "ru-RU": if lang == "ru-RU":
return Text2Speech.rhvoice(out_wav_path, text) return Text2Speech.rhvoice(out_wav_path, text)
@ -162,42 +188,76 @@ class Text2Speech(object):
# guess-language seems like an overkill for now # guess-language seems like an overkill for now
@staticmethod @staticmethod
def guess_lang(unicodetext): def guess_lang(unicodetext):
lang = 'en-GB' lang = "en-GB"
if re.search("[А-Яа-я]", unicodetext) is not None: if re.search("[А-Яа-я]", unicodetext) is not None:
lang = 'ru-RU' lang = "ru-RU"
return lang return lang
@staticmethod @staticmethod
def pico2wave(out_wav_path, unicodetext): def pico2wave(out_wav_path, unicodetext):
if not Text2Speech.valid_tts['pico2wave']: if not Text2Speech.valid_tts["pico2wave"]:
return False 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 return True
@staticmethod @staticmethod
def say(out_wav_path, unicodetext): def say(out_wav_path, unicodetext):
if not Text2Speech.valid_tts['say']: if not Text2Speech.valid_tts["say"]:
return False 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 return True
@staticmethod @staticmethod
def espeak(out_wav_path, unicodetext): def espeak(out_wav_path, unicodetext):
if not Text2Speech.valid_tts['espeak']: if not Text2Speech.valid_tts["espeak"]:
return False 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 return True
@staticmethod @staticmethod
def rhvoice(out_wav_path, unicodetext): def rhvoice(out_wav_path, unicodetext):
if not Text2Speech.valid_tts['RHVoice']: if not Text2Speech.valid_tts["RHVoice"]:
return False return False
tmp_file = tempfile.NamedTemporaryFile(suffix=".wav", delete=False) tmp_file = tempfile.NamedTemporaryFile(suffix=".wav", delete=False)
tmp_file.close() tmp_file.close()
proc = subprocess.Popen(["RHVoice", "--voice=Elena", "--variant=Russian", "--volume=100", "-o", tmp_file.name], stdin=subprocess.PIPE) proc = subprocess.Popen(
proc.communicate(input=unicodetext.encode('utf-8')) [
"RHVoice",
"--voice=Elena",
"--variant=Russian",
"--volume=100",
"-o",
tmp_file.name,
],
stdin=subprocess.PIPE,
)
proc.communicate(input=unicodetext.encode("utf-8"))
# make a little bit louder to be comparable with pico2wave # make a little bit louder to be comparable with pico2wave
subprocess.call(["sox", tmp_file.name, out_wav_path, "norm"]) subprocess.call(["sox", tmp_file.name, out_wav_path, "norm"])
@ -231,17 +291,30 @@ class Record(object):
output += struct.pack("<" + fmt, self._fields.get(i, default)) output += struct.pack("<" + fmt, self._fields.get(i, default))
return output return output
def text_to_speech(self, text, dbid, playlist = False): def text_to_speech(self, text, dbid, playlist=False):
if self.track_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 # Create the voiceover wav file
fn = ''.join(format(x, '02x') 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") path = os.path.join(
self.base,
"iPod_Control",
"Speakable",
"Tracks" if not playlist else "Playlists",
fn + ".wav",
)
return Text2Speech.text2speech(path, text) return Text2Speech.text2speech(path, text)
return False return False
def path_to_ipod(self, filename): def path_to_ipod(self, filename):
if os.path.commonprefix([os.path.abspath(filename), self.base]) != self.base: if os.path.commonprefix([os.path.abspath(filename), self.base]) != self.base:
raise IOError("Cannot get Ipod filename, since file is outside the IPOD path") raise IOError(
"Cannot get Ipod filename, since file is outside the IPOD path"
)
baselen = len(self.base) baselen = len(self.base)
if self.base.endswith(os.path.sep): if self.base.endswith(os.path.sep):
baselen -= 1 baselen -= 1
@ -249,7 +322,9 @@ class Record(object):
return ipodname return ipodname
def ipod_to_path(self, ipodname): def ipod_to_path(self, ipodname):
return os.path.abspath(os.path.join(self.base, os.path.sep.join(ipodname.split("/")))) return os.path.abspath(
os.path.join(self.base, os.path.sep.join(ipodname.split("/")))
)
@property @property
def shuffledb(self): def shuffledb(self):
@ -278,26 +353,29 @@ class Record(object):
def lists(self): def lists(self):
return self.shuffledb.lists return self.shuffledb.lists
class TunesSD(Record): class TunesSD(Record):
def __init__(self, parent): def __init__(self, parent):
Record.__init__(self, parent) Record.__init__(self, parent)
self.track_header = TrackHeader(self) self.track_header = TrackHeader(self)
self.play_header = PlaylistHeader(self) self.play_header = PlaylistHeader(self)
self._struct = collections.OrderedDict([ self._struct = collections.OrderedDict(
("header_id", ("4s", b"bdhs")), # shdb [
("unknown1", ("I", 0x02000003)), ("header_id", ("4s", b"bdhs")), # shdb
("total_length", ("I", 64)), ("unknown1", ("I", 0x02000003)),
("total_number_of_tracks", ("I", 0)), ("total_length", ("I", 64)),
("total_number_of_playlists", ("I", 0)), ("total_number_of_tracks", ("I", 0)),
("unknown2", ("Q", 0)), ("total_number_of_playlists", ("I", 0)),
("max_volume", ("B", 0)), ("unknown2", ("Q", 0)),
("voiceover_enabled", ("B", int(self.track_voiceover))), ("max_volume", ("B", 0)),
("unknown3", ("H", 0)), ("voiceover_enabled", ("B", int(self.track_voiceover))),
("total_tracks_without_podcasts", ("I", 0)), ("unknown3", ("H", 0)),
("track_header_offset", ("I", 64)), ("total_tracks_without_podcasts", ("I", 0)),
("playlist_header_offset", ("I", 0)), ("track_header_offset", ("I", 64)),
("unknown4", ("20s", b"\x00" * 20)), ("playlist_header_offset", ("I", 0)),
]) ("unknown4", ("20s", b"\x00" * 20)),
]
)
def construct(self): def construct(self):
# The header is a fixed length, so no need to calculate it # The header is a fixed length, so no need to calculate it
@ -316,16 +394,19 @@ class TunesSD(Record):
output = Record.construct(self) output = Record.construct(self)
return output + track_header + play_header return output + track_header + play_header
class TrackHeader(Record): class TrackHeader(Record):
def __init__(self, parent): def __init__(self, parent):
self.base_offset = 0 self.base_offset = 0
Record.__init__(self, parent) Record.__init__(self, parent)
self._struct = collections.OrderedDict([ self._struct = collections.OrderedDict(
("header_id", ("4s", b"hths")), # shth [
("total_length", ("I", 0)), ("header_id", ("4s", b"hths")), # shth
("number_of_tracks", ("I", 0)), ("total_length", ("I", 0)),
("unknown1", ("Q", 0)), ("number_of_tracks", ("I", 0)),
]) ("unknown1", ("Q", 0)),
]
)
def construct(self): def construct(self):
self["number_of_tracks"] = len(self.tracks) self["number_of_tracks"] = len(self.tracks)
@ -338,44 +419,49 @@ class TrackHeader(Record):
track = Track(self) track = Track(self)
verboseprint("[*] Adding track", i) verboseprint("[*] Adding track", i)
track.populate(i) track.populate(i)
output += struct.pack("I", self.base_offset + self["total_length"] + len(track_chunk)) output += struct.pack(
"I", self.base_offset + self["total_length"] + len(track_chunk)
)
track_chunk += track.construct() track_chunk += track.construct()
return output + track_chunk return output + track_chunk
class Track(Record): class Track(Record):
def __init__(self, parent): def __init__(self, parent):
Record.__init__(self, parent) Record.__init__(self, parent)
self._struct = collections.OrderedDict([ self._struct = collections.OrderedDict(
("header_id", ("4s", b"rths")), # shtr [
("header_length", ("I", 0x174)), ("header_id", ("4s", b"rths")), # shtr
("start_at_pos_ms", ("I", 0)), ("header_length", ("I", 0x174)),
("stop_at_pos_ms", ("I", 0)), ("start_at_pos_ms", ("I", 0)),
("volume_gain", ("I", int(self.trackgain))), ("stop_at_pos_ms", ("I", 0)),
("filetype", ("I", 1)), ("volume_gain", ("I", int(self.trackgain))),
("filename", ("256s", b"\x00" * 256)), ("filetype", ("I", 1)),
("bookmark", ("I", 0)), ("filename", ("256s", b"\x00" * 256)),
("dontskip", ("B", 1)), ("bookmark", ("I", 0)),
("remember", ("B", 0)), ("dontskip", ("B", 1)),
("unintalbum", ("B", 0)), ("remember", ("B", 0)),
("unknown", ("B", 0)), ("unintalbum", ("B", 0)),
("pregap", ("I", 0x200)), ("unknown", ("B", 0)),
("postgap", ("I", 0x200)), ("pregap", ("I", 0x200)),
("numsamples", ("I", 0)), ("postgap", ("I", 0x200)),
("unknown2", ("I", 0)), ("numsamples", ("I", 0)),
("gapless", ("I", 0)), ("unknown2", ("I", 0)),
("unknown3", ("I", 0)), ("gapless", ("I", 0)),
("albumid", ("I", 0)), ("unknown3", ("I", 0)),
("track", ("H", 1)), ("albumid", ("I", 0)),
("disc", ("H", 0)), ("track", ("H", 1)),
("unknown4", ("Q", 0)), ("disc", ("H", 0)),
("dbid", ("8s", 0)), ("unknown4", ("Q", 0)),
("artistid", ("I", 0)), ("dbid", ("8s", 0)),
("unknown5", ("32s", b"\x00" * 32)), ("artistid", ("I", 0)),
]) ("unknown5", ("32s", b"\x00" * 32)),
]
)
def populate(self, filename): def populate(self, filename):
self["filename"] = self.path_to_ipod(filename).encode('utf-8') self["filename"] = self.path_to_ipod(filename).encode("utf-8")
if os.path.splitext(filename)[1].lower() in (".m4a", ".m4b", ".m4p", ".aa"): if os.path.splitext(filename)[1].lower() in (".m4a", ".m4b", ".m4p", ".aa"):
self["filetype"] = 2 self["filetype"] = 2
@ -386,9 +472,11 @@ class Track(Record):
if mutagen: if mutagen:
audio = None audio = None
try: try:
audio = mutagen.File(filename, easy = True) audio = mutagen.File(filename, easy=True)
except: 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: if audio:
# Note: Rythmbox IPod plugin sets this value always 0. # Note: Rythmbox IPod plugin sets this value always 0.
self["stop_at_pos_ms"] = int(audio.info.length * 1000) self["stop_at_pos_ms"] = int(audio.info.length * 1000)
@ -412,23 +500,26 @@ class Track(Record):
# Handle the VoiceOverData # Handle the VoiceOverData
if isinstance(text, str): if isinstance(text, str):
text = text.encode('utf-8', 'ignore') text = text.encode("utf-8", "ignore")
self["dbid"] = hashlib.md5(text).digest()[:8] self["dbid"] = hashlib.md5(text).digest()[:8]
self.text_to_speech(text, self["dbid"]) self.text_to_speech(text, self["dbid"])
class PlaylistHeader(Record): class PlaylistHeader(Record):
def __init__(self, parent): def __init__(self, parent):
self.base_offset = 0 self.base_offset = 0
Record.__init__(self, parent) Record.__init__(self, parent)
self._struct = collections.OrderedDict([ self._struct = collections.OrderedDict(
("header_id", ("4s", b"hphs")), #shph [
("total_length", ("I", 0)), ("header_id", ("4s", b"hphs")), # shph
("number_of_playlists", ("I", 0)), ("total_length", ("I", 0)),
("number_of_non_podcast_lists", ("2s", b"\xFF\xFF")), ("number_of_playlists", ("I", 0)),
("number_of_master_lists", ("2s", b"\x01\x00")), ("number_of_non_podcast_lists", ("2s", b"\xFF\xFF")),
("number_of_non_audiobook_lists", ("2s", b"\xFF\xFF")), ("number_of_master_lists", ("2s", b"\x01\x00")),
("unknown2", ("2s", b"\x00" * 2)), ("number_of_non_audiobook_lists", ("2s", b"\xFF\xFF")),
]) ("unknown2", ("2s", b"\x00" * 2)),
]
)
def construct(self, tracks): def construct(self, tracks):
# Build the master list # Build the master list
@ -448,7 +539,9 @@ class PlaylistHeader(Record):
playlistcount += 1 playlistcount += 1
chunks += [construction] chunks += [construction]
else: 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["number_of_playlists"] = playlistcount
self["total_length"] = 0x14 + (self["number_of_playlists"] * 4) self["total_length"] = 0x14 + (self["number_of_playlists"] * 4)
@ -463,24 +556,31 @@ class PlaylistHeader(Record):
return output + b"".join(chunks) return output + b"".join(chunks)
class Playlist(Record): class Playlist(Record):
def __init__(self, parent): def __init__(self, parent):
self.listtracks = [] self.listtracks = []
Record.__init__(self, parent) Record.__init__(self, parent)
self._struct = collections.OrderedDict([ self._struct = collections.OrderedDict(
("header_id", ("4s", b"lphs")), # shpl [
("total_length", ("I", 0)), ("header_id", ("4s", b"lphs")), # shpl
("number_of_songs", ("I", 0)), ("total_length", ("I", 0)),
("number_of_nonaudio", ("I", 0)), ("number_of_songs", ("I", 0)),
("dbid", ("8s", b"\x00" * 8)), ("number_of_nonaudio", ("I", 0)),
("listtype", ("I", 2)), ("dbid", ("8s", b"\x00" * 8)),
("unknown1", ("16s", b"\x00" * 16)) ("listtype", ("I", 2)),
]) ("unknown1", ("16s", b"\x00" * 16)),
]
)
def set_master(self, tracks): def set_master(self, tracks):
# By default use "All Songs" builtin voiceover (dbid all zero) # By default use "All Songs" builtin voiceover (dbid all zero)
# Else generate alternative "All Songs" to fit the speaker voice of other playlists # 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'] or Text2Speech.valid_tts['say']): 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["dbid"] = hashlib.md5(b"masterlist").digest()[:8]
self.text_to_speech("All songs", self["dbid"], True) self.text_to_speech("All songs", self["dbid"], True)
self["listtype"] = 1 self["listtype"] = 1
@ -503,28 +603,35 @@ class Playlist(Record):
if dataarr[0].lower().startswith("file"): if dataarr[0].lower().startswith("file"):
num = int(dataarr[0][4:]) num = int(dataarr[0][4:])
filename = urllib.parse.unquote(dataarr[1]).strip() filename = urllib.parse.unquote(dataarr[1]).strip()
if filename.lower().startswith('file://'): if filename.lower().startswith("file://"):
filename = filename[7:] filename = filename[7:]
if self.rename: if self.rename:
filename = validate_unicode(filename) filename = validate_unicode(filename)
sorttracks.append((num, filename)) sorttracks.append((num, filename))
listtracks = [ x for (_, x) in sorted(sorttracks) ] listtracks = [x for (_, x) in sorted(sorttracks)]
return listtracks return listtracks
def populate_directory(self, playlistpath, recursive = True): def populate_directory(self, playlistpath, recursive=True):
# Add all tracks inside the folder and its subfolders recursively. # Add all tracks inside the folder and its subfolders recursively.
# Folders containing no music and only a single Album # Folders containing no music and only a single Album
# would generate duplicated playlists. That is intended and "wont fix". # would generate duplicated playlists. That is intended and "wont fix".
# Empty folders (inside the music path) will generate an error -> "wont fix". # Empty folders (inside the music path) will generate an error -> "wont fix".
listtracks = [] listtracks = []
for (dirpath, dirnames, filenames) in os.walk(playlistpath): for dirpath, dirnames, filenames in os.walk(playlistpath):
dirnames.sort() dirnames.sort()
# Ignore any hidden directories # Ignore any hidden directories
if "/." not in dirpath: if "/." not in dirpath:
for filename in sorted(filenames, key = lambda x: x.lower()): for filename in sorted(filenames, key=lambda x: x.lower()):
# Only add valid music files to playlist # Only add valid music files to playlist
if os.path.splitext(filename)[1].lower() in (".mp3", ".m4a", ".m4b", ".m4p", ".aa", ".wav"): if os.path.splitext(filename)[1].lower() in (
".mp3",
".m4a",
".m4b",
".m4p",
".aa",
".wav",
):
fullPath = os.path.abspath(os.path.join(dirpath, filename)) fullPath = os.path.abspath(os.path.join(dirpath, filename))
listtracks.append(fullPath) listtracks.append(fullPath)
if not recursive: if not recursive:
@ -550,24 +657,26 @@ class Playlist(Record):
text = os.path.splitext(os.path.basename(filename))[0] text = os.path.splitext(os.path.basename(filename))[0]
else: else:
# Read the playlist file # Read the playlist file
with open(filename, 'r', errors="replace") as f: with open(filename, "r", errors="replace") as f:
data = f.readlines() data = f.readlines()
extension = os.path.splitext(filename)[1].lower() extension = os.path.splitext(filename)[1].lower()
if extension == '.pls': if extension == ".pls":
self.listtracks = self.populate_pls(data) self.listtracks = self.populate_pls(data)
elif extension == '.m3u': elif extension == ".m3u":
self.listtracks = self.populate_m3u(data) self.listtracks = self.populate_m3u(data)
else: else:
raise raise
# Ensure all paths are not relative to the playlist file # Ensure all paths are not relative to the playlist file
for i in range(len(self.listtracks)): for i in range(len(self.listtracks)):
self.listtracks[i] = self.remove_relatives(self.listtracks[i], filename) self.listtracks[i] = self.remove_relatives(
self.listtracks[i], filename
)
text = os.path.splitext(os.path.basename(filename))[0] text = os.path.splitext(os.path.basename(filename))[0]
# Handle the VoiceOverData # Handle the VoiceOverData
self["dbid"] = hashlib.md5(text.encode('utf-8')).digest()[:8] self["dbid"] = hashlib.md5(text.encode("utf-8")).digest()[:8]
self.text_to_speech(text, self["dbid"], True) self.text_to_speech(text, self["dbid"], True)
def construct(self, tracks): def construct(self, tracks):
@ -583,8 +692,10 @@ class Playlist(Record):
except: except:
# Print an error if no track was found. # Print an error if no track was found.
# Empty playlists are handeled in the PlaylistHeader class. # Empty playlists are handeled in the PlaylistHeader class.
print("Error: Could not find track \"" + path + "\".") print('Error: Could not find track "' + path + '".')
print("Maybe its an invalid FAT filesystem name. Please fix your playlist. Skipping track.") print(
"Maybe its an invalid FAT filesystem name. Please fix your playlist. Skipping track."
)
if position > -1: if position > -1:
chunks += struct.pack("I", position) chunks += struct.pack("I", position)
self["number_of_songs"] += 1 self["number_of_songs"] += 1
@ -593,8 +704,18 @@ class Playlist(Record):
output = Record.construct(self) output = Record.construct(self)
return output + chunks return output + chunks
class Shuffler(object): class Shuffler(object):
def __init__(self, path, track_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.path = os.path.abspath(path)
self.tracks = [] self.tracks = []
self.albums = [] self.albums = []
@ -609,11 +730,19 @@ class Shuffler(object):
self.auto_id3_playlists = auto_id3_playlists self.auto_id3_playlists = auto_id3_playlists
def initialize(self): def initialize(self):
# remove existing voiceover files (they are either useless or will be overwritten anyway) # remove existing voiceover files (they are either useless or will be overwritten anyway)
for dirname in ('iPod_Control/Speakable/Playlists', 'iPod_Control/Speakable/Tracks'): for dirname in (
shutil.rmtree(os.path.join(self.path, dirname), ignore_errors=True) "iPod_Control/Speakable/Playlists",
for dirname in ('iPod_Control/iTunes', 'iPod_Control/Music', 'iPod_Control/Speakable/Playlists', 'iPod_Control/Speakable/Tracks'): "iPod_Control/Speakable/Tracks",
make_dir_if_absent(os.path.join(self.path, dirname)) ):
shutil.rmtree(os.path.join(self.path, dirname), ignore_errors=True)
for dirname in (
"iPod_Control/iTunes",
"iPod_Control/Music",
"iPod_Control/Speakable/Playlists",
"iPod_Control/Speakable/Tracks",
):
make_dir_if_absent(os.path.join(self.path, dirname))
def dump_state(self): def dump_state(self):
print("Shuffle DB state") print("Shuffle DB state")
@ -624,31 +753,49 @@ class Shuffler(object):
def populate(self): def populate(self):
self.tunessd = TunesSD(self) self.tunessd = TunesSD(self)
for (dirpath, dirnames, filenames) in os.walk(self.path): for dirpath, dirnames, filenames in os.walk(self.path):
dirnames.sort() dirnames.sort()
relpath = get_relpath(dirpath, self.path) relpath = get_relpath(dirpath, self.path)
# Ignore the speakable directory and any hidden directories # Ignore the speakable directory and any hidden directories
if not is_path_prefix("iPod_Control/Speakable", relpath) and "/." not in dirpath: if (
for filename in sorted(filenames, key = lambda x: x.lower()): not is_path_prefix("iPod_Control/Speakable", relpath)
and "/." not in dirpath
):
for filename in sorted(filenames, key=lambda x: x.lower()):
# Ignore hidden files # Ignore hidden files
if not filename.startswith("."): if not filename.startswith("."):
fullPath = os.path.abspath(os.path.join(dirpath, filename)) fullPath = os.path.abspath(os.path.join(dirpath, filename))
if os.path.splitext(filename)[1].lower() in (".mp3", ".m4a", ".m4b", ".m4p", ".aa", ".wav"): if os.path.splitext(filename)[1].lower() in (
".mp3",
".m4a",
".m4b",
".m4p",
".aa",
".wav",
):
self.tracks.append(fullPath) self.tracks.append(fullPath)
if os.path.splitext(filename)[1].lower() in (".pls", ".m3u"): if os.path.splitext(filename)[1].lower() in (".pls", ".m3u"):
self.lists.append(fullPath) self.lists.append(fullPath)
# Create automatic playlists in music directory. # Create automatic playlists in music directory.
# Ignore the (music) root and any hidden directories. # Ignore the (music) root and any hidden directories.
if self.auto_dir_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. # 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 depth = (
dirpath[len(self.path) + len(os.path.sep) :].count(os.path.sep) - 1
)
if self.auto_dir_playlists < 0 or depth <= self.auto_dir_playlists: if self.auto_dir_playlists < 0 or depth <= self.auto_dir_playlists:
self.lists.append(os.path.abspath(dirpath)) self.lists.append(os.path.abspath(dirpath))
if self.auto_id3_playlists != None: if self.auto_id3_playlists != None:
if mutagen: if mutagen:
for grouped_list in group_tracks_by_id3_template(self.tracks, self.auto_id3_playlists): for grouped_list in group_tracks_by_id3_template(
self.tracks, self.auto_id3_playlists
):
self.lists.append(grouped_list) self.lists.append(grouped_list)
else: else:
print("Error: No mutagen found. Cannot generate auto-id3-playlists.") print("Error: No mutagen found. Cannot generate auto-id3-playlists.")
@ -656,7 +803,9 @@ class Shuffler(object):
def write_database(self): def write_database(self):
print("Writing database. This may take a while...") print("Writing database. This may take a while...")
with open(os.path.join(self.path, "iPod_Control", "iTunes", "iTunesSD"), "wb") as f: with open(
os.path.join(self.path, "iPod_Control", "iTunes", "iTunesSD"), "wb"
) as f:
try: try:
f.write(self.tunessd.construct()) f.write(self.tunessd.construct())
except IOError as e: except IOError as e:
@ -670,6 +819,7 @@ class Shuffler(object):
print("Artists", len(self.artists)) print("Artists", len(self.artists))
print("Playlists", len(self.lists)) print("Playlists", len(self.lists))
# #
# Read all files from the directory # Read all files from the directory
# Construct the appropriate iTunesDB file # Construct the appropriate iTunesDB file
@ -678,27 +828,32 @@ class Shuffler(object):
# Use SVOX pico2wave and RHVoice to produce voiceover data # Use SVOX pico2wave and RHVoice to produce voiceover data
# #
def check_unicode(path): def check_unicode(path):
ret_flag = False # True if there is a recognizable file within this level ret_flag = False # True if there is a recognizable file within this level
for item in os.listdir(path): for item in os.listdir(path):
if os.path.isfile(os.path.join(path, item)): if os.path.isfile(os.path.join(path, item)):
if os.path.splitext(item)[1].lower() in audio_ext+list_ext: if os.path.splitext(item)[1].lower() in audio_ext + list_ext:
ret_flag = True ret_flag = True
if raises_unicode_error(item): if raises_unicode_error(item):
src = os.path.join(path, item) src = os.path.join(path, item)
dest = os.path.join(path, hash_error_unicode(item)) + os.path.splitext(item)[1].lower() dest = (
print('Renaming %s -> %s' % (src, dest)) os.path.join(path, hash_error_unicode(item))
+ os.path.splitext(item)[1].lower()
)
print("Renaming %s -> %s" % (src, dest))
os.rename(src, dest) os.rename(src, dest)
else: else:
ret_flag = (check_unicode(os.path.join(path, item)) or ret_flag) ret_flag = check_unicode(os.path.join(path, item)) or ret_flag
if ret_flag and raises_unicode_error(item): if ret_flag and raises_unicode_error(item):
src = os.path.join(path, item) src = os.path.join(path, item)
new_name = hash_error_unicode(item) new_name = hash_error_unicode(item)
dest = os.path.join(path, new_name) dest = os.path.join(path, new_name)
print('Renaming %s -> %s' % (src, dest)) print("Renaming %s -> %s" % (src, dest))
os.rename(src, dest) os.rename(src, dest)
return ret_flag return ret_flag
def nonnegative_int(string): def nonnegative_int(string):
try: try:
intval = int(string) intval = int(string)
@ -709,57 +864,97 @@ def nonnegative_int(string):
raise argparse.ArgumentTypeError("Track gain value should be in range 0-99") raise argparse.ArgumentTypeError("Track gain value should be in range 0-99")
return intval return intval
def checkPathValidity(path): def checkPathValidity(path):
if not os.path.isdir(result.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) sys.exit(1)
if not os.access(result.path, os.W_OK): 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) sys.exit(1)
def handle_interrupt(signal, frame): def handle_interrupt(signal, frame):
print("Interrupt detected, exiting...") print("Interrupt detected, exiting...")
sys.exit(1) sys.exit(1)
if __name__ == '__main__':
if __name__ == "__main__":
signal.signal(signal.SIGINT, handle_interrupt) signal.signal(signal.SIGINT, handle_interrupt)
parser = argparse.ArgumentParser(description= parser = argparse.ArgumentParser(
'Python script for building the Track and Playlist database ' description="Python script for building the Track and Playlist database "
'for the newer gen IPod Shuffle. Version 1.5') "for the newer gen IPod Shuffle. Version 1.5"
)
parser.add_argument('-t', '--track-voiceover', action='store_true', parser.add_argument(
help='Enable track voiceover feature') "-t",
"--track-voiceover",
action="store_true",
help="Enable track voiceover feature",
)
parser.add_argument('-p', '--playlist-voiceover', action='store_true', parser.add_argument(
help='Enable playlist voiceover feature') "-p",
"--playlist-voiceover",
action="store_true",
help="Enable playlist voiceover feature",
)
parser.add_argument('-u', '--rename-unicode', action='store_true', parser.add_argument(
help='Rename files causing unicode errors, will do minimal required renaming') "-u",
"--rename-unicode",
action="store_true",
help="Rename files causing unicode errors, will do minimal required renaming",
)
parser.add_argument('-g', '--track-gain', type=nonnegative_int, default='0', parser.add_argument(
help='Specify volume gain (0-99) for all tracks; ' "-g",
'0 (default) means no gain and is usually fine; ' "--track-gain",
'e.g. 60 is very loud even on minimal player volume') 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('-d', '--auto-dir-playlists', type=int, default=None, const=-1, nargs='?', parser.add_argument(
help='Generate automatic playlists for each folder recursively inside ' "-d",
'"IPod_Control/Music/". You can optionally limit the depth: ' "--auto-dir-playlists",
'0=root, 1=artist, 2=album, n=subfoldername, default=-1 (No Limit).') 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('-i', '--auto-id3-playlists', type=str, default=None, metavar='ID3_TEMPLATE', const='{artist}', nargs='?', parser.add_argument(
help='Generate automatic playlists based on the id3 tags of any music ' "-i",
'added to the iPod. You can optionally specify a template string ' "--auto-id3-playlists",
'based on which id3 tags are used to generate playlists. For eg. ' type=str,
'\'{artist} - {album}\' will use the pair of artist and album to group ' default=None,
'tracks under one playlist. Similarly \'{genre}\' will group tracks based ' metavar="ID3_TEMPLATE",
'on their genre tag. Default template used is \'{artist}\'') 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('-v', '--verbose', action='store_true', parser.add_argument(
help='Show verbose output of database generation.') "-v",
"--verbose",
action="store_true",
help="Show verbose output of database generation.",
)
parser.add_argument('path', help='Path to the IPod\'s root directory') parser.add_argument("path", help="Path to the IPod's root directory")
result = parser.parse_args() result = parser.parse_args()
@ -772,11 +967,13 @@ if __name__ == '__main__':
check_unicode(result.path) check_unicode(result.path)
if not mutagen: if not mutagen:
print("Warning: No mutagen found. Database will not contain any album nor artist information.") print(
"Warning: No mutagen found. Database will not contain any album nor artist information."
)
verboseprint("Playlist voiceover requested:", result.playlist_voiceover) verboseprint("Playlist voiceover requested:", result.playlist_voiceover)
verboseprint("Track voiceover requested:", result.track_voiceover) verboseprint("Track voiceover requested:", result.track_voiceover)
if (result.track_voiceover or result.playlist_voiceover): if result.track_voiceover or result.playlist_voiceover:
if not Text2Speech.check_support(): 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.track_voiceover = False
@ -784,13 +981,15 @@ if __name__ == '__main__':
else: else:
verboseprint("Voiceover available.") verboseprint("Voiceover available.")
shuffle = Shuffler(result.path, shuffle = Shuffler(
track_voiceover=result.track_voiceover, result.path,
playlist_voiceover=result.playlist_voiceover, track_voiceover=result.track_voiceover,
rename=result.rename_unicode, playlist_voiceover=result.playlist_voiceover,
trackgain=result.track_gain, rename=result.rename_unicode,
auto_dir_playlists=result.auto_dir_playlists, trackgain=result.track_gain,
auto_id3_playlists=result.auto_id3_playlists) auto_dir_playlists=result.auto_dir_playlists,
auto_id3_playlists=result.auto_id3_playlists,
)
shuffle.initialize() shuffle.initialize()
shuffle.populate() shuffle.populate()
shuffle.write_database() shuffle.write_database()