Merge branch 'master' into dev-adaptive

This commit is contained in:
Teemu Ikonen 2022-07-02 23:31:38 +03:00
commit 2cc1a7614a
55 changed files with 9339 additions and 9134 deletions

View File

@ -52,7 +52,7 @@ PyPI. With this, you get a self-contained gPodder CLI codebase.
- Size detection on Windows: PyWin32
- Native OS X support: ige-mac-integration
- MP3 Player Sync Support: python-eyed3 (0.7 or newer)
- iPod Sync Support: python-gpod
- iPod Sync Support: libgpod (tested with 0.8.3)
- Clickable links in GTK UI show notes: html5lib
- HTML show notes: WebKit2 gobject bindings
(webkit2gtk, webkitgtk4 or gir1.2-webkit2-4.0 packages).

517
po/ca.po

File diff suppressed because it is too large Load Diff

518
po/cs.po

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

517
po/da.po

File diff suppressed because it is too large Load Diff

517
po/de.po

File diff suppressed because it is too large Load Diff

517
po/el.po

File diff suppressed because it is too large Load Diff

517
po/es.po

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

517
po/eu.po

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

517
po/fi.po

File diff suppressed because it is too large Load Diff

517
po/fr.po

File diff suppressed because it is too large Load Diff

517
po/gl.po

File diff suppressed because it is too large Load Diff

517
po/he.po

File diff suppressed because it is too large Load Diff

517
po/hu.po

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

517
po/it.po

File diff suppressed because it is too large Load Diff

517
po/kk.po

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

517
po/nb.po

File diff suppressed because it is too large Load Diff

517
po/nl.po

File diff suppressed because it is too large Load Diff

517
po/nn.po

File diff suppressed because it is too large Load Diff

517
po/pl.po

File diff suppressed because it is too large Load Diff

517
po/pt.po

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

517
po/ro.po

File diff suppressed because it is too large Load Diff

517
po/ru.po

File diff suppressed because it is too large Load Diff

517
po/sk.po

File diff suppressed because it is too large Load Diff

517
po/sv.po

File diff suppressed because it is too large Load Diff

517
po/tr.po

File diff suppressed because it is too large Load Diff

517
po/uk.po

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -91,13 +91,14 @@ class gPodderExtension:
cmd = [CONVERT_COMMANDS.get(extension, 'normalize-audio'), filename]
# Set cwd to prevent normalize from placing files in the directory gpodder was started from.
if gpodder.ui.win32:
p = util.Popen(cmd)
p = util.Popen(cmd, cwd=episode.channel.save_dir)
p.wait()
stdout, stderr = ("<unavailable>",) * 2
else:
p = util.Popen(cmd, stdout=subprocess.PIPE,
stderr=subprocess.PIPE)
p = util.Popen(cmd, cwd=episode.channel.save_dir,
stdout=subprocess.PIPE, stderr=subprocess.PIPE)
stdout, stderr = p.communicate()
if p.returncode == 0:

View File

@ -261,7 +261,7 @@ class gPodderExtension:
else:
info['title'] = title
info['subtitle'] = episode.description
info['subtitle'] = episode._text_description
if self.container.config.genre_tag is not None:
info['genre'] = self.container.config.genre_tag

View File

@ -31,6 +31,10 @@ from comtypes import COMMETHOD, GUID, IUnknown, client, wireHWND
import gpodder
import gi # isort:skip
from gi.repository import Gtk # isort:skip
_ = gpodder.gettext
@ -54,6 +58,8 @@ TBPFLAG = c_int # enum
TBATF_USEMDITHUMBNAIL = 1
TBATF_USEMDILIVEPREVIEW = 2
TBATFLAG = c_int # enum
# return code
S_OK = HRESULT(0).value
class tagTHUMBBUTTON(Structure):
@ -139,8 +145,14 @@ class ITaskbarList3(ITaskbarList2):
(['in'], POINTER(tagRECT), 'prcClip'))]
assert sizeof(tagTHUMBBUTTON) == 540, sizeof(tagTHUMBBUTTON)
assert alignment(tagTHUMBBUTTON) == 4, alignment(tagTHUMBBUTTON)
assert sizeof(tagTHUMBBUTTON) in [540, 552], sizeof(tagTHUMBBUTTON)
assert alignment(tagTHUMBBUTTON) in [4, 8], alignment(tagTHUMBBUTTON)
def consume_events():
""" consume pending events """
while Gtk.events_pending():
Gtk.main_iteration()
# based on http://stackoverflow.com/a/1744503/905256
@ -154,11 +166,20 @@ class gPodderExtension:
self.taskbar = client.CreateObject(
'{56FDF344-FD6D-11d0-958A-006097C9A090}',
interface=ITaskbarList3)
self.taskbar.HrInit()
ret = self.taskbar.HrInit()
if ret != S_OK:
logger.warning("taskbar.HrInit failed: %r", ret)
del self.taskbar
def on_unload(self):
# let the window change state? otherwise gpodder is stuck on exit
# (tested on windows 7 pro)
consume_events()
if self.taskbar is not None:
self.taskbar.SetProgressState(self.window_handle, TBPF_NOPROGRESS)
# let the taskbar change state otherwise gpodder is stuck on exit
# (tested on windows 7 pro)
consume_events()
def on_ui_object_available(self, name, ui_object):
def callback(self, window, *args):
@ -167,12 +188,18 @@ class gPodderExtension:
win_gpointer = ctypes.pythonapi.PyCapsule_GetPointer(window.get_window().__gpointer__, None)
gdkdll = ctypes.CDLL("libgdk-3-0.dll")
self.window_handle = gdkdll.gdk_win32_window_get_handle(win_gpointer)
ret = self.taskbar.ActivateTab(self.window_handle)
if ret != S_OK:
logger.warning("taskbar.ActivateTab failed: %r", ret)
del self.taskbar
if name == 'gpodder-gtk':
ui_object.main_window.connect('realize',
functools.partial(callback, self))
def on_download_progress(self, progress):
if not self.taskbar:
return
if self.window_handle is None:
if not self.restart_warning:
return

View File

@ -211,8 +211,6 @@ class YoutubeFeed(model.Feed):
episodes = []
for en in self._ie_result['entries']:
guid = video_guid(en['id'])
description = util.remove_html_tags(en.get('description') or _('No description available'))
html_description = util.nice_html_description(en.get('thumbnail'), description)
if en.get('ext'):
mime_type = util.mimetype_from_extension('.{}'.format(en['ext']))
else:
@ -225,8 +223,9 @@ class YoutubeFeed(model.Feed):
ep = {
'title': en.get('title', guid),
'link': en.get('webpage_url'),
'description': description,
'description_html': html_description,
'episode_art_url': en.get('thumbnail'),
'description': util.remove_html_tags(en.get('description') or ''),
'description_html': '',
'url': en.get('webpage_url'),
'file_size': filesize,
'mime_type': mime_type,

View File

@ -113,7 +113,7 @@ class DBusPodcastsProxy(dbus.service.Object):
def episode_to_tuple(episode):
title = safe_str(episode.title)
url = safe_str(episode.url)
description = safe_first_line(episode.description)
description = safe_first_line(episode._text_description)
filename = safe_str(episode.download_filename)
file_type = safe_str(episode.file_type())
is_new = (episode.state == gpodder.STATE_NORMAL and episode.is_new)

View File

@ -231,7 +231,7 @@ class DownloadURLOpener:
# The following is based on Python's urllib.py "URLopener.retrieve"
# Also based on http://mail.python.org/pipermail/python-list/2001-October/110069.html
def retrieve_resume(self, url, filename, reporthook=None, data=None):
def retrieve_resume(self, url, filename, reporthook=None, data=None, disable_auth=False):
"""Download files from an URL; return (headers, real_url)
Resumes a download if the local filename exists and
@ -244,7 +244,7 @@ class DownloadURLOpener:
'User-agent': gpodder.user_agent
}
if self.channel.auth_username or self.channel.auth_password:
if (self.channel.auth_username or self.channel.auth_password) and not disable_auth:
logger.debug('Authenticating as "%s"', self.channel.auth_username)
auth = (self.channel.auth_username, self.channel.auth_password)
else:
@ -277,7 +277,11 @@ class DownloadURLOpener:
try:
resp.raise_for_status()
except HTTPError as e:
raise gPodderDownloadHTTPError(url, resp.status_code, str(e))
if auth is not None:
# Try again without authentication (bug 1296)
return self.retrieve_resume(url, filename, reporthook, data, True)
else:
raise gPodderDownloadHTTPError(url, resp.status_code, str(e))
headers = resp.headers
@ -467,6 +471,9 @@ class DownloadQueueManager(object):
self.tasks.queue_task(task)
self.__spawn_threads()
def has_workers(self):
return len(self.worker_threads) > 0
class DownloadTask(object):
"""An object representing the download task of an episode

View File

@ -238,6 +238,7 @@ class gPodderApplication(Gtk.Application):
pb = GdkPixbuf.Pixbuf.new_from_file_at_size(gpodder.icon_file, 160, 160)
bg.pack_start(Gtk.Image.new_from_pixbuf(pb), False, False, 0)
label = Gtk.Label(justify=Gtk.Justification.CENTER)
label.set_selectable(True)
label.set_markup('\n'.join(x.strip() for x in """
<b>gPodder {version} ({date})</b>

View File

@ -688,7 +688,8 @@ class gPodderPreferences(BuilderWidget):
self.toggle_playlist_interface(False)
self.checkbutton_delete_using_playlists.set_sensitive(False)
self.combobox_on_sync.set_sensitive(False)
self.checkbutton_skip_played_episodes.set_sensitive(False)
self.checkbutton_skip_played_episodes.set_sensitive(True)
self.checkbutton_delete_deleted_episodes.set_sensitive(True)
children = self.btn_filesystemMountpoint.get_children()
if children:

View File

@ -121,27 +121,16 @@ class BuilderWidget(GtkBuilderWidget):
else:
gpodder.user_extensions.on_notification_show(title, message)
def show_confirmation_extended(self, message, title=None, checkbox=None, default_checked=False):
def show_confirmation(self, message, title=None):
dlg = Gtk.MessageDialog(self.main_window, Gtk.DialogFlags.MODAL, Gtk.MessageType.QUESTION, Gtk.ButtonsType.YES_NO)
if title:
dlg.set_title(str(title))
dlg.set_markup('<span weight="bold" size="larger">%s</span>\n\n%s' % (title, message))
else:
dlg.set_markup('<span weight="bold" size="larger">%s</span>' % (message))
if checkbox:
cb = Gtk.CheckButton.new_with_label(checkbox)
cb.set_active(default_checked)
dlg.get_message_area().pack_end(cb, False, False, 0)
dlg.get_widget_for_response(Gtk.ResponseType.NO).grab_focus()
dlg.show_all()
response = dlg.run()
checked = checkbox and cb.get_active()
dlg.destroy()
return dict(confirmed=response == Gtk.ResponseType.YES, checked=checked)
def show_confirmation(self, message, title=None):
return self.show_confirmation_extended(
message, title=title)["confirmed"]
return response == Gtk.ResponseType.YES
def show_text_edit_dialog(self, title, prompt, text=None, empty=False,
is_url=False, affirmative_text=_('_OK')):

View File

@ -3119,7 +3119,7 @@ class gPodder(BuilderWidget, dbus.service.Object):
# Notify all tasks to to carry out any clean-up actions
self.download_status_model.tell_all_tasks_to_quit()
while Gtk.events_pending():
while Gtk.events_pending() or self.download_queue_manager.has_workers():
Gtk.main_iteration()
self.core.shutdown()
@ -3129,12 +3129,12 @@ class gPodder(BuilderWidget, dbus.service.Object):
def format_delete_message(self, message, things, max_things, max_length):
titles = []
for index, thing in zip(range(max_things), things):
titles.append('' + (html.escape(thing.title if len(thing.title) <= max_length else thing.title[:max_length] + '...')))
titles.append('' + (html.escape(thing.title if len(thing.title) <= max_length else thing.title[:max_length] + '')))
if len(things) > max_things:
titles.append('+%(count)d more ...' % {'count': len(things) - max_things})
titles.append('+%(count)d more' % {'count': len(things) - max_things})
return '\n'.join(titles) + '\n\n' + message
def delete_episode_list(self, episodes, confirm=True, callback=None, undownload=False):
def delete_episode_list(self, episodes, confirm=True, callback=None):
# if self.wNotebook.get_current_page() > 0:
# selection = self.treeDownloads.get_selection()
# (model, paths) = selection.get_selected_rows()
@ -3165,20 +3165,8 @@ class gPodder(BuilderWidget, dbus.service.Object):
message = self.format_delete_message(message, episodes, 5, 60)
if confirm:
undownloadable = len([e for e in episodes if e.can_undownload()])
if undownloadable:
checkbox = N_("Mark downloaded episodes as new, after deletion, to allow downloading again",
"Mark downloaded episodes as new, after deletion, to allow downloading again",
undownloadable)
else:
checkbox = None
res = self.show_confirmation_extended(
message, title,
checkbox=checkbox, default_checked=undownload)
if not res["confirmed"]:
return False
undownload = res["checked"]
if confirm and not self.show_confirmation(message, title):
return False
self.on_episodes_cancel_download_activate(force=True)
@ -3206,17 +3194,10 @@ class gPodder(BuilderWidget, dbus.service.Object):
progress.on_progress(idx / len(episodes))
if not episode.archive:
progress.on_message(episode.title)
# ep_undownload must be computed before delete_from_disk
ep_undownload = undownload and episode.can_undownload()
episode.delete_from_disk()
episode_urls.add(episode.url)
channel_urls.add(episode.channel.url)
episodes_status_update.append(episode)
if ep_undownload:
# Undelete and mark episode as new
episode.state = gpodder.STATE_NORMAL
episode.is_new = True
episode.save()
# Notify the web service about the status update + upload
if self.mygpo_client.can_access_webservice():

View File

@ -229,7 +229,7 @@ class gPodderShownotesText(gPodderShownotes):
self.text_buffer.insert_at_cursor('\n')
self.text_buffer.insert_with_tags_by_name(self.text_buffer.get_end_iter(), details, 'details')
self.text_buffer.insert_at_cursor('\n\n')
for target, text in util.extract_hyperlinked_text(episode.description_html or episode.description):
for target, text in util.extract_hyperlinked_text(episode.html_description()):
hyperlinks.append((self.text_buffer.get_char_count(), target))
if target:
self.text_buffer.insert_with_tags_by_name(
@ -363,13 +363,10 @@ class gPodderShownotesHTML(gPodderShownotes):
'duration': episode.get_play_info_string()})
header_html = _('<div id="gpodder-title">\n%(heading)s\n<p>%(subheading)s</p>\n<p>%(details)s</p></div>\n') \
% dict(heading=heading, subheading=subheading, details=details)
description_html = episode.description_html
if not description_html:
description_html = re.sub(r'\n', '<br>\n', episode.description)
# uncomment to prevent background override in html shownotes
# self.manager.remove_all_style_sheets ()
logger.debug("base uri: %s (chan:%s)", self._base_uri, episode.channel.url)
self.html_view.load_html(header_html + description_html, self._base_uri)
self.html_view.load_html(header_html + episode.html_description(), self._base_uri)
# uncomment to show web inspector
# self.html_view.get_inspector().show()
self.episode = episode

View File

@ -0,0 +1,436 @@
#
# -*- 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
import struct
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
# 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()

View File

@ -28,6 +28,7 @@ import collections
import datetime
import glob
import hashlib
import json
import logging
import os
import re
@ -175,12 +176,14 @@ class PodcastParserFeed(Feed):
existing_episode.total_time = youtube.get_total_time(episode)
existing_episode.update_from(episode)
existing_episode.cache_text_description()
existing_episode.save()
continue
elif episode.total_time == 0 and 'youtube' in episode.url:
# query duration for new youtube episodes
episode.total_time = youtube.get_total_time(episode)
episode.cache_text_description()
episode.save()
new_episodes.append(episode)
return new_episodes, seen_guids
@ -271,7 +274,7 @@ class PodcastEpisode(PodcastModelObject):
MAX_FILENAME_LENGTH = 120 # without extension
MAX_FILENAME_WITH_EXT_LENGTH = 140 - len(".partial.webm") # with extension
__slots__ = schema.EpisodeColumns + ('_download_error',)
__slots__ = schema.EpisodeColumns + ('_download_error', '_text_description',)
def _deprecated(self):
raise Exception('Property is deprecated!')
@ -289,17 +292,20 @@ class PodcastEpisode(PodcastModelObject):
episode.guid = entry['guid']
episode.title = entry['title']
episode.link = entry['link']
episode.description = entry['description']
episode.episode_art_url = entry.get('episode_art_url')
if entry.get('description_html'):
episode.description = ''
episode.description_html = entry['description_html']
else:
thumbnail = entry.get('episode_art_url')
description = util.remove_html_tags(episode.description or _('No description available'))
episode.description_html = util.nice_html_description(thumbnail, description)
episode.description = util.remove_html_tags(entry['description'] or '')
episode.description_html = ''
episode.total_time = entry['total_time']
episode.published = entry['published']
episode.payment_url = entry['payment_url']
episode.chapters = None
if entry.get("chapters"):
episode.chapters = json.dumps(entry["chapters"])
audio_available = any(enclosure['mime_type'].startswith('audio/') for enclosure in entry['enclosures'])
video_available = any(enclosure['mime_type'].startswith('video/') for enclosure in entry['enclosures'])
@ -368,8 +374,10 @@ class PodcastEpisode(PodcastModelObject):
self.file_size = 0
self.mime_type = 'application/octet-stream'
self.guid = ''
self.episode_art_url = None
self.description = ''
self.description_html = ''
self.chapters = None
self.link = ''
self.published = 0
self.download_filename = None
@ -388,6 +396,7 @@ class PodcastEpisode(PodcastModelObject):
self.last_playback = 0
self._download_error = None
self._text_description = ''
@property
def channel(self):
@ -504,12 +513,6 @@ class PodcastEpisode(PodcastModelObject):
"""
return self.download_task and self.download_task.can_cancel()
def can_undownload(self):
"""
gPodder.on_btnUndownload_clicked() filters selection with this method.
"""
return self.was_downloaded(and_exists=True) and not self.archive
def can_delete(self):
"""
gPodder.delete_episode_list() filters out locked episodes, and cancels all unlocked tasks in selection.
@ -572,9 +575,21 @@ class PodcastEpisode(PodcastModelObject):
age_prop = property(fget=get_age_string)
def cache_text_description(self):
if self.description:
self._text_description = self.description
elif self.description_html:
self._text_description = util.remove_html_tags(self.description_html)
else:
self._text_description = ''
def html_description(self):
return self.description_html \
or util.nice_html_description(self.episode_art_url, self.description or _('No description available'))
def one_line_description(self):
MAX_LINE_LENGTH = 120
desc = util.remove_html_tags(self.description or '')
desc = self._text_description
desc = re.sub(r'\s+', ' ', desc).strip()
if not desc:
return _('No description available')
@ -864,7 +879,8 @@ class PodcastEpisode(PodcastModelObject):
return '-'
def update_from(self, episode):
for k in ('title', 'url', 'description', 'description_html', 'link', 'published', 'guid', 'payment_url'):
for k in ('title', 'url', 'episode_art_url', 'description', 'description_html', 'chapters', 'link',
'published', 'guid', 'payment_url'):
setattr(self, k, getattr(episode, k))
# Don't overwrite file size on downloaded episodes
# See #648 refreshing a youtube podcast clears downloaded file size
@ -1101,7 +1117,9 @@ class PodcastChannel(PodcastModelObject):
Returns: A new PodcastEpisode object
"""
return self.EpisodeClass.create_from_dict(d, self)
episode = self.EpisodeClass.create_from_dict(d, self)
episode.cache_text_description()
return episode
def _consume_updated_title(self, new_title):
# Replace multi-space and newlines with single space (Maemo bug 11173)

View File

@ -167,7 +167,8 @@ class SoundcloudUser(object):
yield {
'title': track.get('title', track.get('permalink')) or _('Unknown track'),
'link': track.get('permalink_url') or 'https://soundcloud.com/' + self.username,
'description': track.get('description') or _('No description available'),
'description': util.remove_html_tags(track.get('description') or ''),
'description_html': '',
'url': url,
'file_size': int(filesize),
'mime_type': filetype,

View File

@ -46,7 +46,7 @@ class Matcher(object):
return (needle in haystack)
if needle in self._episode.title:
return True
return (needle in self._episode.description)
return (needle in self._episode._text_description)
# case-insensitive search in haystack, or both title and description if no haystack
def s(needle, haystack=None):
@ -55,7 +55,7 @@ class Matcher(object):
return (needle in haystack.casefold())
if needle in self._episode.title.casefold():
return True
return (needle in self._episode.description.casefold())
return (needle in self._episode._text_description.casefold())
# case-sensitive regular expression search in haystack, or both title and description if no haystack
def R(needle, haystack=None):
@ -64,7 +64,7 @@ class Matcher(object):
return regexp.search(haystack)
if regexp.search(self._episode.title):
return True
return regexp.search(self._episode.description)
return regexp.search(self._episode._text_description)
# case-insensitive regular expression search in haystack, or both title and description if no haystack
def r(needle, haystack=None):
@ -73,7 +73,7 @@ class Matcher(object):
return regexp.search(haystack)
if regexp.search(self._episode.title):
return True
return regexp.search(self._episode.description)
return regexp.search(self._episode._text_description)
return bool(eval(term, {'__builtins__': None, 'S': S, 's': s, 'R': R, 'r': r}, self))
except Exception as e:
@ -108,7 +108,7 @@ class Matcher(object):
elif k == 'title':
return episode.title
elif k == 'description':
return episode.description
return episode._text_description
elif k == 'since':
return (datetime.datetime.now() - datetime.datetime.fromtimestamp(episode.published)).days
elif k == 'age':
@ -215,7 +215,7 @@ class EQL(object):
if self._regex:
return re.search(self._query, episode.title, self._flags) is not None
elif self._string:
return self._query in episode.title.lower() or self._query in episode.description.lower()
return self._query in episode.title.lower() or self._query in episode._text_description.lower()
return Matcher(episode).match(self._query)

View File

@ -50,6 +50,8 @@ EpisodeColumns = (
'last_playback',
'payment_url',
'description_html',
'episode_art_url',
'chapters',
)
PodcastColumns = (
@ -72,7 +74,7 @@ PodcastColumns = (
'cover_thumb',
)
CURRENT_VERSION = 7
CURRENT_VERSION = 8
# SQL commands to upgrade old database versions to new ones
@ -114,6 +116,13 @@ UPGRADE_SQL = [
UPDATE episode SET description=remove_html_tags(description_html) WHERE is_html(description)
UPDATE podcast SET http_last_modified=NULL, http_etag=NULL
"""),
# Version 8: Add episode thumbnail URL and chapters
(7, 8, """
ALTER TABLE episode ADD COLUMN episode_art_url TEXT NULL DEFAULT NULL
ALTER TABLE episode ADD COLUMN chapters TEXT NULL DEFAULT NULL
UPDATE podcast SET http_last_modified=NULL, http_etag=NULL
"""),
]
@ -172,7 +181,9 @@ def initialize_database(db):
current_position_updated INTEGER NOT NULL DEFAULT 0,
last_playback INTEGER NOT NULL DEFAULT 0,
payment_url TEXT NULL DEFAULT NULL,
description_html TEXT NOT NULL DEFAULT ''
description_html TEXT NOT NULL DEFAULT '',
episode_art_url TEXT NULL DEFAULT NULL,
chapters TEXT NULL DEFAULT NULL
)
""")
@ -299,6 +310,8 @@ def convert_gpodder2_db(old_db, new_db):
0,
None,
'',
None,
None,
)
new_db.execute("""
INSERT INTO episode VALUES (%s)

View File

@ -48,8 +48,9 @@ _ = gpodder.gettext
gpod_available = True
try:
import gpod
from gpodder import libgpod_ctypes
except:
logger.info('iPod sync not available', exc_info=True)
gpod_available = False
mplayer_available = True if util.find_command('mplayer') is not None else False
@ -58,6 +59,7 @@ eyed3mp3_available = True
try:
import eyed3.mp3
except:
logger.info('eyeD3 MP3 not available', exc_info=True)
eyed3mp3_available = False
@ -88,7 +90,7 @@ def get_track_length(filename):
logger.error('MPlayer could not determine length: %s', filename, exc_info=True)
attempted = True
if eyd3mp3_available:
if eyed3mp3_available:
try:
length = int(eyed3.mp3.Mp3AudioFile(filename).info.time_secs * 1000)
# Notify user on eyed3 success if mplayer failed.
@ -160,7 +162,6 @@ class SyncTrack(object):
Keyword arguments needed:
playcount (How often has the track been played?)
podcast (Which podcast is this track from? Or: Folder name)
released (The release date of the episode)
If any of these fields is unknown, it should not be
passed to the function (the values will default to None
@ -175,11 +176,13 @@ class SyncTrack(object):
# Set some (possible) keyword arguments to default values
self.playcount = 0
self.podcast = None
self.released = None
# Convert keyword arguments to object attributes
self.__dict__.update(kwargs)
def __repr__(self):
return 'SyncTrack(title={}, podcast={})'.format(self.title, self.podcast)
@property
def playcount_str(self):
return str(self.playcount)
@ -227,7 +230,9 @@ class Device(services.ObservableService):
self._config.device_sync.skip_played_episodes)
wrong_type = track.file_type() not in self.allowed_types
if does_not_exist or exclude_played or wrong_type:
if does_not_exist:
tracklist.remove(track)
elif exclude_played or wrong_type:
logger.info('Excluding %s from sync', track.title)
tracklist.remove(track)
@ -251,15 +256,6 @@ class Device(services.ObservableService):
if done_callback:
done_callback()
def remove_tracks(self, tracklist):
for idx, track in enumerate(tracklist):
if self.cancelled:
return False
self.notify('progress', idx, len(tracklist))
self.remove_track(track)
return True
def get_all_tracks(self):
pass
@ -292,8 +288,8 @@ class iPodDevice(Device):
self.mountpoint = self._config.device_sync.device_folder
self.download_status_model = download_status_model
self.download_queue_manager = download_queue_manager
self.itdb = None
self.podcast_playlist = None
self.ipod = None
def get_free_space(self):
# Reserve 10 MiB for iTunesDB writing (to be on the safe side)
@ -307,144 +303,85 @@ class iPodDevice(Device):
def open(self):
Device.open(self)
if not gpod_available:
logger.error('Please install the gpod module to sync with an iPod device.')
logger.error('Please install libgpod 0.8.3 to sync with an iPod device.')
return False
if not os.path.isdir(self.mountpoint):
return False
self.notify('status', _('Opening iPod database'))
self.itdb = gpod.itdb_parse(self.mountpoint, None)
if self.itdb is None:
self.ipod = libgpod_ctypes.iPodDatabase(self.mountpoint)
if not self.ipod.itdb or not self.ipod.podcasts_playlist or not self.ipod.master_playlist:
return False
self.itdb.mountpoint = self.mountpoint
self.podcasts_playlist = gpod.itdb_playlist_podcasts(self.itdb)
self.master_playlist = gpod.itdb_playlist_mpl(self.itdb)
self.notify('status', _('iPod opened'))
if self.podcasts_playlist:
self.notify('status', _('iPod opened'))
# build the initial tracks_list
self.tracks_list = self.get_all_tracks()
# build the initial tracks_list
self.tracks_list = self.get_all_tracks()
return True
else:
return False
return True
def close(self):
if self.itdb is not None:
if self.ipod is not None:
self.notify('status', _('Saving iPod database'))
gpod.itdb_write(self.itdb, None)
self.itdb = None
if self._config.ipod_write_gtkpod_extended:
self.notify('status', _('Writing extended gtkpod database'))
itunes_folder = os.path.join(self.mountpoint, 'iPod_Control', 'iTunes')
ext_filename = os.path.join(itunes_folder, 'iTunesDB.ext')
idb_filename = os.path.join(itunes_folder, 'iTunesDB')
if os.path.exists(ext_filename) and os.path.exists(idb_filename):
try:
db = gpod.ipod.Database(self.mountpoint)
gpod.gtkpod.parse(ext_filename, db, idb_filename)
gpod.gtkpod.write(ext_filename, db, idb_filename)
db.close()
except:
logger.error('Error writing iTunesDB.ext')
else:
logger.warning('Could not find %s or %s.',
ext_filename, idb_filename)
self.ipod.close()
self.ipod = None
Device.close(self)
return True
def update_played_or_delete(self, channel, episodes, delete_from_db):
"""
Check whether episodes on ipod are played and update as played
and delete if required.
"""
for episode in episodes:
track = self.episode_on_device(episode)
if track:
gtrack = track.libgpodtrack
if gtrack.playcount > 0:
if delete_from_db and not gtrack.rating:
logger.info('Deleting episode from db %s', gtrack.title)
channel.delete_episode(episode)
else:
logger.info('Marking episode as played %s', gtrack.title)
def purge(self):
for track in gpod.sw_get_playlist_tracks(self.podcasts_playlist):
if gpod.itdb_filename_on_ipod(track) is None:
logger.info('Episode has no file: %s', track.title)
# self.remove_track_gpod(track)
elif track.playcount > 0 and not track.rating:
logger.info('Purging episode: %s', track.title)
self.remove_track_gpod(track)
def get_all_tracks(self):
tracks = []
for track in gpod.sw_get_playlist_tracks(self.podcasts_playlist):
filename = gpod.itdb_filename_on_ipod(track)
for track in self.ipod.get_podcast_tracks():
filename = track.filename_on_ipod
if filename is None:
# This can happen if the episode is deleted on the device
logger.info('Episode has no file: %s', track.title)
self.remove_track_gpod(track)
continue
length = 0
modified = ''
else:
length = util.calculate_size(filename)
timestamp = util.file_modification_timestamp(filename)
modified = util.format_date(timestamp)
length = util.calculate_size(filename)
timestamp = util.file_modification_timestamp(filename)
modified = util.format_date(timestamp)
try:
released = gpod.itdb_time_mac_to_host(track.time_released)
released = util.format_date(released)
except ValueError as ve:
# timestamp out of range for platform time_t (bug 418)
logger.info('Cannot convert track time: %s', ve)
released = 0
t = SyncTrack(track.title, length, modified,
modified_sort=timestamp,
libgpodtrack=track,
t = SyncTrack(track.episode_title, length, modified,
ipod_track=track,
playcount=track.playcount,
released=released,
podcast=track.artist)
podcast=track.podcast_title)
tracks.append(t)
return tracks
def episode_on_device(self, episode):
return next((track for track in self.tracks_list
if track.ipod_track.podcast_rss == episode.channel.url and
track.ipod_track.podcast_url == episode.url), None)
def remove_track(self, track):
self.notify('status', _('Removing %s') % track.title)
self.remove_track_gpod(track.libgpodtrack)
def remove_track_gpod(self, track):
filename = gpod.itdb_filename_on_ipod(track)
logger.info('Removing track from iPod: %r', track.title)
track.ipod_track.remove_from_device()
try:
gpod.itdb_playlist_remove_track(self.podcasts_playlist, track)
except:
logger.info('Track %s not in playlist', track.title)
self.tracks_list.remove(next((sync_track for sync_track in self.tracks_list
if sync_track.ipod_track == track), None))
except ValueError:
...
gpod.itdb_track_unlink(track)
util.delete_file(filename)
def add_track(self, episode, reporthook=None):
def add_track(self, task, reporthook=None):
episode = task.episode
self.notify('status', _('Adding %s') % episode.title)
tracklist = gpod.sw_get_playlist_tracks(self.podcasts_playlist)
podcasturls = [track.podcasturl for track in tracklist]
tracklist = self.ipod.get_podcast_tracks()
episode_urls = [track.podcast_url for track in tracklist]
if episode.url in podcasturls:
if episode.url in episode_urls:
# Mark as played on iPod if played locally (and set podcast flags)
self.set_podcast_flags(tracklist[podcasturls.index(episode.url)], episode)
self.update_from_episode(tracklist[episode_urls.index(episode.url)], episode)
return True
original_filename = episode.local_filename(create=False)
local_filename = episode.local_filename(create=False)
# The file has to exist, if we ought to transfer it, and therefore,
# local_filename(create=False) must never return None as filename
assert original_filename is not None
local_filename = original_filename
assert local_filename is not None
if util.calculate_size(original_filename) > self.get_free_space():
if util.calculate_size(local_filename) > self.get_free_space():
logger.error('Not enough space on %s, sync aborted...', self.mountpoint)
d = {'episode': episode.title, 'mountpoint': self.mountpoint}
message = _('Error copying %(episode)s: Not enough free space on %(mountpoint)s')
@ -452,69 +389,38 @@ class iPodDevice(Device):
self.cancelled = True
return False
local_filename = episode.local_filename(create=False)
(fn, extension) = os.path.splitext(local_filename)
if extension.lower().endswith('ogg'):
# XXX: Proper file extension/format support check for iPod
logger.error('Cannot copy .ogg files to iPod.')
return False
track = gpod.itdb_track_new()
track = self.ipod.add_track(local_filename, episode.title, episode.channel.title,
episode._text_description, episode.url, episode.channel.url,
episode.published, get_track_length(local_filename), episode.file_type() == 'audio')
# Add release time to track if episode.published has a valid value
if episode.published > 0:
try:
# libgpod>= 0.5.x uses a new timestamp format
track.time_released = gpod.itdb_time_host_to_mac(int(episode.published))
except:
# old (pre-0.5.x) libgpod versions expect mactime, so
# we're going to manually build a good mactime timestamp here :)
#
# + 2082844800 for unixtime => mactime (1970 => 1904)
track.time_released = int(episode.published + 2082844800)
self.update_from_episode(track, episode, initial=True)
track.title = str(episode.title)
track.album = str(episode.channel.title)
track.artist = str(episode.channel.title)
track.description = str(util.remove_html_tags(episode.description))
track.podcasturl = str(episode.url)
track.podcastrss = str(episode.channel.url)
track.tracklen = get_track_length(local_filename)
track.size = os.path.getsize(local_filename)
if episode.file_type() == 'audio':
track.filetype = 'mp3'
track.mediatype = 0x00000004
elif episode.file_type() == 'video':
track.filetype = 'm4v'
track.mediatype = 0x00000006
self.set_podcast_flags(track, episode)
gpod.itdb_track_add(self.itdb, track, -1)
gpod.itdb_playlist_add_track(self.master_playlist, track, -1)
gpod.itdb_playlist_add_track(self.podcasts_playlist, track, -1)
copied = gpod.itdb_cp_track_to_ipod(track, str(local_filename), None)
reporthook(episode.file_size, 1, episode.file_size)
# If the file has been converted, delete the temporary file here
if local_filename != original_filename:
util.delete_file(local_filename)
return True
def set_podcast_flags(self, track, episode):
try:
# Set several flags for to podcast values
track.remember_playback_position = 0x01
track.flag1 = 0x02
track.flag2 = 0x01
track.flag3 = 0x01
track.flag4 = 0x01
except:
logger.warning('Seems like your python-gpod is out-of-date.')
def update_from_episode(self, track, episode, *, initial=False):
if initial:
# Set the initial bookmark on the device based on what we have locally
track.initialize_bookmark(episode.is_new, episode.current_position * 1000)
else:
# Copy updated status from iPod
if track.playcount > 0:
episode.is_new = False
if track.bookmark_time > 0:
logger.info('Playback position from iPod: %s', util.format_time(track.bookmark_time / 1000))
episode.is_new = False
episode.current_position = int(track.bookmark_time / 1000)
episode.current_position_updated = time.time()
episode.save()
class MP3PlayerDevice(Device):
@ -645,7 +551,6 @@ class MP3PlayerDevice(Device):
modified = util.format_date(timestamp.tv_sec)
t = SyncTrack(title, info.get_size(), modified,
modified_sort=timestamp,
filename=file.get_uri(),
podcast=podcast_name)
tracks.append(t)

View File

@ -67,7 +67,7 @@ cp -a "$checkout"/tools/mac-osx/make_cert_pem.py "$resources"/bin
# install gPodder hard dependencies
$run_pip install setuptools wheel
$run_pip install podcastparser==0.6.7 mygpoclient==1.8 requests[socks]==2.25.1
$run_pip install podcastparser==0.6.8 mygpoclient==1.9 requests[socks]==2.25.1
# install extension dependencies; no explicit version for yt-dlp
$run_pip install mutagen==1.45.1 html5lib==1.1 yt-dlp

View File

@ -1,8 +1,8 @@
# PyPI / pip requirements for Linux
# For the benefit of e.g. flatpak-pip-generator.
#
mygpoclient==1.8
podcastparser==0.6.6
mygpoclient==1.9
podcastparser==0.6.8
requests[socks]==2.25.1
urllib3==1.26.5
html5lib==1.1

View File

@ -84,8 +84,8 @@ function extract_installer {
}
PIP_REQUIREMENTS="\
podcastparser==0.6.7
mygpoclient==1.8
podcastparser==0.6.8
mygpoclient==1.9
git+https://github.com/jaraco/pywin32-ctypes.git@f27d6a0
html5lib==1.1
webencodings==0.5.1
@ -97,7 +97,7 @@ urllib3==1.26.5
chardet==4.0.0
idna==3.2
PySocks==1.7.1
comtypes==1.1.10
comtypes==1.1.11
"
function install_deps {