mirror of
https://github.com/nims11/IPod-Shuffle-4g.git
synced 2025-12-08 00:18:01 +09:00
Merge ce2b20ebf5 into a97a99ab86
This commit is contained in:
commit
523c0f4d3c
2 changed files with 73 additions and 11 deletions
|
|
@ -94,6 +94,9 @@ The file can be found in the [extras](extras) folder.
|
||||||
#### Compress/Convert your music files
|
#### 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/).
|
([#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
|
#### 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/)
|
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
|
you can use Rythmbox to sync your personal music library to your IPod
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,8 @@ import shutil
|
||||||
import re
|
import re
|
||||||
import tempfile
|
import tempfile
|
||||||
import signal
|
import signal
|
||||||
|
import enum
|
||||||
|
import functools
|
||||||
|
|
||||||
# External libraries
|
# External libraries
|
||||||
try:
|
try:
|
||||||
|
|
@ -21,8 +23,27 @@ try:
|
||||||
except ImportError:
|
except ImportError:
|
||||||
mutagen = None
|
mutagen = None
|
||||||
|
|
||||||
audio_ext = (".mp3", ".m4a", ".m4b", ".m4p", ".aa", ".wav")
|
class PlaylistType(enum.Enum):
|
||||||
list_ext = (".pls", ".m3u")
|
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):
|
def make_dir_if_absent(path):
|
||||||
try:
|
try:
|
||||||
os.makedirs(path)
|
os.makedirs(path)
|
||||||
|
|
@ -310,7 +331,7 @@ class TunesSD(Record):
|
||||||
self["playlist_header_offset"] = self.play_header.base_offset
|
self["playlist_header_offset"] = self.play_header.base_offset
|
||||||
|
|
||||||
self["total_number_of_tracks"] = self.track_header["number_of_tracks"]
|
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"]
|
self["total_number_of_playlists"] = self.play_header["number_of_playlists"]
|
||||||
|
|
||||||
output = Record.construct(self)
|
output = Record.construct(self)
|
||||||
|
|
@ -319,6 +340,7 @@ class TunesSD(Record):
|
||||||
class TrackHeader(Record):
|
class TrackHeader(Record):
|
||||||
def __init__(self, parent):
|
def __init__(self, parent):
|
||||||
self.base_offset = 0
|
self.base_offset = 0
|
||||||
|
self.total_podcasts = 0
|
||||||
Record.__init__(self, parent)
|
Record.__init__(self, parent)
|
||||||
self._struct = collections.OrderedDict([
|
self._struct = collections.OrderedDict([
|
||||||
("header_id", ("4s", b"hths")), # shth
|
("header_id", ("4s", b"hths")), # shth
|
||||||
|
|
@ -331,6 +353,7 @@ class TrackHeader(Record):
|
||||||
self["number_of_tracks"] = len(self.tracks)
|
self["number_of_tracks"] = len(self.tracks)
|
||||||
self["total_length"] = 20 + (len(self.tracks) * 4)
|
self["total_length"] = 20 + (len(self.tracks) * 4)
|
||||||
output = Record.construct(self)
|
output = Record.construct(self)
|
||||||
|
self.total_podcasts = 0
|
||||||
|
|
||||||
# Construct the underlying tracks
|
# Construct the underlying tracks
|
||||||
track_chunk = bytes()
|
track_chunk = bytes()
|
||||||
|
|
@ -338,14 +361,20 @@ class TrackHeader(Record):
|
||||||
track = Track(self)
|
track = Track(self)
|
||||||
verboseprint("[*] Adding track", i)
|
verboseprint("[*] Adding track", i)
|
||||||
track.populate(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))
|
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
|
||||||
|
|
||||||
|
def total_tracks_without_podcasts(self):
|
||||||
|
return self["number_of_tracks"] - self.total_podcasts
|
||||||
|
|
||||||
class Track(Record):
|
class Track(Record):
|
||||||
|
|
||||||
def __init__(self, parent):
|
def __init__(self, parent):
|
||||||
Record.__init__(self, parent)
|
Record.__init__(self, parent)
|
||||||
|
self.is_podcast = False
|
||||||
self._struct = collections.OrderedDict([
|
self._struct = collections.OrderedDict([
|
||||||
("header_id", ("4s", b"rths")), # shtr
|
("header_id", ("4s", b"rths")), # shtr
|
||||||
("header_length", ("I", 0x174)),
|
("header_length", ("I", 0x174)),
|
||||||
|
|
@ -374,11 +403,23 @@ class Track(Record):
|
||||||
("unknown5", ("32s", b"\x00" * 32)),
|
("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):
|
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"):
|
# assign the "filetype" based on the extension
|
||||||
self["filetype"] = 2
|
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]
|
text = os.path.splitext(os.path.basename(filename))[0]
|
||||||
|
|
||||||
|
|
@ -390,6 +431,9 @@ class Track(Record):
|
||||||
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:
|
||||||
|
if "Podcast" in audio.get("genre", ["Unknown"]):
|
||||||
|
self.set_podcast()
|
||||||
|
|
||||||
# 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)
|
||||||
|
|
||||||
|
|
@ -424,7 +468,7 @@ class PlaylistHeader(Record):
|
||||||
("header_id", ("4s", b"hphs")), #shph
|
("header_id", ("4s", b"hphs")), #shph
|
||||||
("total_length", ("I", 0)),
|
("total_length", ("I", 0)),
|
||||||
("number_of_playlists", ("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_master_lists", ("2s", b"\x01\x00")),
|
||||||
("number_of_non_audiobook_lists", ("2s", b"\xFF\xFF")),
|
("number_of_non_audiobook_lists", ("2s", b"\xFF\xFF")),
|
||||||
("unknown2", ("2s", b"\x00" * 2)),
|
("unknown2", ("2s", b"\x00" * 2)),
|
||||||
|
|
@ -439,18 +483,26 @@ class PlaylistHeader(Record):
|
||||||
|
|
||||||
# Build all the remaining playlists
|
# Build all the remaining playlists
|
||||||
playlistcount = 1
|
playlistcount = 1
|
||||||
|
podcastlistcount = 0
|
||||||
for i in self.lists:
|
for i in self.lists:
|
||||||
playlist = Playlist(self)
|
playlist = Playlist(self)
|
||||||
verboseprint("[+] Adding playlist", (i[0] if type(i) == type(()) else i))
|
verboseprint("[+] Adding playlist", (i[0] if type(i) == type(()) else i))
|
||||||
playlist.populate(i)
|
playlist.populate(i)
|
||||||
construction = playlist.construct(tracks)
|
construction = playlist.construct(tracks)
|
||||||
if playlist["number_of_songs"] > 0:
|
if playlist["number_of_songs"] > 0:
|
||||||
|
if PlaylistType(playlist["listtype"]) == PlaylistType.PODCAST:
|
||||||
|
podcastlistcount += 1
|
||||||
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
|
||||||
|
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)
|
self["total_length"] = 0x14 + (self["number_of_playlists"] * 4)
|
||||||
# Start the header
|
# Start the header
|
||||||
|
|
||||||
|
|
@ -466,6 +518,7 @@ class PlaylistHeader(Record):
|
||||||
class Playlist(Record):
|
class Playlist(Record):
|
||||||
def __init__(self, parent):
|
def __init__(self, parent):
|
||||||
self.listtracks = []
|
self.listtracks = []
|
||||||
|
self.listtype = PlaylistType.NORMAL
|
||||||
Record.__init__(self, parent)
|
Record.__init__(self, parent)
|
||||||
self._struct = collections.OrderedDict([
|
self._struct = collections.OrderedDict([
|
||||||
("header_id", ("4s", b"lphs")), # shpl
|
("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']):
|
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 = PlaylistType.ALL_SONGS
|
||||||
self.listtracks = tracks
|
self.listtracks = tracks
|
||||||
|
|
||||||
def populate_m3u(self, data):
|
def populate_m3u(self, data):
|
||||||
|
|
@ -524,7 +577,7 @@ class Playlist(Record):
|
||||||
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 audio_ext:
|
||||||
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:
|
||||||
|
|
@ -545,6 +598,8 @@ class Playlist(Record):
|
||||||
text = obj[0]
|
text = obj[0]
|
||||||
else:
|
else:
|
||||||
filename = obj
|
filename = obj
|
||||||
|
if "/iPod_Control/Podcasts/" in filename:
|
||||||
|
self.listtype = PlaylistType.PODCAST
|
||||||
if os.path.isdir(filename):
|
if os.path.isdir(filename):
|
||||||
self.listtracks = self.populate_directory(filename)
|
self.listtracks = self.populate_directory(filename)
|
||||||
text = os.path.splitext(os.path.basename(filename))[0]
|
text = os.path.splitext(os.path.basename(filename))[0]
|
||||||
|
|
@ -573,11 +628,15 @@ class Playlist(Record):
|
||||||
def construct(self, tracks):
|
def construct(self, tracks):
|
||||||
self["total_length"] = 44 + (4 * len(self.listtracks))
|
self["total_length"] = 44 + (4 * len(self.listtracks))
|
||||||
self["number_of_songs"] = 0
|
self["number_of_songs"] = 0
|
||||||
|
self["listtype"] = self.listtype.value
|
||||||
|
|
||||||
chunks = bytes()
|
chunks = bytes()
|
||||||
for i in self.listtracks:
|
for i in self.listtracks:
|
||||||
path = self.ipod_to_path(i)
|
path = self.ipod_to_path(i)
|
||||||
position = -1
|
position = -1
|
||||||
|
if PlaylistType.ALL_SONGS == self.listtype and "/iPod_Control/Podcasts/" in path:
|
||||||
|
# exclude podcasts from the "All Songs" playlist
|
||||||
|
continue
|
||||||
try:
|
try:
|
||||||
position = tracks.index(path)
|
position = tracks.index(path)
|
||||||
except:
|
except:
|
||||||
|
|
@ -612,7 +671,7 @@ class Shuffler(object):
|
||||||
# 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 ('iPod_Control/Speakable/Playlists', 'iPod_Control/Speakable/Tracks'):
|
||||||
shutil.rmtree(os.path.join(self.path, dirname), ignore_errors=True)
|
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))
|
make_dir_if_absent(os.path.join(self.path, dirname))
|
||||||
|
|
||||||
def dump_state(self):
|
def dump_state(self):
|
||||||
|
|
@ -640,7 +699,7 @@ class Shuffler(object):
|
||||||
|
|
||||||
# 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 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.
|
# 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:
|
||||||
|
|
@ -682,7 +741,7 @@ 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 all_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)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue