This commit is contained in:
Arno Hautala 2022-03-23 11:15:32 -04:00 committed by GitHub
commit 523c0f4d3c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
2 changed files with 73 additions and 11 deletions

View file

@ -94,6 +94,9 @@ 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/).
#### Podcast support
Place podcast tracks in `iPod_Control/Podcasts`, or add "Podcast" to the ID3 Genre, to generate playlists. These tracks will be skipped when shuffling, will be marked to remember their last playback position, and won't be included in the "All Songs" playlist.
#### 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

View file

@ -14,6 +14,8 @@ import shutil
import re
import tempfile
import signal
import enum
import functools
# External libraries
try:
@ -21,8 +23,27 @@ try:
except ImportError:
mutagen = None
audio_ext = (".mp3", ".m4a", ".m4b", ".m4p", ".aa", ".wav")
list_ext = (".pls", ".m3u")
class PlaylistType(enum.Enum):
ALL_SONGS = 1
NORMAL = 2
PODCAST = 3
AUDIOBOOK = 4
class FileType(enum.Enum):
MP3 = (1, {'.mp3'})
AAC = (2, {'.m4a', '.m4b', '.m4p', '.aa'})
WAV = (4, {'.wav'})
def __init__(self, filetype, extensions):
self.filetype = filetype
self.extensions = extensions
# collect all the supported audio extensions
audio_ext = functools.reduce(lambda j,k: j.union(k), map(lambda i: i.extensions, FileType))
# the supported playlist extensions
list_ext = {".pls", ".m3u"}
# all the supported file extensions
all_ext = audio_ext.union(list_ext)
def make_dir_if_absent(path):
try:
os.makedirs(path)
@ -310,7 +331,7 @@ class TunesSD(Record):
self["playlist_header_offset"] = self.play_header.base_offset
self["total_number_of_tracks"] = self.track_header["number_of_tracks"]
self["total_tracks_without_podcasts"] = self.track_header["number_of_tracks"]
self["total_tracks_without_podcasts"] = self.track_header.total_tracks_without_podcasts()
self["total_number_of_playlists"] = self.play_header["number_of_playlists"]
output = Record.construct(self)
@ -319,6 +340,7 @@ class TunesSD(Record):
class TrackHeader(Record):
def __init__(self, parent):
self.base_offset = 0
self.total_podcasts = 0
Record.__init__(self, parent)
self._struct = collections.OrderedDict([
("header_id", ("4s", b"hths")), # shth
@ -331,6 +353,7 @@ class TrackHeader(Record):
self["number_of_tracks"] = len(self.tracks)
self["total_length"] = 20 + (len(self.tracks) * 4)
output = Record.construct(self)
self.total_podcasts = 0
# Construct the underlying tracks
track_chunk = bytes()
@ -338,14 +361,20 @@ class TrackHeader(Record):
track = Track(self)
verboseprint("[*] Adding track", i)
track.populate(i)
if track.is_podcast:
self.total_podcasts += 1
output += struct.pack("I", self.base_offset + self["total_length"] + len(track_chunk))
track_chunk += track.construct()
return output + track_chunk
def total_tracks_without_podcasts(self):
return self["number_of_tracks"] - self.total_podcasts
class Track(Record):
def __init__(self, parent):
Record.__init__(self, parent)
self.is_podcast = False
self._struct = collections.OrderedDict([
("header_id", ("4s", b"rths")), # shtr
("header_length", ("I", 0x174)),
@ -374,11 +403,23 @@ class Track(Record):
("unknown5", ("32s", b"\x00" * 32)),
])
def set_podcast(self):
self.is_podcast = True
self["dontskip"] = 0 # podcasts should not be "not skipped" when shuffling (re: should not be shuffled)
self["remember"] = 1 # podcasts should remember their last playback position
def populate(self, 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
# assign the "filetype" based on the extension
ext = os.path.splitext(filename)[1].lower()
for type in FileType:
if ext in type.extensions:
self["filetype"] = type.filetype
break
if "/iPod_Control/Podcasts/" in filename:
self.set_podcast()
text = os.path.splitext(os.path.basename(filename))[0]
@ -390,6 +431,9 @@ class Track(Record):
except:
print("Error calling mutagen. Possible invalid filename/ID3Tags (hyphen in filename?)")
if audio:
if "Podcast" in audio.get("genre", ["Unknown"]):
self.set_podcast()
# Note: Rythmbox IPod plugin sets this value always 0.
self["stop_at_pos_ms"] = int(audio.info.length * 1000)
@ -424,7 +468,7 @@ class PlaylistHeader(Record):
("header_id", ("4s", b"hphs")), #shph
("total_length", ("I", 0)),
("number_of_playlists", ("I", 0)),
("number_of_non_podcast_lists", ("2s", b"\xFF\xFF")),
("number_of_non_podcast_lists", ("H", 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)),
@ -439,18 +483,26 @@ class PlaylistHeader(Record):
# Build all the remaining playlists
playlistcount = 1
podcastlistcount = 0
for i in self.lists:
playlist = Playlist(self)
verboseprint("[+] Adding playlist", (i[0] if type(i) == type(()) else i))
playlist.populate(i)
construction = playlist.construct(tracks)
if playlist["number_of_songs"] > 0:
if PlaylistType(playlist["listtype"]) == PlaylistType.PODCAST:
podcastlistcount += 1
playlistcount += 1
chunks += [construction]
else:
print("Error: Playlist does not contain a single track. Skipping playlist.")
self["number_of_playlists"] = playlistcount
if podcastlistcount > 0:
# "number_of_non_podcast_lists" should default to 0xFFFF if there
# aren't any podcast playlists, so only calculate the count if
# the podcastlistcount is greater than 0
self["number_of_non_podcast_lists"] = playlistcount - podcastlistcount
self["total_length"] = 0x14 + (self["number_of_playlists"] * 4)
# Start the header
@ -466,6 +518,7 @@ class PlaylistHeader(Record):
class Playlist(Record):
def __init__(self, parent):
self.listtracks = []
self.listtype = PlaylistType.NORMAL
Record.__init__(self, parent)
self._struct = collections.OrderedDict([
("header_id", ("4s", b"lphs")), # shpl
@ -483,7 +536,7 @@ class Playlist(Record):
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
self.listtype = PlaylistType.ALL_SONGS
self.listtracks = tracks
def populate_m3u(self, data):
@ -524,7 +577,7 @@ class Playlist(Record):
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"):
if os.path.splitext(filename)[1].lower() in audio_ext:
fullPath = os.path.abspath(os.path.join(dirpath, filename))
listtracks.append(fullPath)
if not recursive:
@ -545,6 +598,8 @@ class Playlist(Record):
text = obj[0]
else:
filename = obj
if "/iPod_Control/Podcasts/" in filename:
self.listtype = PlaylistType.PODCAST
if os.path.isdir(filename):
self.listtracks = self.populate_directory(filename)
text = os.path.splitext(os.path.basename(filename))[0]
@ -573,11 +628,15 @@ class Playlist(Record):
def construct(self, tracks):
self["total_length"] = 44 + (4 * len(self.listtracks))
self["number_of_songs"] = 0
self["listtype"] = self.listtype.value
chunks = bytes()
for i in self.listtracks:
path = self.ipod_to_path(i)
position = -1
if PlaylistType.ALL_SONGS == self.listtype and "/iPod_Control/Podcasts/" in path:
# exclude podcasts from the "All Songs" playlist
continue
try:
position = tracks.index(path)
except:
@ -612,7 +671,7 @@ class Shuffler(object):
# remove existing voiceover files (they are either useless or will be overwritten anyway)
for dirname in ('iPod_Control/Speakable/Playlists', 'iPod_Control/Speakable/Tracks'):
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'):
for dirname in ('iPod_Control/iTunes', 'iPod_Control/Music', 'iPod_Control/Podcasts', 'iPod_Control/Speakable/Playlists', 'iPod_Control/Speakable/Tracks'):
make_dir_if_absent(os.path.join(self.path, dirname))
def dump_state(self):
@ -640,7 +699,7 @@ class Shuffler(object):
# Create automatic playlists in music directory.
# 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 or "iPod_Control/Podcasts/" 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_dir_playlists < 0 or depth <= self.auto_dir_playlists:
@ -682,7 +741,7 @@ def check_unicode(path):
ret_flag = False # True if there is a recognizable file within this level
for item in os.listdir(path):
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 all_ext:
ret_flag = True
if raises_unicode_error(item):
src = os.path.join(path, item)