Merge branch 'master' into dev-adaptive
This commit is contained in:
commit
2cc1a7614a
|
@ -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/cs_CZ.po
517
po/cs_CZ.po
File diff suppressed because it is too large
Load Diff
517
po/es_ES.po
517
po/es_ES.po
File diff suppressed because it is too large
Load Diff
517
po/es_MX.po
517
po/es_MX.po
File diff suppressed because it is too large
Load Diff
517
po/fa_IR.po
517
po/fa_IR.po
File diff suppressed because it is too large
Load Diff
517
po/id_ID.po
517
po/id_ID.po
File diff suppressed because it is too large
Load Diff
517
po/ko_KR.po
517
po/ko_KR.po
File diff suppressed because it is too large
Load Diff
517
po/messages.pot
517
po/messages.pot
File diff suppressed because it is too large
Load Diff
517
po/pt_BR.po
517
po/pt_BR.po
File diff suppressed because it is too large
Load Diff
517
po/zh_CN.po
517
po/zh_CN.po
File diff suppressed because it is too large
Load Diff
|
@ -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:
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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')):
|
||||
|
|
|
@ -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():
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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()
|
|
@ -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)
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 {
|
||||
|
|
Loading…
Reference in New Issue