~ubuntu-branches/ubuntu/natty/miro/natty

« back to all changes in this revision

Viewing changes to lib/startup.py

  • Committer: Bazaar Package Importer
  • Author(s): Bryce Harrington
  • Date: 2011-01-22 02:46:33 UTC
  • mfrom: (1.4.10 upstream) (1.7.5 experimental)
  • Revision ID: james.westby@ubuntu.com-20110122024633-kjme8u93y2il5nmf
Tags: 3.5.1-1ubuntu1
* Merge from debian.  Remaining ubuntu changes:
  - Use python 2.7 instead of python 2.6
  - Relax dependency on python-dbus to >= 0.83.0

Show diffs side-by-side

added added

removed removed

Lines of Context:
 
1
# Miro - an RSS based video player application
 
2
# Copyright (C) 2005-2010 Participatory Culture Foundation
 
3
#
 
4
# This program is free software; you can redistribute it and/or modify
 
5
# it under the terms of the GNU General Public License as published by
 
6
# the Free Software Foundation; either version 2 of the License, or
 
7
# (at your option) any later version.
 
8
#
 
9
# This program is distributed in the hope that it will be useful,
 
10
# but WITHOUT ANY WARRANTY; without even the implied warranty of
 
11
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 
12
# GNU General Public License for more details.
 
13
#
 
14
# You should have received a copy of the GNU General Public License
 
15
# along with this program; if not, write to the Free Software
 
16
# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA  02110-1301 USA
 
17
#
 
18
# In addition, as a special exception, the copyright holders give
 
19
# permission to link the code of portions of this program with the OpenSSL
 
20
# library.
 
21
#
 
22
# You must obey the GNU General Public License in all respects for all of
 
23
# the code used other than OpenSSL. If you modify file(s) with this
 
24
# exception, you may extend this exception to your version of the file(s),
 
25
# but you are not obligated to do so. If you do not wish to do so, delete
 
26
# this exception statement from your version. If you delete this exception
 
27
# statement from all source files in the program, then also delete it here.
 
28
 
 
29
"""Startup code.
 
30
 
 
31
In general, frontends should do the following to handle startup.
 
32
FIXME
 
33
    - (optional) call startup.install_movies_gone_handler()
 
34
    - Call startup.initialize()
 
35
    - Wait for either the 'startup-success', or 'startup-failure' signal
 
36
"""
 
37
 
 
38
from miro.gtcache import gettext as _
 
39
import logging
 
40
import os
 
41
import traceback
 
42
import threading
 
43
import time
 
44
 
 
45
from miro.clock import clock
 
46
from miro import app
 
47
from miro import autodler
 
48
from miro import autoupdate
 
49
from miro import config
 
50
from miro import commandline
 
51
from miro import crashreport
 
52
from miro import controller
 
53
from miro import database
 
54
from miro import databaselog
 
55
from miro import databaseupgrade
 
56
from miro import dialogs
 
57
from miro import downloader
 
58
from miro import eventloop
 
59
from miro import fileutil
 
60
from miro import guide
 
61
from miro import httpauth
 
62
from miro import httpclient
 
63
from miro import iconcache
 
64
from miro import item
 
65
from miro import feed
 
66
from miro import folder
 
67
from miro import messages
 
68
from miro import messagehandler
 
69
from miro import models
 
70
from miro import moviedata
 
71
from miro import playlist
 
72
from miro import prefs
 
73
from miro.plat.utils import setup_logging
 
74
from miro.plat import config as platformcfg
 
75
from miro import tabs
 
76
from miro import theme
 
77
from miro import util
 
78
from miro import searchengines
 
79
from miro import storedatabase
 
80
from miro import videoconversion
 
81
 
 
82
DEBUG_DB_MEM_USAGE = False
 
83
mem_usage_test_event = threading.Event()
 
84
 
 
85
class StartupError(Exception):
 
86
    def __init__(self, summary, description):
 
87
        self.summary = summary
 
88
        self.description = description
 
89
 
 
90
def startup_function(func):
 
91
    """Decorator for startup functions.  This decorator catches exceptions and
 
92
    turns them into StartupFailure messages.
 
93
    """
 
94
    def wrapped(*args, **kwargs):
 
95
        try:
 
96
            func(*args, **kwargs)
 
97
        except StartupError, e:
 
98
            if e.summary is not None:
 
99
                m = messages.StartupFailure(e.summary, e.description)
 
100
            else:
 
101
                m = messages.FrontendQuit()
 
102
            m.send_to_frontend()
 
103
        except (SystemExit, KeyboardInterrupt):
 
104
            raise
 
105
        except Exception, exc:
 
106
            # we do this so that we only kick up the database error
 
107
            # if it's a database-related exception AND the app has a
 
108
            # db attribute
 
109
            if ((isinstance(exc, (database.DatabaseException,
 
110
                                  database.DatabaseStandardError,
 
111
                                  storedatabase.sqlite3.OperationalError))
 
112
                 and app.db is not None)):
 
113
 
 
114
                # somewhere in one of the startup functions, Miro
 
115
                # kicked up a database-related problem.  we don't know
 
116
                # where it happend, so we can't just start fresh and
 
117
                # keep going.  instead we have to start fresh, shut
 
118
                # miro down, and on the next run, maybe miro will
 
119
                # work.
 
120
                msg = exc.message
 
121
                if not msg:
 
122
                    msg = str(exc)
 
123
                logging.exception("Database error on startup:")
 
124
                m = messages.StartupDatabaseFailure(
 
125
                    _("Database Error"),
 
126
                    _("We're sorry, %(appname)s was unable to start up due "
 
127
                      "to a problem with the database:\n\n"
 
128
                      "Error: %(error)s\n\n"
 
129
                      "It's possible that your database file is corrupted and "
 
130
                      "cannot be used.\n\n"
 
131
                      "You can start fresh and your damaged database will be "
 
132
                      "removed, but you will have to re-add your feeds and "
 
133
                      "media files.  If you want to do this, press the "
 
134
                      "Start Fresh button and restart %(appname)s.\n\n"
 
135
                      "To help us fix problems like this in the future, "
 
136
                      "please file a bug report at %(url)s.",
 
137
                      {"appname": config.get(prefs.SHORT_APP_NAME),
 
138
                       "url": config.get(prefs.BUG_REPORT_URL),
 
139
                       "error": msg}
 
140
                      ))
 
141
                m.send_to_frontend()
 
142
 
 
143
            else:
 
144
                logging.warn(
 
145
                    "Unknown startup error: %s", traceback.format_exc())
 
146
                m = messages.StartupFailure(
 
147
                    _("Unknown Error"),
 
148
                    _("An unknown error prevented %(appname)s from startup.  "
 
149
                      "Please file a bug report at %(url)s.",
 
150
                      {"appname": config.get(prefs.SHORT_APP_NAME),
 
151
                       "url": config.get(prefs.BUG_REPORT_URL)}
 
152
                      ))
 
153
                m.send_to_frontend()
 
154
    return wrapped
 
155
 
 
156
def _movies_directory_gone_handler(callback):
 
157
    """Default _movies_directory_gone_handler.  The frontend should
 
158
    override this using the ``install_movies_directory_gone_handler``
 
159
    function.
 
160
    """
 
161
    logging.error("Movies directory is gone -- no handler installed!")
 
162
    eventloop.add_urgent_call(callback, "continuing startup")
 
163
 
 
164
def install_movies_directory_gone_handler(callback):
 
165
    global _movies_directory_gone_handler
 
166
    _movies_directory_gone_handler = callback
 
167
 
 
168
def _first_time_handler(callback):
 
169
    """Default _first_time_handler.  The frontend should override this
 
170
    using the ``install_first_time_handler`` function.
 
171
    """
 
172
    logging.error("First time -- no handler installed.")
 
173
    eventloop.add_urgent_call(callback, "continuing startup")
 
174
 
 
175
def install_first_time_handler(callback):
 
176
    global _first_time_handler
 
177
    _first_time_handler = callback
 
