Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Podcast support #54

Open
wants to merge 21 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,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
Expand Down
81 changes: 70 additions & 11 deletions ipod-shuffle-4g.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,36 @@
import re
import tempfile
import signal
import enum
import functools

# External libraries
try:
import mutagen
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))
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Doesnt it make sense to include that inside the FileType Class?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe I'm missing how to do this, but my attempts haven't succeeded and the examples I have seen for defining this as a static attribute involve extra lines. Also, given that it's right above two other lists of extensions, I don't think it's objectionable to have it outside the class.

# 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)
Expand Down Expand Up @@ -294,7 +315,7 @@ def construct(self):
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)
Expand All @@ -303,6 +324,7 @@ def construct(self):
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
Expand All @@ -315,21 +337,28 @@ def construct(self):
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()
for i in self.tracks:
track = Track(self)
verboseprint("[*] Adding track", i)
track.populate(i)
if track.is_podcast:
self.total_podcasts += 1
NicoHood marked this conversation as resolved.
Show resolved Hide resolved
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)),
Expand Down Expand Up @@ -358,11 +387,23 @@ def __init__(self, parent):
("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]

Expand All @@ -374,6 +415,9 @@ def populate(self, filename):
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)

Expand Down Expand Up @@ -408,7 +452,7 @@ def __init__(self, parent):
("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)),
Expand All @@ -423,18 +467,26 @@ def construct(self, tracks):

# 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:
NicoHood marked this conversation as resolved.
Show resolved Hide resolved
# "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

Expand All @@ -450,6 +502,7 @@ def construct(self, tracks):
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
Expand All @@ -467,7 +520,7 @@ def set_master(self, tracks):
if self.playlist_voiceover and (Text2Speech.valid_tts['pico2wave'] or Text2Speech.valid_tts['espeak']):
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
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why did you change from struct to class property?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I created the class property self.listtype so I could set it to the enum value and then compare to the enum instead of converting to the .value. I'm not sure why I didn't do the same for the PlaylistHeader. I'd be inclined to make that one match with a new class property, rather than making this match by dropping it.

self.listtracks = tracks

def populate_m3u(self, data):
Expand Down Expand Up @@ -508,7 +561,7 @@ def populate_directory(self, playlistpath, recursive = True):
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:
Expand All @@ -529,6 +582,8 @@ def populate(self, obj):
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]
Expand Down Expand Up @@ -557,11 +612,15 @@ def populate(self, obj):
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:
Expand Down Expand Up @@ -596,7 +655,7 @@ def initialize(self):
# 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):
Expand Down Expand Up @@ -624,7 +683,7 @@ def populate(self):

# 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:
Expand Down Expand Up @@ -666,7 +725,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:
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think all_ext is really bad to understand. Since it is only used once (i think) we do not need it and can use the better understandable name.

Why does it even check the extension here? The name of the function only checks unicode, so is it even placed correct here (in the first place, was not your initial code).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I figured it made sense to give that union a name and define it with the other lists.

I assume it's doing the extension check to avoid renaming any non-audio files. I'll note that while the code renames non-unicode files to avoid characters that can't be handled by the iPod Shuffle file system, there are some ascii characters that can't be handled as well. Specifically, any file with these characters can't be played: "*/:<>?|\. That might be useful to check for as well, but I figure those make sense in a different PR.

ret_flag = True
if raises_unicode_error(item):
src = os.path.join(path, item)
Expand Down