-
Notifications
You must be signed in to change notification settings - Fork 40
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
base: master
Are you sure you want to change the base?
Podcast support #54
Changes from all commits
fd6150c
bab8af0
5714079
7dc7d4c
368a9b2
dd0ccd9
78e1f59
020433a
e0a2f6c
3ca83b6
0b277a9
dde2e3b
d4a9a14
767f371
a61317d
3d7189c
aa740c2
299813b
622f55f
c68063e
ce2b20e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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)) | ||
# 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) | ||
|
@@ -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) | ||
|
@@ -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 | ||
|
@@ -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)), | ||
|
@@ -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] | ||
|
||
|
@@ -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) | ||
|
||
|
@@ -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)), | ||
|
@@ -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 | ||
|
||
|
@@ -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 | ||
|
@@ -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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why did you change from struct to class property? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I created the class property |
||
self.listtracks = tracks | ||
|
||
def populate_m3u(self, data): | ||
|
@@ -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: | ||
|
@@ -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] | ||
|
@@ -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: | ||
|
@@ -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): | ||
|
@@ -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: | ||
|
@@ -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: | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think 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). There was a problem hiding this comment. Choose a reason for hiding this commentThe 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: |
||
ret_flag = True | ||
if raises_unicode_error(item): | ||
src = os.path.join(path, item) | ||
|
There was a problem hiding this comment.
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?There was a problem hiding this comment.
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.