178
 
 
179
def setup_global_feed(url, *args, **kwargs):
 
180
    view = feed.Feed.make_view('origURL=?', (url,))
 
181
    view_count = view.count()
 
182
    if view_count == 0:
 
183
        logging.info("Spawning global feed %s", url)
 
184
        feed.Feed(url, *args, **kwargs)
 
185
    elif view_count > 1:
 
186
        allFeeds = [f for f in view]
 
187
        for extra in allFeeds[1:]:
 
188
            extra.remove()
 
189
        raise StartupError("Database inconsistent",
 
190
                "Too many db objects for %s" % url)
 
191
 
 
192
def initialize(themeName):
 
193
    """Initialize Miro.  This sets up things like logging and the config
 
194
    system and should be called as early as possible.
 
195
    """
 
196
    # this is platform specific
 
197
    setup_logging()
 
198
    # this is portable general
 
199
    util.setup_logging()
 
200
    app.controller = controller.Controller()
 
201
    config.load(themeName)
 
202
 
 
203
def startup():
 
204
    """Startup Miro.
 
205
 
 
206
    This method starts up the eventloop and schedules the rest of the startup
 
207
    to run in the event loop.
 
208
 
 
209
    Frontends should call this method, then wait for 1 of 2 messages
 
210
 
 
211
    StartupSuccess is sent once the startup is done and the backend is ready
 
212
    to go.
 
213
 
 
214
    StartupFailure is sent if something bad happened.
 
215
 
 
216
    initialize() must be called before startup().
 
217
    """
 
218
    logging.info("Starting up %s", config.get(prefs.LONG_APP_NAME))
 
219
    logging.info("Version:    %s", config.get(prefs.APP_VERSION))
 
220
    logging.info("Revision:   %s", config.get(prefs.APP_REVISION))
 
221
    logging.info("Builder:    %s", config.get(prefs.BUILD_MACHINE))
 
222
    logging.info("Build Time: %s", config.get(prefs.BUILD_TIME))
 
223
    eventloop.connect('thread-started', finish_startup)
 
224
    logging.info("Reading HTTP Password list")
 
225
    httpauth.init()
 
226
    httpauth.restore_from_file()
 
227
    logging.info("Starting libCURL thread")
 
228
    httpclient.init_libcurl()
 
229
    httpclient.start_thread()
 
230
    logging.info("Starting event loop thread")
 
231
    eventloop.startup()
 
232
    if DEBUG_DB_MEM_USAGE:
 
233
        mem_usage_test_event.wait()
 
234
 
 
235
@startup_function
 
236
def finish_startup(obj, thread):
 
237
    database.set_thread(thread)
 
238
    logging.info("Restoring database...")
 
239
    start = time.time()
 
240
    app.db = storedatabase.LiveStorage()
 
241
    try:
 
242
        app.db.upgrade_database()
 
243
    except databaseupgrade.DatabaseTooNewError:
 
244
        summary = _("Database too new")
 
245
        description = _(
 
246
            "You have a database that was saved with a newer version of "
 
247
            "%(appname)s. You must download the latest version of "
 
248
            "%(appname)s and run that.",
 
249
            {"appname": config.get(prefs.SHORT_APP_NAME)},
 
250
        )
 
251
        raise StartupError(summary, description)
 
252
    except storedatabase.UpgradeErrorSendCrashReport, e:
 
253
        send_startup_crash_report(e.report)
 
254
        return
 
255
    except storedatabase.UpgradeError:
 
256
        raise StartupError(None, None)
 
257
    database.initialize()
 
258
    end = time.time()
 
259
    logging.timing("Database upgrade time: %.3f", end - start)
 
260
    if app.db.startup_version != app.db.current_version:
 
261
        databaselog.info("Upgraded database from version %s to %s",
 
262
                app.db.startup_version, app.db.current_version)
 
263
    databaselog.print_old_log_entries()
 
264
    models.initialize()
 
265
    if DEBUG_DB_MEM_USAGE:
 
266
        util.db_mem_usage_test()
 
267
        mem_usage_test_event.set()
 
