441 lines
16 KiB
Python
441 lines
16 KiB
Python
#
|
|
# -*- coding: utf-8 -*-
|
|
#
|
|
# gPodder - A media aggregator and podcast client
|
|
# Copyright (c) 2005-2022 The gPodder Team
|
|
#
|
|
# gPodder is free software; you can redistribute it and/or modify
|
|
# it under the terms of the GNU General Public License as published by
|
|
# the Free Software Foundation; either version 3 of the License, or
|
|
# (at your option) any later version.
|
|
#
|
|
# gPodder is distributed in the hope that it will be useful,
|
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
# GNU General Public License for more details.
|
|
#
|
|
# You should have received a copy of the GNU General Public License
|
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
#
|
|
|
|
#
|
|
# libgpod_ctypes: Minimalistic ctypes-based bindings for libgpod
|
|
# (Just enough coverage to get podcast syncing working again...)
|
|
# Thomas Perl <m@thp.io>, May 2022
|
|
#
|
|
|
|
|
|
import ctypes
|
|
import logging
|
|
import os
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
# libgpod, for iTunesDB access
|
|
libgpod = ctypes.CDLL('libgpod.so.4')
|
|
|
|
# glib, for g_strdup() and g_free()
|
|
libglib = ctypes.CDLL('libglib-2.0.so.0')
|
|
|
|
|
|
# glib/gtypes.h: typedef gint gboolean;
|
|
gboolean = ctypes.c_int
|
|
|
|
# glib/gstrfuncs.h: gchar *g_strdup(const gchar *str);
|
|
libglib.g_strdup.argtypes = (ctypes.c_char_p,)
|
|
# Note: This MUST be c_void_p, so that the glib-allocated buffer will
|
|
# be preserved when assigning to track member variables. The reason
|
|
# for this is that Python ctypes tries to be helpful and converts a
|
|
# c_char_p restype to a Python bytes object, which will be different
|
|
# from the memory returned by g_strdup(). For track properties, the
|
|
# values will be free'd indirectly by itdb_free() later.
|
|
libglib.g_strdup.restype = ctypes.c_void_p
|
|
|
|
# glib/gmem.h: void g_free(gpointer mem);
|
|
libglib.g_free.argtypes = (ctypes.c_void_p,)
|
|
libglib.g_free.restype = None
|
|
|
|
# ctypes.c_time_t will be available in Python 3.12 onwards
|
|
# See also: https://github.com/python/cpython/pull/92870
|
|
if hasattr(ctypes, 'c_time_t'):
|
|
time_t = ctypes.c_time_t
|
|
else:
|
|
# See also: https://github.com/python/cpython/issues/92869
|
|
if ctypes.sizeof(ctypes.c_void_p) == ctypes.sizeof(ctypes.c_int64):
|
|
time_t = ctypes.c_int64
|
|
else:
|
|
# On 32-bit systems, time_t is historically 32-bit, but due to Y2K38
|
|
# there have been efforts to establish 64-bit time_t on 32-bit Linux:
|
|
# https://linux.slashdot.org/story/20/02/15/0247201/linux-is-ready-for-the-end-of-time
|
|
# https://www.gnu.org/software/libc/manual/html_node/64_002dbit-time-symbol-handling.html
|
|
logger.info('libgpod may cause issues if time_t is 64-bit on your 32-bit system.')
|
|
time_t = ctypes.c_int32
|
|
|
|
|
|
# glib/glist.h: struct _GList
|
|
class GList(ctypes.Structure):
|
|
...
|
|
|
|
|
|
GList._fields_ = [
|
|
('data', ctypes.c_void_p),
|
|
('next', ctypes.POINTER(GList)),
|
|
('prev', ctypes.POINTER(GList)),
|
|
]
|
|
|
|
|
|
# gpod/itdb.h
|
|
class Itdb_iTunesDB(ctypes.Structure):
|
|
_fields_ = [
|
|
('tracks', ctypes.POINTER(GList)),
|
|
# ...
|
|
]
|
|
|
|
|
|
# gpod/itdb.h: struct _Itdb_Playlist
|
|
class Itdb_Playlist(ctypes.Structure):
|
|
_fields_ = [
|
|
('itdb', ctypes.POINTER(Itdb_iTunesDB)),
|
|
('name', ctypes.c_char_p),
|
|
('type', ctypes.c_uint8),
|
|
('flag1', ctypes.c_uint8),
|
|
('flag2', ctypes.c_uint8),
|
|
('flag3', ctypes.c_uint8),
|
|
('num', ctypes.c_int),
|
|
('members', ctypes.POINTER(GList)),
|
|
# ...
|
|
]
|
|
|
|
|
|
# gpod/itdb.h
|
|
class Itdb_Chapterdata(ctypes.Structure):
|
|
...
|
|
|
|
|
|
# gpod/itdb.h
|
|
class Itdb_Track(ctypes.Structure):
|
|
_fields_ = [
|
|
('itdb', ctypes.POINTER(Itdb_iTunesDB)),
|
|
('title', ctypes.c_char_p),
|
|
('ipod_path', ctypes.c_char_p),
|
|
('album', ctypes.c_char_p),
|
|
('artist', ctypes.c_char_p),
|
|
('genre', ctypes.c_char_p),
|
|
('filetype', ctypes.c_char_p),
|
|
('comment', ctypes.c_char_p),
|
|
('category', ctypes.c_char_p),
|
|
('composer', ctypes.c_char_p),
|
|
('grouping', ctypes.c_char_p),
|
|
('description', ctypes.c_char_p),
|
|
('podcasturl', ctypes.c_char_p),
|
|
('podcastrss', ctypes.c_char_p),
|
|
('chapterdata', ctypes.POINTER(Itdb_Chapterdata)),
|
|
('subtitle', ctypes.c_char_p),
|
|
('tvshow', ctypes.c_char_p),
|
|
('tvepisode', ctypes.c_char_p),
|
|
('tvnetwork', ctypes.c_char_p),
|
|
('albumartist', ctypes.c_char_p),
|
|
('keywords', ctypes.c_char_p),
|
|
('sort_artist', ctypes.c_char_p),
|
|
('sort_title', ctypes.c_char_p),
|
|
('sort_album', ctypes.c_char_p),
|
|
('sort_albumartist', ctypes.c_char_p),
|
|
('sort_composer', ctypes.c_char_p),
|
|
('sort_tvshow', ctypes.c_char_p),
|
|
('id', ctypes.c_uint32),
|
|
('size', ctypes.c_uint32),
|
|
('tracklen', ctypes.c_int32),
|
|
('cd_nr', ctypes.c_int32),
|
|
('cds', ctypes.c_int32),
|
|
('track_nr', ctypes.c_int32),
|
|
('bitrate', ctypes.c_int32),
|
|
('samplerate', ctypes.c_uint16),
|
|
('samplerate_low', ctypes.c_uint16),
|
|
('year', ctypes.c_int32),
|
|
('volume', ctypes.c_int32),
|
|
('soundcheck', ctypes.c_uint32),
|
|
('soundcheck', ctypes.c_uint32),
|
|
('time_added', time_t),
|
|
('time_modified', time_t),
|
|
('time_played', time_t),
|
|
('bookmark_time', ctypes.c_uint32),
|
|
('rating', ctypes.c_uint32),
|
|
('playcount', ctypes.c_uint32),
|
|
('playcount2', ctypes.c_uint32),
|
|
('recent_playcount', ctypes.c_uint32),
|
|
('transferred', gboolean),
|
|
('BPM', ctypes.c_int16),
|
|
('app_rating', ctypes.c_uint8),
|
|
('type1', ctypes.c_uint8),
|
|
('type2', ctypes.c_uint8),
|
|
('compilation', ctypes.c_uint8),
|
|
('starttime', ctypes.c_uint32),
|
|
('stoptime', ctypes.c_uint32),
|
|
('checked', ctypes.c_uint8),
|
|
('dbid', ctypes.c_uint64),
|
|
('drm_userid', ctypes.c_uint32),
|
|
('visible', ctypes.c_uint32),
|
|
('filetype_marker', ctypes.c_uint32),
|
|
('artwork_count', ctypes.c_uint16),
|
|
('artwork_size', ctypes.c_uint32),
|
|
('samplerate2', ctypes.c_float),
|
|
('unk126', ctypes.c_uint16),
|
|
('unk132', ctypes.c_uint32),
|
|
('time_released', time_t),
|
|
('unk144', ctypes.c_uint16),
|
|
('explicit_flag', ctypes.c_uint16),
|
|
('unk148', ctypes.c_uint32),
|
|
('unk152', ctypes.c_uint32),
|
|
('skipcount', ctypes.c_uint32),
|
|
('recent_skipcount', ctypes.c_uint32),
|
|
('last_skipped', ctypes.c_uint32),
|
|
('has_artwork', ctypes.c_uint8),
|
|
('skip_when_shuffling', ctypes.c_uint8),
|
|
('remember_playback_position', ctypes.c_uint8),
|
|
('flag4', ctypes.c_uint8),
|
|
('dbid2', ctypes.c_uint64),
|
|
('lyrics_flag', ctypes.c_uint8),
|
|
('movie_flag', ctypes.c_uint8),
|
|
('mark_unplayed', ctypes.c_uint8),
|
|
('unk179', ctypes.c_uint8),
|
|
('unk180', ctypes.c_uint32),
|
|
('pregap', ctypes.c_uint32),
|
|
('samplecount', ctypes.c_uint64),
|
|
('unk196', ctypes.c_uint32),
|
|
('postgap', ctypes.c_uint32),
|
|
('unk204', ctypes.c_uint32),
|
|
('mediatype', ctypes.c_uint32),
|
|
# ...
|
|
]
|
|
|
|
|
|
# gpod/itdb.h: Itdb_iTunesDB *itdb_parse (const gchar *mp, GError **error);
|
|
libgpod.itdb_parse.argtypes = (ctypes.c_char_p, ctypes.c_void_p)
|
|
libgpod.itdb_parse.restype = ctypes.POINTER(Itdb_iTunesDB)
|
|
|
|
# gpod/itdb.h: Itdb_Playlist *itdb_playlist_podcasts (Itdb_iTunesDB *itdb);
|
|
libgpod.itdb_playlist_podcasts.argtypes = (ctypes.POINTER(Itdb_iTunesDB),)
|
|
libgpod.itdb_playlist_podcasts.restype = ctypes.POINTER(Itdb_Playlist)
|
|
|
|
# gpod/itdb.h: Itdb_Playlist *itdb_playlist_mpl (Itdb_iTunesDB *itdb);
|
|
libgpod.itdb_playlist_mpl.argtypes = (ctypes.POINTER(Itdb_iTunesDB),)
|
|
libgpod.itdb_playlist_mpl.restype = ctypes.POINTER(Itdb_Playlist)
|
|
|
|
# gpod/itdb.h: gboolean itdb_write (Itdb_iTunesDB *itdb, GError **error);
|
|
libgpod.itdb_write.argtypes = (ctypes.POINTER(Itdb_iTunesDB), ctypes.c_void_p)
|
|
libgpod.itdb_write.restype = gboolean
|
|
|
|
# gpod/itdb.h: guint32 itdb_playlist_tracks_number (Itdb_Playlist *pl);
|
|
libgpod.itdb_playlist_tracks_number.argtypes = (ctypes.POINTER(Itdb_Playlist),)
|
|
libgpod.itdb_playlist_tracks_number.restype = ctypes.c_uint32
|
|
|
|
# gpod/itdb.h: gchar *itdb_filename_on_ipod (Itdb_Track *track);
|
|
libgpod.itdb_filename_on_ipod.argtypes = (ctypes.POINTER(Itdb_Track),)
|
|
# Needs to be c_void_p, because the returned pointer-to-memory must be free'd with g_free() after use.
|
|
libgpod.itdb_filename_on_ipod.restype = ctypes.c_void_p
|
|
|
|
# gpod/itdb.h: Itdb_Track *itdb_track_new (void);
|
|
libgpod.itdb_track_new.argtypes = ()
|
|
libgpod.itdb_track_new.restype = ctypes.POINTER(Itdb_Track)
|
|
|
|
# gpod/itdb.h: void itdb_track_add (Itdb_iTunesDB *itdb, Itdb_Track *track, gint32 pos);
|
|
libgpod.itdb_track_add.argtypes = (ctypes.POINTER(Itdb_iTunesDB), ctypes.POINTER(Itdb_Track), ctypes.c_int32)
|
|
libgpod.itdb_track_add.restype = None
|
|
|
|
# gpod/itdb.h: void itdb_playlist_add_track (Itdb_Playlist *pl, Itdb_Track *track, gint32 pos);
|
|
libgpod.itdb_playlist_add_track.argtypes = (ctypes.POINTER(Itdb_Playlist), ctypes.POINTER(Itdb_Track), ctypes.c_int32)
|
|
libgpod.itdb_playlist_add_track.restype = None
|
|
|
|
# gpod/itdb.h: gboolean itdb_cp_track_to_ipod (Itdb_Track *track, const gchar *filename, GError **error);
|
|
libgpod.itdb_cp_track_to_ipod.argtypes = (ctypes.POINTER(Itdb_Track), ctypes.c_char_p, ctypes.c_void_p)
|
|
libgpod.itdb_cp_track_to_ipod.restype = gboolean
|
|
|
|
# gpod/itdb.h: time_t itdb_time_host_to_mac (time_t time);
|
|
libgpod.itdb_time_host_to_mac.argtypes = (time_t,)
|
|
libgpod.itdb_time_host_to_mac.restype = time_t
|
|
|
|
# gpod/itdb.h: void itdb_playlist_remove_track (Itdb_Playlist *pl, Itdb_Track *track);
|
|
libgpod.itdb_playlist_remove_track.argtypes = (ctypes.POINTER(Itdb_Playlist), ctypes.POINTER(Itdb_Track))
|
|
libgpod.itdb_playlist_remove_track.restype = None
|
|
|
|
# gpod/itdb.h: void itdb_track_remove (Itdb_Track *track);
|
|
libgpod.itdb_track_remove.argtypes = (ctypes.POINTER(Itdb_Track),)
|
|
libgpod.itdb_track_remove.restype = None
|
|
|
|
# gpod/itdb.h: void itdb_free (Itdb_iTunesDB *itdb);
|
|
libgpod.itdb_free.argtypes = (ctypes.POINTER(Itdb_iTunesDB),)
|
|
libgpod.itdb_free.restype = None
|
|
|
|
|
|
# gpod/itdb.h
|
|
ITDB_MEDIATYPE_AUDIO = (1 << 0)
|
|
ITDB_MEDIATYPE_MOVIE = (1 << 1)
|
|
ITDB_MEDIATYPE_PODCAST = (1 << 2)
|
|
ITDB_MEDIATYPE_VIDEO_PODCAST = (ITDB_MEDIATYPE_MOVIE | ITDB_MEDIATYPE_PODCAST)
|
|
|
|
|
|
def glist_foreach(ptr_to_glist, item_type):
|
|
cur = ptr_to_glist
|
|
while cur:
|
|
yield ctypes.cast(cur[0].data, item_type)
|
|
if not cur[0].next:
|
|
break
|
|
cur = cur[0].next
|
|
|
|
|
|
class iPodTrack(object):
|
|
def __init__(self, db, track):
|
|
self.db = db
|
|
self.track = track
|
|
|
|
self.episode_title = track[0].title.decode()
|
|
self.podcast_title = track[0].album.decode()
|
|
|
|
self.podcast_url = track[0].podcasturl.decode()
|
|
self.podcast_rss = track[0].podcastrss.decode()
|
|
|
|
self.playcount = track[0].playcount
|
|
self.bookmark_time = track[0].bookmark_time
|
|
|
|
# This returns a newly-allocated string, so we have to juggle the memory
|
|
# around a bit and take a copy of the string before free'ing it again.
|
|
filename_ptr = libgpod.itdb_filename_on_ipod(track)
|
|
if filename_ptr:
|
|
self.filename_on_ipod = ctypes.string_at(filename_ptr).decode()
|
|
libglib.g_free(filename_ptr)
|
|
else:
|
|
self.filename_on_ipod = None
|
|
|
|
def __repr__(self):
|
|
return 'iPodTrack(episode={}, podcast={})'.format(self.episode_title, self.podcast_title)
|
|
|
|
def initialize_bookmark(self, is_new, bookmark_time):
|
|
self.track[0].mark_unplayed = 0x02 if is_new else 0x01
|
|
self.track[0].bookmark_time = int(bookmark_time)
|
|
|
|
def remove_from_device(self):
|
|
libgpod.itdb_playlist_remove_track(self.db.podcasts_playlist, self.track)
|
|
libgpod.itdb_playlist_remove_track(self.db.master_playlist, self.track)
|
|
|
|
# This frees the memory pointed-to by the track object
|
|
libgpod.itdb_track_remove(self.track)
|
|
|
|
self.track = None
|
|
|
|
# Don't forget to write the database on close
|
|
self.db.modified = True
|
|
|
|
if self.filename_on_ipod is not None:
|
|
try:
|
|
os.unlink(self.filename_on_ipod)
|
|
except Exception as e:
|
|
logger.info('Could not delete podcast file from iPod', exc_info=True)
|
|
|
|
|
|
class iPodDatabase(object):
|
|
def __init__(self, mountpoint):
|
|
self.mountpoint = mountpoint
|
|
self.itdb = libgpod.itdb_parse(mountpoint.encode(), None)
|
|
|
|
if not self.itdb:
|
|
raise ValueError('iTunesDB not found at {}'.format(self.mountpoint))
|
|
|
|
logger.info('iTunesDB: %s', self.itdb)
|
|
|
|
self.modified = False
|
|
|
|
self.podcasts_playlist = libgpod.itdb_playlist_podcasts(self.itdb)
|
|
self.master_playlist = libgpod.itdb_playlist_mpl(self.itdb)
|
|
|
|
self.tracks = [iPodTrack(self, track)
|
|
for track in glist_foreach(self.podcasts_playlist[0].members, ctypes.POINTER(Itdb_Track))]
|
|
|
|
def get_podcast_tracks(self):
|
|
return self.tracks
|
|
|
|
def add_track(self, filename, episode_title, podcast_title, description, podcast_url, podcast_rss,
|
|
published_timestamp, track_length, is_audio):
|
|
track = libgpod.itdb_track_new()
|
|
|
|
track[0].title = libglib.g_strdup(episode_title.encode())
|
|
track[0].album = libglib.g_strdup(podcast_title.encode())
|
|
track[0].artist = libglib.g_strdup(podcast_title.encode())
|
|
track[0].description = libglib.g_strdup(description.encode())
|
|
track[0].podcasturl = libglib.g_strdup(podcast_url.encode())
|
|
track[0].podcastrss = libglib.g_strdup(podcast_rss.encode())
|
|
track[0].tracklen = track_length
|
|
track[0].size = os.path.getsize(filename)
|
|
track[0].time_released = libgpod.itdb_time_host_to_mac(published_timestamp)
|
|
|
|
if is_audio:
|
|
track[0].filetype = libglib.g_strdup(b'mp3')
|
|
track[0].mediatype = ITDB_MEDIATYPE_PODCAST
|
|
else:
|
|
track[0].filetype = libglib.g_strdup(b'm4v')
|
|
track[0].mediatype = ITDB_MEDIATYPE_VIDEO_PODCAST
|
|
|
|
# Start at the beginning, and add "unplayed" bullet
|
|
track[0].bookmark_time = 0
|
|
track[0].mark_unplayed = 0x02
|
|
|
|
# from set_podcast_flags()
|
|
track[0].remember_playback_position = 0x01
|
|
track[0].skip_when_shuffling = 0x01
|
|
track[0].flag1 = 0x02
|
|
track[0].flag2 = 0x01
|
|
track[0].flag3 = 0x01
|
|
track[0].flag4 = 0x01
|
|
|
|
libgpod.itdb_track_add(self.itdb, track, -1)
|
|
|
|
libgpod.itdb_playlist_add_track(self.podcasts_playlist, track, -1)
|
|
libgpod.itdb_playlist_add_track(self.master_playlist, track, -1)
|
|
|
|
copied = libgpod.itdb_cp_track_to_ipod(track, filename.encode(), None)
|
|
logger.info('Copy result: %r', copied)
|
|
self.modified = True
|
|
|
|
self.tracks.append(iPodTrack(self, track))
|
|
return self.tracks[-1]
|
|
|
|
def __del__(self):
|
|
# If we hit the finalizer without closing the iTunesDB properly,
|
|
# just free the memory, but don't write out any modifications.
|
|
self.close(write=False)
|
|
|
|
def close(self, write=True):
|
|
if self.itdb:
|
|
if self.modified and write:
|
|
result = libgpod.itdb_write(self.itdb, None)
|
|
logger.info('Close result: %r', result)
|
|
self.modified = False
|
|
|
|
libgpod.itdb_free(self.itdb)
|
|
self.itdb = None
|
|
|
|
|
|
if __name__ == '__main__':
|
|
import argparse
|
|
import textwrap
|
|
|
|
parser = argparse.ArgumentParser(description='Dump podcasts in iTunesDB via libgpod')
|
|
parser.add_argument('mountpoint', type=str, help='Path to mounted iPod storage')
|
|
|
|
args = parser.parse_args()
|
|
|
|
ipod = iPodDatabase(args.mountpoint)
|
|
|
|
for track in ipod.get_podcast_tracks():
|
|
print(textwrap.dedent(f"""
|
|
Episode: {track.episode_title}
|
|
Podcast: {track.podcast_title}
|
|
Episode URL: {track.podcast_url}
|
|
Podcast URL: {track.podcast_rss}
|
|
Play count: {track.playcount}
|
|
Bookmark: {track.bookmark_time/1000:.0f} seconds
|
|
Filename: {track.filename_on_ipod}
|
|
""").rstrip())
|
|
|
|
ipod.close()
|