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
Changes from 4 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
46 changes: 40 additions & 6 deletions ipod-shuffle-4g.py
Original file line number Diff line number Diff line change
Expand Up @@ -294,7 +294,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 +303,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 @@ -322,14 +323,20 @@ def construct(self):
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 @@ -364,6 +371,10 @@ def populate(self, filename):
if os.path.splitext(filename)[1].lower() in (".m4a", ".m4b", ".m4p", ".aa"):
self["filetype"] = 2

if "/iPod_Control/Podcasts/" in filename:
self.is_podcast = True
self["dontskip"] = 0

text = os.path.splitext(os.path.basename(filename))[0]

# Try to get album and artist information with mutagen
Expand All @@ -374,6 +385,10 @@ 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.is_podcast = True
self["dontskip"] = 0

# 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 +423,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", 65535)),
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 this?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

At the time, because I assumed I needed to specify that it was an unsigned short instead of two bytes, but I suppose they're equivalent. I do think it's useful to indicate the actual type now that it'll change values.

Copy link
Collaborator

Choose a reason for hiding this comment

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

To reduce the noise I would revert this change

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 just tested and actually can't revert this. Packing the structure to build the database throws an exception because the struct has a short but is expecting bytes.

Traceback (most recent call last):
  File ".../IPod-Shuffle-4g/./ipod-shuffle-4g.py", line 810, in <module>
    shuffle.write_database()
  File ".../IPod-Shuffle-4g/./ipod-shuffle-4g.py", line 675, in write_database
    f.write(self.tunessd.construct())
  File ".../IPod-Shuffle-4g/./ipod-shuffle-4g.py", line 293, in construct
    play_header = self.play_header.construct(self.track_header.tracks)
  File ".../IPod-Shuffle-4g/./ipod-shuffle-4g.py", line 463, in construct
    output = Record.construct(self)
  File ".../IPod-Shuffle-4g/./ipod-shuffle-4g.py", line 215, in construct
    output += struct.pack("<" + fmt, self._fields.get(i, default))
struct.error: argument for 's' must be a bytes object

Copy link
Collaborator

Choose a reason for hiding this comment

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

Indeed H seems correct here. Can you give it the value 0xFFFF?

I am also wondering if a non-podcasts count makes sense. I guess a podcast count makes more sense, but that is what the docs from reverse engineering say. But maybe those are wrong!?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Technically, ("H", b"\xFF\xFF") would be accepted, but I think that just looks confusing, given that it's representing a number.

I also don't think I'm going to question the docs. If it was actually a "podcast count", 0 would make more sense for the case when there aren't any podcast playlists.

Copy link
Collaborator

Choose a reason for hiding this comment

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

0xFFFF is more clear than the number. Maybe it is also a signed short, which would be -1.

("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 +438,23 @@ 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 playlist["listtype"] == 3:
NicoHood marked this conversation as resolved.
Show resolved Hide resolved
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
self["number_of_non_podcast_lists"] = playlistcount - podcastlistcount
self["total_length"] = 0x14 + (self["number_of_playlists"] * 4)
# Start the header

Expand Down Expand Up @@ -470,6 +490,9 @@ def set_master(self, tracks):
self["listtype"] = 1
self.listtracks = tracks

def set_podcast(self):
self["listtype"] = 3
NicoHood marked this conversation as resolved.
Show resolved Hide resolved

def populate_m3u(self, data):
listtracks = []
for i in data:
Expand Down Expand Up @@ -500,6 +523,8 @@ def populate_directory(self, playlistpath, recursive = True):
# Folders containing no music and only a single Album
# would generate duplicated playlists. That is intended and "wont fix".
# Empty folders (inside the music path) will generate an error -> "wont fix".
if "/iPod_Control/Podcasts/" in playlistpath:
self.set_podcast()
NicoHood marked this conversation as resolved.
Show resolved Hide resolved
listtracks = []
for (dirpath, dirnames, filenames) in os.walk(playlistpath):
dirnames.sort()
Expand All @@ -510,6 +535,8 @@ def populate_directory(self, playlistpath, recursive = True):
# Only add valid music files to playlist
if os.path.splitext(filename)[1].lower() in (".mp3", ".m4a", ".m4b", ".m4p", ".aa", ".wav"):
fullPath = os.path.abspath(os.path.join(dirpath, filename))
if os.path.islink(fullPath):
fullPath = os.path.realpath(fullPath)
NicoHood marked this conversation as resolved.
Show resolved Hide resolved
listtracks.append(fullPath)
if not recursive:
break
Expand Down Expand Up @@ -562,6 +589,9 @@ def construct(self, tracks):
for i in self.listtracks:
path = self.ipod_to_path(i)
position = -1
if self["listtype"] == 1 and "/iPod_Control/Podcasts/" in path:
print ('not including podcast in master playlist: {}'.format(path))
Copy link
Collaborator

Choose a reason for hiding this comment

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

Do we really need to print that to the user? Also shouldnt the listtype be 3 instead of 1?

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 can remove that printout. The intention was to match the print level of other messages.
Also, this section is excluding podcasts from the All Songs playlist. That's a bit more clear with the enum, but I added a comment.

Copy link
Collaborator

Choose a reason for hiding this comment

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

I have not run the program for ages. If you say the print fits, we can keep it. But please use a capital Not at the beginning :-D

I currently try to understand the code again. Isnt there a track object inside that playlist of which we can check if it is a podcast. But at another point we also accept id3 tags with Genre set to Podcast, but this code here does not support this.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

It's not a track object, just a path to the track. In order to detect by id3 at this point, the code would have to reference the track header and look up each track object, or reload the metadata with mutagen. I think that would be better handled by a bigger re-architecture that loads all data, building the database objects and then writes everything at once, rather than building up some data as the database is built.

continue
try:
position = tracks.index(path)
except:
Expand Down Expand Up @@ -596,7 +626,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 All @@ -617,14 +647,18 @@ def populate(self):
# Ignore hidden files
if not filename.startswith("."):
fullPath = os.path.abspath(os.path.join(dirpath, filename))
if os.path.islink(fullPath):
fullPath = os.path.realpath(fullPath)
if os.path.splitext(filename)[1].lower() in (".mp3", ".m4a", ".m4b", ".m4p", ".aa", ".wav"):
self.tracks.append(fullPath)
if fullPath not in self.tracks:
self.tracks.append(fullPath)
if os.path.splitext(filename)[1].lower() in (".pls", ".m3u"):
self.lists.append(fullPath)
if fullPath not in self.lists:
self.lists.append(fullPath)

# 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