From ad8bfd5439d37dc8a3d9c1cfe1e2db901b8b309f Mon Sep 17 00:00:00 2001 From: "R.D. Vaughan" Date: Fri, 21 Jun 2013 14:47:11 -0400 Subject: [PATCH] Add support for Miro v6.x. Fix watch video detection. Signed-off-by: Raymond Wagner --- .../contrib/imports/mirobridge/mirobridge.py | 174 ++-- .../mirobridge_interpreter_6_0_0.py | 841 ++++++++++++++++++ 2 files changed, 952 insertions(+), 63 deletions(-) create mode 100644 mythtv/contrib/imports/mirobridge/mirobridge/mirobridge_interpreter_6_0_0.py diff --git a/mythtv/contrib/imports/mirobridge/mirobridge.py b/mythtv/contrib/imports/mirobridge/mirobridge.py index d5c7d290558..09ef701567f 100755 --- a/mythtv/contrib/imports/mirobridge/mirobridge.py +++ b/mythtv/contrib/imports/mirobridge/mirobridge.py @@ -1,17 +1,18 @@ #!/usr/bin/env python # -*- coding: UTF-8 -*- # ---------------------- -# Name: mirobridge.py Maintains MythTV database with Miro's downloaded video files. +# Name: mirobridge.py Maintains MythTV database with Miro's downloaded +# video files. # Python Script # Author: R.D. Vaughan -# Purpose: This python script is intended to perform synchronise Miro's video files with MythTV's -# "Watch Recordings" and MythVideo. +# Purpose: This python script is intended to perform synchronise Miro's +# video files with MythTV's "Watch Recordings" and MythVideo. # # The source of all video files is from those downloaded my Miro. -# The source of all cover art and screen shoots are from those downloaded and maintained by -# Miro. -# Miro v2.03 or later must be already installed and configured and already capable of -# downloading videos. +# The source of all cover art and screen shoots are from those +# downloaded and maintained by Miro. +# Miro v2.03 or later must be already installed and configured +# and already capable of downloading videos. # # Command line examples: # See help (-u and -h) options @@ -22,15 +23,18 @@ __title__ ="mirobridge - Maintains Miro's Video files with MythTV"; __author__="R.D.Vaughan" __purpose__=''' -This python script is intended to synchronise Miro's video files with MythTV's "Watch Recordings" and MythVideo. +This python script is intended to synchronise Miro's video files with +MythTV's "Watch Recordings" and MythVideo. The source of all video files are from those downloaded my Miro. The source of all meta data for the video files is from the Miro data base. -The source of all cover art and screen shots are from those downloaded and maintained by Miro. -Miro v2.0.3 or later must already be installed and configured and capable of downloading videos. +The source of all cover art and screen shots are from those downloaded +and maintained by Miro. +Miro v2.0.3 or later must already be installed and configured and +capable of downloading videos. ''' -__version__=u"v0.7.0" +__version__=u"v0.7.1" # 0.1.0 Initial development # 0.2.0 Initial Alpha release for internal testing only # 0.2.1 Fixes from initial alpha test @@ -211,9 +215,12 @@ # 0.6.8 Sometimes Miro metadata has no video filename. Skip these invalid videos. # 0.6.9 Adjust to datetime issues with MythTV v0.26's move to UTC datatimes in DB # 0.7.0 Fix bug introduced with v0.6.9, ticket reported as #11219 and #11220 +# 0.7.1 Added support for Miro 6.x. Miro 5.x can never be supported due to +# miro cli support being broken. examples_txt=u''' -For examples, please see the Mirobridge's wiki page at http://www.mythtv.org/wiki/MiroBridge +For examples, please see the Mirobridge's wiki page at +http://www.mythtv.org/wiki/MiroBridge ''' # Common function imports @@ -431,7 +438,6 @@ def __getattr__(self, attr): utils.initialize_locale() except: pass - # Set up gettext before everything else from miro import config # New for Miro4 (Changed import location) from miro import eventloop # New for Miro4 @@ -452,29 +458,33 @@ def __getattr__(self, attr): from miro import startup from miro import app from miro.frontends.cli.events import EventHandler - # Required for Miro 4 as the configuration calls changed location # and additional Miro 4 specific imports are required try: - dummy = app.config.get(prefs.APP_VERSION) - # A test to see if this is Miro v4 before the version can be read. - # If there is no exception this is Miro v4 + version = app.config.get(prefs.APP_VERSION) + # A test to see if this is Miro v4 or v6 before the version can be read. + # If there is no exception this is Miro v4 or v6 eventloop.setup_config_watcher() from miro import signals from miro import messages from miro import eventloop from miro import feed from miro import workerprocess - from miro.frontends.cli.application import InfoUpdaterCallbackList - from miro.frontends.cli.application import InfoUpdater - from miro.plat.renderers.gstreamerrenderer import movie_data_program_info + if version[0] == '4': + from miro.frontends.cli.application import InfoUpdaterCallbackList + from miro.frontends.cli.application import InfoUpdater + from miro.plat.renderers.gstreamerrenderer import movie_data_program_info + else: + from miro import util + app.startup_timer = util.DebuggingTimer() + utils.register_exec_prefix() + # miroConfiguration = app.config.get from miro import controller app.controller = controller.Controller() except: miroConfiguration = config.get pass - except Exception, e: logger.critical(u"Importing Miro functions has an issue. Miro must be installed "\ u"and functional, error(%s)", e) @@ -501,9 +511,17 @@ def __getattr__(self, attr): elif miroConfiguration(prefs.APP_VERSION) < u"4.0": logger.info("Using mirobridge_interpreter_3_5_0") from mirobridge.mirobridge_interpreter_3_5_0 import MiroInterpreter - else: + elif miroConfiguration(prefs.APP_VERSION) < u"5.0": logger.info("Using mirobridge_interpreter_4_0_2") from mirobridge.mirobridge_interpreter_4_0_2 import MiroInterpreter + elif miroConfiguration(prefs.APP_VERSION) < u"6.0": + logger.critical(''' +Miro version 5.x cannot be supported due to that version not supporting +a CLI mode. Use versions 4.0.2+ or 6.0+ but not any v5.''') + sys.exit(1) + else: + logger.info("Using mirobridge_interpreter_6_0_0") + from mirobridge.mirobridge_interpreter_6_0_0 import MiroInterpreter from mirobridge.metadata import MetaData except Exception, e: logger.critical(u"Importing mirobridge functions has failed. At least a 'mirobridge_interpreter' "\ @@ -1165,26 +1183,34 @@ def getOldrecordedOrphans(): # Prevents accidental deletions. metadata.convertOldMiroVideos() - recorded_array = list(mythdb.searchRecorded(chanid=channel_id, hostname=localhostname)) + recorded_array = list(mythdb.searchRecorded(chanid=channel_id, + hostname=localhostname)) oldrecorded_array = list(mythdb.searchOldRecorded(chanid=channel_id, )) videometadata = list(mythdb.searchVideos(category=u'Miro')) orphans = [] for record in oldrecorded_array: for recorded in recorded_array: - if recorded[u'starttime'] == record[u'starttime'] and recorded[u'endtime'] == \ - record[u'endtime']: + # First check if the recording was marked for deletion + # by the user. The BE actually deletes the recorded record. + if recorded['autoexpire'] == 9999: + continue + # + if recorded[u'starttime'] == record[u'starttime'] and \ + recorded[u'endtime'] == record[u'endtime']: break else: for video in videometadata: - if video[u'title'] == record[u'title'] and video[u'subtitle'] == record[u'subtitle']: + if video[u'title'] == record[u'title'] and \ + video[u'subtitle'] == record[u'subtitle']: break else: orphans.append(record) for data in orphans: if simulation: - logger.info(u"Simulation: Remove orphaned oldrecorded record (%s - %s)" % \ + logger.info( + u"Simulation: Remove orphaned oldrecorded record (%s - %s)" % \ (data[u'title'], data[u'subtitle'])) else: try: @@ -1198,26 +1224,29 @@ def getOldrecordedOrphans(): # Attempt a clean up for orphaned recorded video files and/or graphics metadata.cleanupVideoAndGraphics(u'%s%s_%s.%s' % \ - (vid_graphics_dirs[u'default'], channel_id, - data[u'starttime'].strftime('%Y%m%d%H%M%S'), u'png')) + (vid_graphics_dirs[u'default'], channel_id, + data[u'starttime'].strftime('%Y%m%d%H%M%S'), u'png')) - # Attempt a clean up for orphaned MythVideo files and/or graphics from the Default directory + # Attempt a clean up for orphaned MythVideo files and/or + # graphics from the Default directory metadata.cleanupVideoAndGraphics(u'%s%s - %s.%s' % \ - (vid_graphics_dirs[u'default'], data[u'title'], - data[u'subtitle'], u'png')) + (vid_graphics_dirs[u'default'], data[u'title'], + data[u'subtitle'], u'png')) # Attempt a clean up for orphaned MythVideo screenshot metadata.cleanupVideoAndGraphics(u'%s%s - %s%s.%s' % \ - (vid_graphics_dirs[u'episodeimagedir'], data[u'title'], - data[u'subtitle'], graphic_suffix[u'episodeimagedir'], u'png')) + (vid_graphics_dirs[u'episodeimagedir'], data[u'title'], + data[u'subtitle'], graphic_suffix[u'episodeimagedir'], + u'png')) # Remove any unique cover art graphic files if data[u'title'].lower() in channel_icon_override: metadata.cleanupVideoAndGraphics(u'%s%s - %s%s.%s' % \ - (vid_graphics_dirs[u'posterdir'], data[u'title'], - data[u'subtitle'], graphic_suffix[u'posterdir'], u'png')) + (vid_graphics_dirs[u'posterdir'], data[u'title'], + data[u'subtitle'], graphic_suffix[u'posterdir'], u'png')) - displayMessage(u"Removed orphaned Miro video and graphics files (%s - %s)" % \ + displayMessage( + u"Removed orphaned Miro video and graphics files (%s - %s)" % \ (data[u'title'], data[u'subtitle'])) return orphans @@ -1700,7 +1729,9 @@ def getPlayedMiroVideos(): filenames=[] recorded = list(mythdb.searchRecorded(chanid=channel_id, hostname=localhostname)) for record in recorded: - if record[u'watched'] == 0: # Skip if the video has NOT been watched + # Skip if the video has NOT been watched or has been marked for + # deletion + if record[u'watched'] == 0 or record['autoexpire'] == 9999: continue try: filenames.append(os.path.realpath(storagegroups[u'default']+record[u'basename'])) @@ -2317,25 +2348,33 @@ def main(): # displayMessage(u"Starting Miro Frontend and Backend") startup.initialize(miroConfiguration(prefs.THEME_NAME)) - if miroConfiguration(prefs.APP_VERSION) > u"4.0": # Only required for Miro 4 + # + # Only required for Miro 4 + if miroConfiguration(prefs.APP_VERSION)[0] == u"4": app.info_updater = InfoUpdater() app.cli_events = EventHandler() app.cli_events.connect_to_signals() - if miroConfiguration(prefs.APP_VERSION) > u"4.0": # Only required for Miro 4 + # Only required for Miro 4 and higher + if miroConfiguration(prefs.APP_VERSION) > u"4.0": startup.install_first_time_handler(app.cli_events.handle_first_time) startup.startup() app.cli_events.startup_event.wait() if app.cli_events.startup_failure: - logger.critical(u"Starting Miro Frontend and Backend failed: (%s)\n(%s)" % \ - (app.cli_events.startup_failure[0], app.cli_events.startup_failure[1])) + logger.critical( +u"Starting Miro Frontend and Backend failed: (%s)\n(%s)" % \ + (app.cli_events.startup_failure[0], + app.cli_events.startup_failure[1])) app.controller.shutdown() time.sleep(5) # Let the shutdown processing complete sys.exit(1) - if miroConfiguration(prefs.APP_VERSION) > u"4.0": # Only required for Miro 4 + # Only required for Miro 4 + if miroConfiguration(prefs.APP_VERSION)[0] == u"4": app.movie_data_program_info = movie_data_program_info + # Only required for Miro 4 and higher + if miroConfiguration(prefs.APP_VERSION) > u"4.0": messages.FrontendStarted().send_to_backend() app.cli_interpreter = MiroInterpreter() @@ -2346,15 +2385,19 @@ def main(): app.cli_interpreter.simulation = opts.simulation app.cli_interpreter.videofiles = [] app.cli_interpreter.downloading = False - app.cli_interpreter.icon_cache_dir = miroConfiguration(prefs.ICON_CACHE_DIRECTORY) + app.cli_interpreter.icon_cache_dir = miroConfiguration( + prefs.ICON_CACHE_DIRECTORY) app.cli_interpreter.imagemagick = imagemagick app.cli_interpreter.statistics = statistics + # + ## Version specific logic if miroConfiguration(prefs.APP_VERSION) < u"2.5.0": app.renderer = app.cli_interpreter - elif miroConfiguration(prefs.APP_VERSION) > u"4.0": # Only required for Miro 4 + elif miroConfiguration(prefs.APP_VERSION) > u"4.0": pass else: - app.movie_data_program_info = app.cli_interpreter.movie_data_program_info + app.movie_data_program_info = \ + app.cli_interpreter.movie_data_program_info # # Attempt to import an opml file @@ -2363,9 +2406,11 @@ def main(): results = 0 try: app.cli_interpreter.do_mythtv_import_opml(opts.import_opml) - time.sleep(30) # Let the Miro backend process the OPML file before shutting down + # Let the Miro backend process the OPML file before shutting down + time.sleep(30) except Exception, e: - logger.critical(u"Import of OPML file (%s) failed, error(%s)." % (opts.import_opml, e)) + logger.critical(u"Import of OPML file (%s) failed, error(%s)." % + (opts.import_opml, e)) results = 1 # Gracefully close the Miro database and shutdown the Miro Front and Back ends app.controller.shutdown() @@ -2409,22 +2454,23 @@ def main(): app.cli_interpreter.verbose = True # Deal with orphaned oldrecorded records. - # These records indicate that the MythTV user deleted the video from the Watched Recordings screen - # or from MythVideo + # These records indicate that the MythTV user deleted the video + # from the Watched Recordings screen or from MythVideo # These video items must also be deleted from Miro videostodelete = getOldrecordedOrphans() if len(videostodelete): - displayMessage(u"Starting Miro delete of videos deleted in the MythTV "\ + displayMessage( + u"Starting Miro delete of videos deleted in the MythTV "\ u"Watched Recordings screen.") for video in videostodelete: # Completely remove the video and item information from Miro - app.cli_interpreter.do_mythtv_item_remove([video[u'title'], video[u'subtitle']]) + app.cli_interpreter.do_mythtv_item_remove( + [video[u'title'], video[u'subtitle']]) # # Collect the set of played Miro video files # app.cli_interpreter.videofiles = getPlayedMiroVideos() - # # Updated the played status of items # @@ -2437,13 +2483,11 @@ def main(): # app.cli_interpreter.do_mythtv_getunwatched(u'') unwatched = app.cli_interpreter.videofiles - # # Get the watched videos details from Miro # app.cli_interpreter.do_mythtv_getwatched(u'') watched = app.cli_interpreter.videofiles - # # Massage empty titles and subtitles from Miro # @@ -2470,17 +2514,21 @@ def main(): unwatched_copy.append(item) for item in unwatched_copy: # Check for a duplicate against already watched Miro videos for x in watched: - if item[u'channelTitle'] == x[u'channelTitle'] and item[u'title'] == x[u'title']: + if item[u'channelTitle'] == x[u'channelTitle'] and \ + item[u'title'] == x[u'title']: try: unwatched.remove(item) # Completely remove this duplicate video and item information from Miro - app.cli_interpreter.do_mythtv_item_remove(item[u'videoFilename']) - displayMessage((u"Skipped adding a duplicate Miro video to the MythTV "\ - u"Watch Recordings screen (%s - %s) which is already in "\ - u"MythVideo.\nSometimes a Miro channel has the same video "\ - u"downloaded multiple times.\nThis is a Miro/Channel web "\ - u"site issue and often rectifies itself overtime.") % \ - (item[u'channelTitle'], item[u'title'])) + app.cli_interpreter.do_mythtv_item_remove( + item[u'videoFilename']) + displayMessage(( +u'''Skipped adding a duplicate Miro video to the MythTV +Watch Recordings screen (%s - %s) which is already in +MythVideo. +Sometimes a Miro channel has the same video +downloaded multiple times. +This is a Miro/Channel web site issue and often +rectifies itself overtime.''') % (item[u'channelTitle'], item[u'title'])) except ValueError: pass duplicates = [] diff --git a/mythtv/contrib/imports/mirobridge/mirobridge/mirobridge_interpreter_6_0_0.py b/mythtv/contrib/imports/mirobridge/mirobridge/mirobridge_interpreter_6_0_0.py new file mode 100644 index 00000000000..1a5a6d9e78e --- /dev/null +++ b/mythtv/contrib/imports/mirobridge/mirobridge/mirobridge_interpreter_6_0_0.py @@ -0,0 +1,841 @@ +# Miro - an RSS based video player application +# Copyright (C) 2005, 2006, 2007, 2008, 2009, 2010, 2011 +# Participatory Culture Foundation +# +# This program 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 2 of the License, or +# (at your option) any later version. +# +# This program 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, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA +# +# In addition, as a special exception, the copyright holders give +# permission to link the code of portions of this program with the OpenSSL +# library. +# +# You must obey the GNU General Public License in all respects for all of +# the code used other than OpenSSL. If you modify file(s) with this +# exception, you may extend this exception to your version of the file(s), +# but you are not obligated to do so. If you do not wish to do so, delete +# this exception statement from your version. If you delete this exception +# statement from all source files in the program, then also delete it here. + +import cmd +import threading +import time +import Queue + +from miro import app +from miro import dialogs +from miro import eventloop +from miro import item +from miro import folder +from miro import tabs +from miro.frontends.cli import clidialog +from miro.plat import resources + +############################################################################ +# +# Start of mirobridge.py import additions - All to get feed updates, +# auto downloads and OPML import working +# +############################################################################ +import os, sys, subprocess, re, fnmatch, string +import logging +from miro import moviedata +from miro import commandline +from miro import autodler +from miro import downloader +from miro import iconcache +from miro.clock import clock +from miro import feed +from miro.commandline import parse_command_line_args +from miro import fileutil +from miro import autoupdate +from miro import startup +from miro import filetypes +from miro import messages +from miro import config +from miro import prefs +from miro import workerprocess +########################################################################## +# +# End of mirobridge.py import additions - All to get feed updates, auto +# downloads and OPML import working +# +########################################################################## + +def run_in_event_loop(func): + def decorated(*args, **kwargs): + return_hack = [] + event = threading.Event() + def runThenSet(): + try: + return_hack.append(func(*args, **kwargs)) + finally: + event.set() + eventloop.add_urgent_call(runThenSet, 'run in event loop') + event.wait() + if return_hack: + return return_hack[0] + decorated.__doc__ = func.__doc__ + return decorated + +class FakeTab: + def __init__(self, tab_type, tab_template_base): + self.type = tab_type + self.tab_template_base = tab_template_base + +class MiroInterpreter(cmd.Cmd): + def __init__(self): + cmd.Cmd.__init__(self) + self.quit_flag = False + self.tab = None + self.init_database_objects() + + def emptyline(self): + print "Type \"help\" for help." + + @run_in_event_loop + def init_database_objects(self): + self.feed_tabs = tabs.TabOrder.feed_order() + self.playlist_tabs = tabs.TabOrder.playlist_order() + self.tab_changed() + + def tab_changed(self): + """Calculate the current prompt. This method access database objects, + so it should only be called from the backend event loop + """ + if self.tab is None: + self.prompt = "> " + self.selection_type = None + + elif self.tab.type == 'feed': + if isinstance(self.tab, folder.ChannelFolder): + self.prompt = "channel folder: %s > " % self.tab.get_title() + self.selection_type = 'channel-folder' + else: + self.prompt = "channel: %s > " % self.tab.get_title() + self.selection_type = 'feed' + + elif self.tab.type == 'playlist': + self.prompt = "playlist: %s > " % self.tab.get_title() + self.selection_type = 'playlist' + + elif (self.tab.type == 'statictab' and + self.tab.tab_template_base == 'downloadtab'): + self.prompt = "downloads > " + self.selection_type = 'downloads' + else: + raise ValueError("Unknown tab type") + + def postcmd(self, stop, line): + # HACK + # If the last command results in a dialog, give it a little time to + # pop up + time.sleep(0.1) + while True: + try: + dialog = app.cli_events.dialog_queue.get_nowait() + except Queue.Empty: + break + clidialog.handle_dialog(dialog) + + return self.quit_flag + + def do_help(self, line): + """help -- Lists commands and help.""" + commands = [m for m in dir(self) if m.startswith("do_")] + for mem in commands: + docstring = getattr(self, mem).__doc__ + print " ", docstring.strip() + + def do_quit(self, line): + """quit -- Quits Miro cli.""" + self.quit_flag = True + + @run_in_event_loop + def do_feed(self, line): + """feed -- Selects a feed by name.""" + for tab in self.feed_tabs.get_all_tabs(): + if tab.get_title() == line: + self.tab = tab + self.tab.type = "feed" + self.tab_changed() + return + print "Error: %s not found." % line + + @run_in_event_loop + def do_rmfeed(self, line): + """rmfeed -- Deletes a feed.""" + for tab in self.feed_tabs.get_all_tabs(): + if tab.get_title() == line: + tab.remove() + return + print "Error: %s not found." % line + + @run_in_event_loop + def complete_feed(self, text, line, begidx, endidx): + return self.handle_tab_complete(text, + list(self.feed_tabs.get_all_tabs())) + + @run_in_event_loop + def complete_rmfeed(self, text, line, begidx, endidx): + return self.handle_tab_complete(text, + list(self.feed_tabs.get_all_tabs())) + + @run_in_event_loop + def complete_playlist(self, text, line, begidx, endidx): + return self.handle_tab_complete(text, + self.playlist_tabs.get_all_tabs()) + + def handle_tab_complete(self, text, view_items): + text = text.lower() + matches = [] + for tab in view_items: + if tab.get_title().lower().startswith(text): + matches.append(tab.get_title()) + return matches + + def handle_item_complete(self, text, view, filterFunc=lambda i: True): + text = text.lower() + matches = [] + for item_ in view: + if (item_.get_title().lower().startswith(text) and + filterFunc(item_)): + matches.append(item_.get_title()) + return matches + + ###################################################################### + # + # Start of mirobridge specific routines - Part 1 + # + ###################################################################### + @run_in_event_loop + def do_mythtv_check_downloading(self, line): + """Check if any items are being downloaded. Set True or False""" + self.downloading = False + downloadingItems = item.Item.only_downloading_view() + count = downloadingItems.count() + for it in downloadingItems: + logging.info( + u"(%s - %s) video is downloading with (%0.0f%%) complete" % ( + it.get_channel_title(True).replace(u'/',u'-'), + it.get_title().replace(u'/',u'-'), it.download_progress())) + if not count: + logging.info(u"No items downloading") + if count: + self.downloading = True + + @run_in_event_loop + def do_mythtv_import_opml(self, filename): + """Import an OPML file""" + try: + messages.ImportFeeds(filename).send_to_backend() + logging.info( + u"Import of OPML file (%s) sent to Miro" % (filename)) + except Exception, e: + logging.info( + u"Import of OPML file (%s) failed, error (%s)" % (filename, e)) + + @run_in_event_loop + def do_mythtv_updatewatched(self, line): + """Process MythTV update watched videos""" + items = item.Item.downloaded_view() + for video in self.videofiles: + for it in items: + if it.get_filename() == video: + break + else: + logging.info( + u"Item for Miro video (%s) not found, skipping" % video) + continue + if self.simulation: + logging.info( + u"Simulation: Item (%s - %s) marked as seen and watched" % ( + it.get_channel_title(True), it.get_title())) + else: + it.mark_watched(mark_other_items=True) + self.statistics[u'Miro_marked_watch_seen']+=1 + logging.info( + u"Item (%s - %s) marked as seen and watched" % ( + it.get_channel_title(True), it.get_title())) + + @run_in_event_loop + def do_mythtv_getunwatched(self, line): + """Process MythTV get all un-watched video details""" + if self.verbose: + print + print u"Getting details on un-watched Miro videos" + self.videofiles = [] + if item.Item.unwatched_downloaded_items().count(): + if self.verbose: + print u"%-20s %-10s %s" % (u"State", u"Size", u"Name") + print u"-" * 70 + for it in item.Item.unwatched_downloaded_items(): + # Skip any audio file as MythTV Internal player may abort + # the MythTV Frontend on a MP3 + state = it.get_state() + if not state == u'newly-downloaded': + continue + # Skip any bittorrent video downloads for legal concerns + if hasattr(it.get_parent(), u'url'): + if filetypes.is_torrent_filename(it.get_parent().url): + continue + + # Any item without a proper file name needs to be removed + # as Miro metadata is corrupt + if it.get_filename() == None: + it.expire() + self.statistics[u'Miro_videos_deleted']+=1 + logging.info( +u'Unwatched video (%s) has been removed from Miro as item had no valid file name' % it.get_title()) + continue + + self.printItems(it) + self.videofiles.append(self._get_item_dict(it)) + if self.verbose: + print + if not len(self.videofiles): + logging.info(u"No un-watched Miro videos") + + @run_in_event_loop + def do_mythtv_getwatched(self, line): + """Process MythTV get all watched/saved video details""" + if self.verbose: + print + print u"Getting details on watched/saved Miro videos" + self.videofiles = [] + if item.Item.downloaded_view().count(): + if self.verbose: + print u"%-20s %-10s %s" % (u"State", u"Size", u"Name") + print u"-" * 70 + for it in item.Item.downloaded_view(): + state = it.get_state() + if state == u'newly-downloaded': + continue + # Skip any bittorrent video downloads for legal concerns + if hasattr(it.get_parent(), u'url'): + if filetypes.is_torrent_filename(it.get_parent().url): + continue + + # Any item without a proper file name needs to be removed as Miro metadata is corrupt + if it.get_filename() == None: + it.expire() + self.statistics[u'Miro_videos_deleted']+=1 + logging.info( +u'Watched video (%s) has been removed from Miro as item had no valid file name' % it.get_title()) + continue + + self.printItems(it) + self.videofiles.append(self._get_item_dict(it)) + if self.verbose: + print + if not len(self.videofiles): + logging.info(u"No watched/saved Miro videos") + + ########################################################### + # START - Function used for debugging - DO NOT UNCOMMENT + # + ########################################################### +# @run_in_event_loop +# def do_mythtv_test_views(self, line): +# """Process MythTV get all watched/saved video details""" +# print +# print u"Testing various views" +# print + +# print "Download view count (%s)" % item.Item.downloaded_view().count() +# print "Newly Download view count (%s)" % item.Item.newly_downloaded_view().count() +# print "Tab View Download view count (%s)" % item.Item.download_tab_view().count() +# print "unwatched Download view count (%s)" % item.Item.unwatched_downloaded_items().count() +# print "unique Download view count (%s)" % item.Item.unique_new_video_view().count() + +# print + +# if item.Item.downloaded_view().count(): +# print u"%-20s %-10s %s" % (u"State", u"Size", u"Name") +# print u"-" * 70 +# for it in item.Item.downloaded_view(): +# state = it.get_state() +# self.printItems(it) +# print + ########################################################### + # END - Function used for debugging - DO NOT UNCOMMENT + # + ########################################################### + + def printItems(self, it): + if not self.verbose: + return + state = it.get_state() + if state == u'downloading': + state += u' (%0.0f%%)' % it.download_progress() + print u"%-20s %-10s %s" % (state, it.get_size_for_display(), + it.get_title()) + # end printItems() + + @run_in_event_loop + def do_mythtv_item_remove(self, args): + """Removes an item from Miro by file name or Channel and title""" + for it in item.Item.downloaded_view(): + if isinstance(args, list): + if not args[0] or not args[1]: + continue + if filter( + self.is_not_punct_char, + it.get_channel_title(True).lower()) == filter( + self.is_not_punct_char, args[0].lower()) and ( + filter(self.is_not_punct_char, + it.get_title().lower())).startswith( + filter(self.is_not_punct_char, args[1].lower())): + break + elif args: + if filter(self.is_not_punct_char, + it.get_filename().lower()) == filter( + self.is_not_punct_char, args.lower()): + break + else: + logging.info(u"No item named %s" % args) + return + if it.is_downloaded(): + if self.simulation: + logging.info( +u"Simulation: Item (%s - %s) has been removed from Miro" % ( + it.get_channel_title(True), it.get_title())) + else: + it.expire() + self.statistics[u'Miro_videos_deleted']+=1 + logging.info(u'%s has been removed from Miro' % it.get_title()) + else: + logging.info(u'%s is not downloaded' % it.get_title()) + + def _get_item_dict(self, it): + """Take an item and convert all elements into a dictionary + return a dictionary of item elements + """ + def compatibleGraphics(filename): + if filename: + (dirName, fileName) = os.path.split(filename) + (fileBaseName, fileExtension)=os.path.splitext(fileName) + if not fileExtension[1:] in [u"png", u"jpg", u"bmp", u"gif"]: + return u'' + else: + return filename + else: + return u'' + + def useImageMagick(screenshot): + """ Using ImageMagick's utility 'identify'. Decide whether the screen shot is worth using. + >>> useImageMagick('identify screenshot.jpg') + >>> Example returned information "rose.jpg JPEG 640x480 DirectClass 87kb 0.050u 0:01" + >>> u'' if the screenshot quality is too low + >>> screenshot if the quality is good enough to use + """ + if not self.imagemagick: # If imagemagick is not installed do not bother checking + return u'' + + width_height = re.compile( +u'''^(.+?)[ ]\[?([0-9]+)x([0-9]+)[^\\/]*$''', re.UNICODE) + p = subprocess.Popen(u'identify "%s"' % (screenshot), + shell=True, bufsize=4096, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, close_fds=True) + + response = p.stdout.readline() + if response: + match = width_height.match(response) + if match: + dummy, width, height = match.groups() + width, height = int(width), int(height) + if width >= 320: + return screenshot + return u'' + else: + return u'' + return screenshot + # end useImageMagick() + + item_icon_filename = it.icon_cache.filename + channel_icon = it.get_feed().icon_cache.get_filename() + + # Conform to maximum length for MythTV database fields title and subtitle + maximum_length = 128 + channel_title = it.get_channel_title(True).replace(u'/',u'-') + if channel_title: + if len(channel_title) > maximum_length: + channel_title = channel_title[:maximum_length] + channel_title = channel_title.replace(u'"', u'') # These characters mess with filenames + title = it.get_title().replace(u'/',u'-') + if title: + if len(title) > maximum_length: + title = title[:maximum_length] + title = title.replace(u'"', u'') # These characters mess with filenames + title = title.replace(u"'", u'') # These characters mess with filenames + + item_dict = { + u'feed_id': it.feed_id, u'parent_id': it.parent_id, + u'isContainerItem': it.is_container_item, + u'seen': it.get_state(), + u'autoDownloaded': it.auto_downloaded, + u'pendingManualDL': it.pending_manual_download, + u'downloadedTime': it.downloaded_time, + u'watchedTime': it.watched_time, + u'pendingReason': it.pending_reason, + u'title': title, + u'expired': it.expired, + u'keep': it.keep, u'videoFilename': it.get_filename(), + u'eligibleForAutoDownload': it.eligible_for_autodownload, + u'duration': it.duration, + u'screenshot': it.screenshot, + u'resumeTime': it.resume_time, + u'channelTitle': channel_title, + u'description': it.get_description(), + u'size': it.get_size(), + u'releasedate': it.get_release_date(), + u'length': it.get_duration_value(), + u'channel_icon': channel_icon, + u'item_icon': item_icon_filename, + u'inetref': u'', u'season': 1, u'episode': 1, + } + + if not item_dict[u'screenshot']: + if item_dict[u'item_icon']: + item_dict[u'screenshot'] = useImageMagick(item_dict[u'item_icon']) + + for key in [u'screenshot', u'channel_icon', u'item_icon']: + if item_dict[key]: + item_dict[key] = compatibleGraphics(item_dict[key]) + #if self.verbose: + # if item_dict[key]: + # print "item (%s - %s) %s (%s)" % (channel_title, title, key, item_dict[key]) + # else: + # print "item (%s - %s) does NOT have a %s" % (channel_title, title, key) + + #print item_dict + return item_dict + + # Two routines used for Channel title search and matching + def is_punct_char(self, char): + '''check if char is punctuation char + return True if char is punctuation + return False if char is not punctuation + ''' + return char in string.punctuation + + def is_not_punct_char(self, char): + '''check if char is not punctuation char + return True if char is not punctuation + return False if chaar is punctuation + ''' + return not self.is_punct_char(char) + + ####################################################################### + # + # End of mythbridge specific routines - Part 1 + # + ####################################################################### + + def _print_feeds(self, feeds): + current_folder = None + for tab in feeds: + if isinstance(tab, folder.ChannelFolder): + current_folder = tab + elif tab.get_folder() is not current_folder: + current_folder = None + if current_folder is None: + print " * " + tab.get_title() + elif current_folder is tab: + print " * [Folder] %s" % tab.get_title() + else: + print " * - %s" % tab.get_title() + + @run_in_event_loop + def do_feeds(self, line): + """feeds -- Lists all feeds.""" + print "FEEDS" + self._print_feeds(self.feed_tabs.get_all_tabs()) + + @run_in_event_loop + def do_update(self, line): + """update -- Updates all the feeds.""" + for mem in self.feed_tabs.get_all_tabs(): + print "telling %s to update" % mem.get_title() + mem.update() + + @run_in_event_loop + def do_play(self, line): + """play -- Plays an item by name in an external player.""" + if self.selection_type is None: + print "Error: No feed/playlist selected." + return + item_ = self._find_item(line) + if item_ is None: + print "No item named %r" % line + return + if item_.is_downloaded(): + resources.open_file(item_.get_filename()) + else: + print '%s is not downloaded' % item_.get_title() + + @run_in_event_loop + def do_playlists(self, line): + """playlists -- Lists all playlists.""" + for tab in self.playlistTabs.getView(): + print tab.obj.get_title() + + @run_in_event_loop + def do_playlist(self, line): + """playlist -- Selects a playlist.""" + for tab in self.playlistTabs.getView(): + if tab.obj.get_title() == line: + self.tab = tab + self.tab_changed() + return + print "Error: %s not found." % line + + @run_in_event_loop + def do_items(self, line): + """items -- Lists the items in the feed/playlist/tab selected.""" + if self.selection_type is None: + print "Error: No tab/feed/playlist selected." + return + elif self.selection_type == 'feed': + feed = self.tab + view = feed.items + self.printout_item_list(view) + elif self.selection_type == 'playlist': + playlist = self.tab.obj + self.printout_item_list(playlist.getView()) + elif self.selection_type == 'downloads': + self.printout_item_list(item.Item.downloading_view(), + item.Item.paused_view()) + elif self.selection_type == 'channel-folder': + folder = self.tab.obj + allItems = views.items.filterWithIndex( + indexes.itemsByChannelFolder, folder) + allItemsSorted = allItems.sort(folder.itemSort.sort) + self.printout_item_list(allItemsSorted) + allItemsSorted.unlink() + else: + raise ValueError("Unknown tab type") + + @run_in_event_loop + def do_downloads(self, line): + """downloads -- Selects the downloads tab.""" + self.tab = FakeTab("statictab", "downloadtab") + self.tab_changed() + + def printout_item_list(self, *views): + totalItems = 0 + for view in views: + totalItems += view.count() + if totalItems > 0: + print "%-20s %-10s %s" % ("State", "Size", "Name") + print "-" * 70 + for view in views: + for item in view: + state = item.get_state() + if state == 'downloading': + state += ' (%0.0f%%)' % item.download_progress() + print "%-20s %-10s %s" % (state, + item.get_size_for_display(), + item.get_title()) + print + else: + print "No items" + + def _get_item_view(self): + if self.selection_type == 'feed': + return item.Item.visible_feed_view(self.tab.id) + elif self.selection_type == 'playlist': + return item.Item.playlist_view(self.tab.id) + elif self.selection_type == 'downloads': + return item.Item.downloading_view() + elif self.selection_type == 'channel-folder': + folder = self.tab + return item.Item.visible_folder_view(folder.id) + else: + raise ValueError("Unknown selection type") + + def _find_item(self, line): + line = line.lower() + for item in self._get_item_view(): + if item.get_title().lower() == line: + return item + + @run_in_event_loop + def do_stop(self, line): + """stop -- Stops download by name.""" + if self.selection_type is None: + print "Error: No feed/playlist selected." + return + item = self._find_item(line) + if item is None: + print "No item named %r" % line + return + if item.get_state() in ('downloading', 'paused'): + item.expire() + else: + print '%s is not being downloaded' % item.get_title() + + @run_in_event_loop + def complete_stop(self, text, line, begidx, endidx): + return self.handle_item_complete(text, self._get_item_view(), + lambda i: i.get_state() in ('downloading', 'paused')) + + @run_in_event_loop + def do_download(self, line): + """download -- Downloads an item by name in the feed/playlist + selected. + """ + if self.selection_type is None: + print "Error: No feed/playlist selected." + return + item = self._find_item(line) + if item is None: + print "No item named %r" % line + return + if item.get_state() == 'downloading': + print '%s is currently being downloaded' % item.get_title() + elif item.is_downloaded(): + print '%s is already downloaded' % item.get_title() + else: + item.download() + + @run_in_event_loop + def complete_download(self, text, line, begidx, endidx): + return self.handle_item_complete(text, self._get_item_view(), + lambda i: i.is_downloadable()) + + @run_in_event_loop + def do_pause(self, line): + """pause -- Pauses a download by name.""" + if self.selection_type is None: + print "Error: No feed/playlist selected." + return + item = self._find_item(line) + if item is None: + print "No item named %r" % line + return + if item.get_state() == 'downloading': + item.pause() + else: + print '%s is not being downloaded' % item.get_title() + + @run_in_event_loop + def complete_pause(self, text, line, begidx, endidx): + return self.handle_item_complete(text, self._get_item_view(), + lambda i: i.get_state() == 'downloading') + + @run_in_event_loop + def do_resume(self, line): + """resume -- Resumes a download by name.""" + if self.selection_type is None: + print "Error: No feed/playlist selected." + return + item = self._find_item(line) + if item is None: + print "No item named %r" % line + return + if item.get_state() == 'paused': + item.resume() + else: + print '%s is not a paused download' % item.get_title() + + @run_in_event_loop + def complete_resume(self, text, line, begidx, endidx): + return self.handle_item_complete(text, self._get_item_view(), + lambda i: i.get_state() == 'paused') + + @run_in_event_loop + def do_rm(self, line): + """rm -- Removes an item by name in the feed/playlist selected. + """ + if self.selection_type is None: + print "Error: No feed/playlist selected." + return + item = self._find_item(line) + if item is None: + print "No item named %r" % line + return + if item.is_downloaded(): + item.expire() + else: + print '%s is not downloaded' % item.get_title() + + @run_in_event_loop + def complete_rm(self, text, line, begidx, endidx): + return self.handle_item_complete(text, self._get_item_view(), + lambda i: i.is_downloaded()) + + @run_in_event_loop + def do_testdialog(self, line): + """testdialog -- Tests the cli dialog system.""" + d = dialogs.ChoiceDialog("Hello", "I am a test dialog", + dialogs.BUTTON_OK, dialogs.BUTTON_CANCEL) + def callback(dialog): + print "TEST CHOICE: %s" % dialog.choice + d.run(callback) + + ####################################################################### + # + # Start of mirobridge specific routines - Part 2 + # + ####################################################################### +@eventloop.idle_iterator +def clear_icon_cache_orphans(): + # delete icon_cache rows from the database with no associated + # item/feed/guide. + removed_objs = [] + for ic in iconcache.IconCache.orphaned_view(): + logging.warn("No object for IconCache: %s. Discarding", ic) + ic.remove() + removed_objs.append(str(ic.url)) + if removed_objs: + databaselog.info("Removed IconCache objects without an associated " + "db object: %s", ','.join(removed_objs)) + yield None + + # delete files in the icon cache directory that don't belong to IconCache + # objects. + + cachedir = fileutil.expand_filename(app.config.get( + prefs.ICON_CACHE_DIRECTORY)) + if not os.path.isdir(cachedir): + return + + existingFiles = [os.path.normcase(os.path.join(cachedir, f)) + for f in os.listdir(cachedir)] + yield None + + knownIcons = iconcache.IconCache.all_filenames() + yield None + + knownIcons = [ os.path.normcase(fileutil.expand_filename(path)) for path in + knownIcons] + yield None + + for filename in existingFiles: + if (os.path.exists(filename) + and os.path.basename(filename)[0] != '.' + and os.path.basename(filename) != 'extracted' + and not filename in knownIcons): + try: + os.remove(filename) + except OSError: + pass + yield None + ##################################################################### + # + # End of mirobridge specific routines - Part 2 + # + #####################################################################