268
 
 
269
    logging.info("Loading video converters...")
 
270
    videoconversion.conversion_manager.startup()
 
271
    searchengines.create_engines()
 
272
    setup_global_feeds()
 
273
    # call fix_database_inconsistencies() ASAP after the manual feed is set up
 
274
    fix_database_inconsistencies()
 
275
    logging.info("setup tabs...")
 
276
    setup_tabs()
 
277
    logging.info("setup theme...")
 
278
    setup_theme()
 
279
    install_message_handler()
 
280
    downloader.init_controller()
 
281
 
 
282
    eventloop.add_urgent_call(check_firsttime, "check first time")
 
283
 
 
284
def fix_database_inconsistencies():
 
285
    item.fix_non_container_parents()
 
286
    item.move_orphaned_items()
 
287
    playlist.fix_missing_item_ids()
 
288
    folder.fix_playlist_missing_item_ids()
 
289
 
 
290
@startup_function
 
291
def check_firsttime():
 
292
    """Run the first time wizard if need be.
 
293
    """
 
294
    callback = lambda: eventloop.add_urgent_call(check_movies_gone, "check movies gone")
 
295
    if is_first_time():
 
296
        logging.info("First time -- calling handler.")
 
297
        _first_time_handler(callback)
 
298
        return
 
299
 
 
300
    eventloop.add_urgent_call(check_movies_gone, "check movies gone")
 
301
 
 
302
@startup_function
 
303
def check_movies_gone():
 
304
    """Checks to see if the movies directory is gone.
 
305
    """
 
306
    callback = lambda: eventloop.add_urgent_call(fix_movies_gone,
 
307
                                               "fix movies gone")
 
308
 
 
309
    if is_movies_directory_gone():
 
310
        logging.info("Movies directory is gone -- calling handler.")
 
311
        _movies_directory_gone_handler(callback)
 
312
        return
 
313
 
 
314
    movies_dir = fileutil.expand_filename(config.get(prefs.MOVIES_DIRECTORY))
 
315
    # if the directory doesn't exist, create it.
 
316
    if not os.path.exists(movies_dir):
 
317
        try:
 
318
            os.makedirs(movies_dir)
 
319
        except OSError:
 
320
            logging.info("Movies directory can't be created -- calling handler")
 
321
            # FIXME - this isn't technically correct, but it's probably
 
322
            # close enough that a user can fix the issue and Miro can
 
323
            # run happily.
 
324
            _movies_directory_gone_handler(callback)
 
325
            return
 
326
 
 
327
    # make sure the directory is writeable
 
328
    try:
 
329
        fn = os.path.join(movies_dir, "testfile")
 
330
        f = open(fn, "w")
 
331
        f.write("test")
 
332
        f.close()
 
333
        os.remove(fn)
 
334
    except IOError:
 
335
        _movies_directory_gone_handler(callback)
 
336
        return
 
337
    eventloop.add_urgent_call(finish_backend_startup, "reconnect downloaders")
 
338
 
 
339
@startup_function
 
340
def fix_movies_gone():
 
341
    config.set(prefs.MOVIES_DIRECTORY, platformcfg.get(prefs.MOVIES_DIRECTORY))
 
342
    eventloop.add_urgent_call(finish_backend_startup, "reconnect downloaders")
 
343
 
 
344
@startup_function
 
345
def finish_backend_startup():
 
346
    """Last bit of startup required before we load the frontend.  """
 
347
    # Uncomment the next line to test startup error handling
 
348
    # raise StartupError("Test Error", "Startup Failed")
 
349
    reconnect_downloaders()
 
350
    guide.download_guides()
 
351
    feed.remove_orphaned_feed_impls()
 
352
    messages.StartupSuccess().send_to_frontend()
 
353
 
 
354
@eventloop.idle_iterator
 
355
def on_frontend_started():
 
356
    """Perform startup actions that should happen after the frontend is
 
357
    already up and running.
 
358
    """
 
359
    logging.info("Starting auto downloader...")
 
360
    autodler.start_downloader()
 
361
    yield None
 
362
    feed.expire_items()
 
363
    yield None
 
364
    logging.info("Starting movie data updates")
 
365
    item.update_incomplete_movie_data()
 
366
    yield None
 
367
    moviedata.movie_data_updater.start_thread()
 
368
    yield None
 
369
    commandline.startup()
 
370
    yield None
 
371
    autoupdate.check_for_updates()
 
372
    yield None
 
373
    # Wait a bit before starting the downloader daemon.  It can cause a bunch
 
374
    # of disk/CPU load, so try to avoid it slowing other stuff down.
 
375
    eventloop.add_timeout(5, downloader.startup_downloader,
 
376
            "start downloader daemon")
 
377
    # ditto for feed updates
 
378
    eventloop.add_timeout(30, feed.start_updates, "start feed updates")
 
379
    # ditto for clearing stale icon cache files, except it's the very lowest
 
380
    # priority
 
381
    eventloop.add_timeout(10, clear_icon_cache_orphans, "clear orphans")
 
382
 
 
383
def setup_global_feeds():
 
384
    setup_global_feed(u'dtv:manualFeed', initiallyAutoDownloadable=False)
 
385
    setup_global_feed(u'dtv:singleFeed', initiallyAutoDownloadable=False)
 
386
    setup_global_feed(u'dtv:search', initiallyAutoDownloadable=False)
 
387
    setup_global_feed(u'dtv:searchDownloads')
 
388
    setup_global_feed(u'dtv:directoryfeed')
 
389
 
 
390
def setup_tabs():
 
391
    def setup_tab_order(type):
 
392
        current_tab_orders = list(tabs.TabOrder.view_for_type(type))
 
393
        if len(current_tab_orders) == 0:
 
394
            logging.info("Creating %s tab order" % type)
 
395
            tab_order = tabs.TabOrder(type)
 
396
        else:
 
397
            current_tab_orders[0].restore_tab_list()
 
398
    setup_tab_order(u'site')
 
399
    setup_tab_order(u'channel')
 
400
    setup_tab_order(u'audio-channel')
 
401
    setup_tab_order(u'playlist')
 
402
 
 
403
def is_first_time():
 
404
    """Checks to see if this is the first time that Miro has been run.
 
405
    This is to do any first-time setup, show the user the first-time
 
406
    wizard, ...
 
407
 
 
408
    Returns True if yes, False if no.
 
409
    """
 
410
    return not config.get(prefs.STARTUP_TASKS_DONE)
 
411
 
 
412
def mark_first_time():
 
413
    config.set(prefs.STARTUP_TASKS_DONE, True)
 
414
 
 
415
def is_movies_directory_gone():
 
416
    """Checks to see if the MOVIES_DIRECTORY exists.
 
417
 
 
418
    Returns True if yes, False if no.
 
419
    """
 
420
    movies_dir = fileutil.expand_filename(config.get(prefs.MOVIES_DIRECTORY))
 
421
    if not movies_dir.endswith(os.path.sep):
 
422
        movies_dir += os.path.sep
 
423
    logging.info("Checking movies directory %r...", movies_dir)
 
424
    try:
 
425
        if os.path.exists(movies_dir):
 
426
            contents = os.listdir(movies_dir)
 
427
            if contents:
 
428
                # there's something inside the directory consider it
 
429
                # present (even if all our items are missing).
 
430
                return False
 
431
 
 
432
    except OSError:
 
433
        # we can't access the directory--treat it as if it's gone.
 
434
        logging.info("Can't access directory.")
 
435
        return True
 
436
 
 
437
    # at this point either there's no movies_dir or there is an empty
 
438
    # movies_dir.  we check to see if we think something is downloaded.
 
439
    for downloader_ in downloader.RemoteDownloader.make_view():
 
440
        if ((downloader_.is_finished()
 
441
             and downloader_.get_filename().startswith(movies_dir))):
 
442
            # we think something is downloaded, so it seems like the
 
443
            # movies directory is gone.
 
444
            logging.info("Directory there, but missing files.")
 
445
            return True
 
446
 
 
447
    # we have no content, so everything's fine.
 
448
    return False
 
449
 
 
450
def setup_theme():
 
451
    themeHistory = _get_theme_history()
 
452
    themeHistory.check_new_theme()
 
453
 
 
454
def install_message_handler():
 
455
    handler = messagehandler.BackendMessageHandler(on_frontend_started)
 
456
    messages.BackendMessage.install_handler(handler)
 
457
 
 
458
def _get_theme_history():
 
459
    current_themes = list(theme.ThemeHistory.make_view())
 
460
    if len(current_themes) > 0:
 
461
        return current_themes[0]
 
462
    else:
 
463
        return theme.ThemeHistory()
 
464
 
 
465
@eventloop.idle_iterator
 
466
def clear_icon_cache_orphans():
 
467
    # delete icon_cache rows from the database with no associated
 
468
    # item/feed/guide.
 
469
    removed_objs = []
 
470
    for ic in iconcache.IconCache.orphaned_view():
 
471
        logging.warn("No object for IconCache: %s.  Discarding", ic)
 
472
        ic.remove()
 
473
        removed_objs.append(str(ic.url))
 
474
    if removed_objs:
 
475
        databaselog.info("Removed IconCache objects without an associated "
 
476
                "db object: %s", ','.join(removed_objs))
 
477
    yield None
 
478
 
 
479
    # delete files in the icon cache directory that don't belong to IconCache
 
480
    # objects.
 
481
 
 
482
    cachedir = fileutil.expand_filename(config.get(prefs.ICON_CACHE_DIRECTORY))
 
483
    if not os.path.isdir(cachedir):
 
484
        return
 
485
 
 
486
    existingFiles = [os.path.normcase(os.path.join(cachedir, f))
 
487
            for f in os.listdir(cachedir)]
 
488
    yield None
 
489
 
 
490
    knownIcons = iconcache.IconCache.all_filenames()
 
491
    yield None
 
492
 
 
493
    knownIcons = [ os.path.normcase(fileutil.expand_filename(path)) for path in
 
494
            knownIcons]
 
495
    yield None
 
496
 
 
497
    for filename in existingFiles:
 
498
        if (os.path.exists(filename)
 
499
                and os.path.basename(filename)[0] != '.'
 
500
                and os.path.basename(filename) != 'extracted'
 
501
                and not filename in knownIcons):
 
502
            try:
 
503
                os.remove(filename)
 
504
            except OSError:
 
505
                pass
 
506
        yield None
 
507
 
 
508
def send_startup_crash_report(report):
 
509
    logging.info("Startup failed, waiting to send crash report")
 
510
    title = _("Submitting Crash Report")
 
511
    description = _(
 
512
        "%(appname)s will now submit a crash report to our crash "
 
513
        "database\n\n"
 
514
        "Do you want to include entire program database "
 
515
        "including all video and feed metadata with crash report? "
 
516
        "This will help us diagnose the issue.",
 
517
        {"appname": config.get(prefs.SHORT_APP_NAME)})
 
518
    d = dialogs.ChoiceDialog(title, description,
 
519
            dialogs.BUTTON_INCLUDE_DATABASE,
 
520
            dialogs.BUTTON_DONT_INCLUDE_DATABASE)
 
521
    choice = d.run_blocking()
 
522
    send_database = (choice == dialogs.BUTTON_INCLUDE_DATABASE)
 
523
    app.controller.send_bug_report(report, '', send_database, quit_after=True)
 
524
 
 
525
def reconnect_downloaders():
 
526
    for downloader_ in downloader.RemoteDownloader.orphaned_view():
 
527
        logging.warn("removing orphaned downloader: %s", downloader_.url)
 
528
        downloader_.remove()
 
529
    manualItems = item.Item.feed_view(feed.Feed.get_manual_feed().get_id())
 
530
    for item_ in manualItems:
 
531
        if item_.downloader is None and item_.__class__ == item.Item:
 
532
            logging.warn("removing cancelled external torrent: %s", item_)
 
533
            item_.remove()