mirror of
https://github.com/nims11/IPod-Shuffle-4g.git
synced 2025-12-07 07:58: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
|
||||
([#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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue