From 4d3ade20c704288d8c13d321651b37b866b10a95 Mon Sep 17 00:00:00 2001 From: Raoul Snyman Date: Sat, 10 Oct 2020 23:41:03 -0700 Subject: [PATCH] Add support for folders to plugins - Make a derivative MediaManagerItem class for generic folder support - Make add and choose folder dialogs based on image plugin dialogs - Implement folder and item mixins to get db models "for free" - Implement database layer for media plugin - Implement database layer for presentations plugins - Refactor media plugin to inherit from FolderLibraryItem - Refactor presentations plugin to inherit from FolderLibraryItem - Migrate media files from settings to database - Migrate presentations files from settings to database - Convert the load icons in the media plugin into a dropdown - Add new tests for MediaMediaItem.get_list() - Closes #165 - Closes #223 - Closes #224 - Closes #582 --- openlp/core/common/i18n.py | 2 + openlp/core/common/settings.py | 10 + openlp/core/lib/db.py | 58 ++- openlp/core/lib/mediamanageritem.py | 36 +- openlp/core/projectors/db.py | 15 +- openlp/core/ui/folders.py | 302 +++++++++++++ openlp/core/ui/icons.py | 1 + openlp/core/ui/library.py | 387 ++++++++++++++++ openlp/core/ui/servicemanager.py | 2 + openlp/plugins/bibles/lib/manager.py | 8 +- openlp/plugins/images/lib/mediaitem.py | 2 +- openlp/plugins/media/lib/db.py | 46 ++ openlp/plugins/media/lib/mediaitem.py | 414 ++++++++++-------- openlp/plugins/media/mediaplugin.py | 41 +- openlp/plugins/presentations/lib/db.py | 46 ++ openlp/plugins/presentations/lib/mediaitem.py | 211 ++++----- .../presentations/presentationplugin.py | 52 +-- tests/openlp_plugins/bibles/test_manager.py | 6 +- tests/openlp_plugins/media/test_mediaitem.py | 106 ++++- .../openlp_plugins/presentations/conftest.py | 7 +- .../presentations/test_mediaitem.py | 44 +- 21 files changed, 1378 insertions(+), 418 deletions(-) create mode 100644 openlp/core/ui/folders.py create mode 100644 openlp/core/ui/library.py create mode 100644 openlp/plugins/media/lib/db.py create mode 100644 openlp/plugins/presentations/lib/db.py diff --git a/openlp/core/common/i18n.py b/openlp/core/common/i18n.py index ad4529607..e377995ed 100644 --- a/openlp/core/common/i18n.py +++ b/openlp/core/common/i18n.py @@ -336,6 +336,8 @@ class UiStrings(metaclass=Singleton): """ self.About = translate('OpenLP.Ui', 'About') self.Add = translate('OpenLP.Ui', '&Add') + self.AddFolder = translate('OpenLP.Ui', 'Add folder') + self.AddFolderDot = translate('OpenLP.Ui', 'Add folder.') self.AddGroup = translate('OpenLP.Ui', 'Add group') self.AddGroupDot = translate('OpenLP.Ui', 'Add group.') self.Advanced = translate('OpenLP.Ui', 'Advanced') diff --git a/openlp/core/common/settings.py b/openlp/core/common/settings.py index 31193c851..a12a49ee1 100644 --- a/openlp/core/common/settings.py +++ b/openlp/core/common/settings.py @@ -278,6 +278,11 @@ class Settings(QtCore.QSettings): 'media/vlc arguments': '', 'media/live volume': 50, 'media/preview volume': 0, + 'media/db type': 'sqlite', + 'media/db username': '', + 'media/db password': '', + 'media/db hostname': '', + 'media/db database': '', 'players/background color': '#000000', 'planningcenter/status': PluginStatus.Inactive, 'planningcenter/application_id': '', @@ -296,6 +301,11 @@ class Settings(QtCore.QSettings): 'presentations/powerpoint control window': QtCore.Qt.Unchecked, 'presentations/impress use display setting': QtCore.Qt.Unchecked, 'presentations/last directory': None, + 'presentations/db type': 'sqlite', + 'presentations/db username': '', + 'presentations/db password': '', + 'presentations/db hostname': '', + 'presentations/db database': '', 'servicemanager/last directory': None, 'servicemanager/last file': None, 'servicemanager/service theme': None, diff --git a/openlp/core/lib/db.py b/openlp/core/lib/db.py index a4df3aa61..d1a392d52 100644 --- a/openlp/core/lib/db.py +++ b/openlp/core/lib/db.py @@ -30,10 +30,11 @@ from urllib.parse import quote_plus as urlquote from alembic.migration import MigrationContext from alembic.operations import Operations -from sqlalchemy import Column, MetaData, Table, UnicodeText, create_engine, types -from sqlalchemy.engine.url import make_url, URL +from sqlalchemy import Column, ForeignKey, Integer, MetaData, Table, Unicode, UnicodeText, create_engine, types +from sqlalchemy.engine.url import URL, make_url from sqlalchemy.exc import DBAPIError, InvalidRequestError, OperationalError, ProgrammingError, SQLAlchemyError -from sqlalchemy.orm import mapper, scoped_session, sessionmaker +from sqlalchemy.ext.declarative import declared_attr +from sqlalchemy.orm import backref, mapper, relationship, scoped_session, sessionmaker from sqlalchemy.pool import NullPool from openlp.core.common import delete_file @@ -43,7 +44,6 @@ from openlp.core.common.json import OpenLPJSONDecoder, OpenLPJSONEncoder from openlp.core.common.registry import Registry from openlp.core.lib.ui import critical_error_message_box - log = logging.getLogger(__name__) @@ -224,6 +224,49 @@ def get_upgrade_op(session): return Operations(context) +class CommonMixin(object): + """ + Base class to automate table name and ID column. + """ + @declared_attr + def __tablename__(self): + return self.__name__.lower() + + id = Column(Integer, primary_key=True) + + +class FolderMixin(CommonMixin): + """ + A mixin to provide most of the fields needed for folder support + """ + name = Column(Unicode(255), nullable=False, index=True) + + @declared_attr + def parent_id(self): + return Column(Integer(), ForeignKey('folder.id')) + + @declared_attr + def folders(self): + return relationship('Folder', backref=backref('parent', remote_side='Folder.id'), order_by='Folder.name') + + @declared_attr + def items(self): + return relationship('Item', backref='folder', order_by='Item.name') + + +class ItemMixin(CommonMixin): + """ + A mixin to provide most of the fields needed for folder support + """ + name = Column(Unicode(255), nullable=False, index=True) + file_path = Column(Unicode(255)) + file_hash = Column(Unicode(255)) + + @declared_attr + def folder_id(self): + return Column(Integer(), ForeignKey('folder.id')) + + class BaseModel(object): """ BaseModel provides a base object with a set of generic functions @@ -494,16 +537,19 @@ class Manager(object): if try_count >= 2: raise - def get_object_filtered(self, object_class, filter_clause): + def get_object_filtered(self, object_class, *filter_clauses): """ Returns an object matching specified criteria :param object_class: The type of object to return :param filter_clause: The criteria to select the object by """ + query = self.session.query(object_class) + for filter_clause in filter_clauses: + query = query.filter(filter_clause) for try_count in range(3): try: - return self.session.query(object_class).filter(filter_clause).first() + return query.first() except OperationalError as oe: # This exception clause is for users running MySQL which likes to terminate connections on its own # without telling anyone. See bug #927473. However, other dbms can raise it, usually in a diff --git a/openlp/core/lib/mediamanageritem.py b/openlp/core/lib/mediamanageritem.py index 6074e71d6..57be566f7 100644 --- a/openlp/core/lib/mediamanageritem.py +++ b/openlp/core/lib/mediamanageritem.py @@ -21,13 +21,12 @@ """ Provides the generic functions for interfacing plugins with the Media Manager. """ -import logging import re from PyQt5 import QtCore, QtWidgets from openlp.core.common.i18n import UiStrings, translate -from openlp.core.common.mixins import RegistryProperties +from openlp.core.common.mixins import LogMixin, RegistryProperties from openlp.core.common.registry import Registry from openlp.core.lib import ServiceItemContext from openlp.core.lib.plugin import StringContent @@ -39,10 +38,8 @@ from openlp.core.widgets.edits import SearchEdit from openlp.core.widgets.toolbar import OpenLPToolbar from openlp.core.widgets.views import ListWidgetWithDnD -log = logging.getLogger(__name__) - -class MediaManagerItem(QtWidgets.QWidget, RegistryProperties): +class MediaManagerItem(QtWidgets.QWidget, RegistryProperties, LogMixin): """ MediaManagerItem is a helper widget for plugins. @@ -75,7 +72,6 @@ class MediaManagerItem(QtWidgets.QWidget, RegistryProperties): that is performed automatically by OpenLP when necessary. If this method is not defined, a default will be used (treat the filename as an image). """ - log.info('Media Item loaded') def __init__(self, parent=None, plugin=None): """ @@ -212,12 +208,16 @@ class MediaManagerItem(QtWidgets.QWidget, RegistryProperties): self.add_actionlist_to_toolbar(toolbar_actions) def add_actionlist_to_toolbar(self, toolbar_actions): - for action in toolbar_actions: - self.toolbar.add_toolbar_action('{name}{action}Action'.format(name=self.plugin.name, action=action[0]), - text=self.plugin.get_string(action[1])['title'], - icon=action[2], - tooltip=self.plugin.get_string(action[1])['tooltip'], - triggers=action[3]) + added_actions = [] + for action, plugin, icon, triggers in toolbar_actions: + added_actions.append(self.toolbar.add_toolbar_action( + '{name}{action}Action'.format(name=self.plugin.name, action=action), + text=self.plugin.get_string(plugin)['title'], + icon=icon, + tooltip=self.plugin.get_string(plugin)['tooltip'], + triggers=triggers + )) + return added_actions def add_list_view_to_toolbar(self): """ @@ -350,7 +350,7 @@ class MediaManagerItem(QtWidgets.QWidget, RegistryProperties): self, self.on_new_prompt, self.settings.value(self.settings_section + '/last directory'), self.on_new_file_masks) - log.info('New file(s) {file_paths}'.format(file_paths=file_paths)) + self.log_info('New file(s) {file_paths}'.format(file_paths=file_paths)) if file_paths: self.application.set_busy_cursor() self.validate_and_load(file_paths) @@ -441,7 +441,7 @@ class MediaManagerItem(QtWidgets.QWidget, RegistryProperties): file_paths = [] for index in range(self.list_view.count()): list_item = self.list_view.item(index) - file_path = list_item.data(QtCore.Qt.UserRole) + file_path = list_item.data(0, QtCore.Qt.UserRole) file_paths.append(file_path) return file_paths @@ -523,7 +523,7 @@ class MediaManagerItem(QtWidgets.QWidget, RegistryProperties): translate('OpenLP.MediaManagerItem', 'You must select one or more items to preview.')) else: - log.debug('{plug} Preview requested'.format(plug=self.plugin.name)) + self.log_debug('{plug} Preview requested'.format(plug=self.plugin.name)) Registry().set_flag('has doubleclick added item to service', False) service_item = self.build_service_item() if service_item: @@ -558,7 +558,7 @@ class MediaManagerItem(QtWidgets.QWidget, RegistryProperties): :param item_id: item to make live :param remote: From Remote """ - log.debug('%s Live requested', self.plugin.name) + self.log_debug('{plugin} Live requested'.format(plugin=self.plugin.name)) item = None if item_id: item = self.create_item_from_id(item_id) @@ -593,7 +593,7 @@ class MediaManagerItem(QtWidgets.QWidget, RegistryProperties): # Is it possible to process multiple list items to generate # multiple service items? if self.single_service_item: - log.debug('{plugin} Add requested'.format(plugin=self.plugin.name)) + self.log_debug('{plugin} Add requested'.format(plugin=self.plugin.name)) self.add_to_service(replace=self.remote_triggered) else: items = self.list_view.selectedIndexes() @@ -634,7 +634,7 @@ class MediaManagerItem(QtWidgets.QWidget, RegistryProperties): translate('OpenLP.MediaManagerItem', 'You must select one or more items.')) else: - log.debug('{plugin} Add requested'.format(plugin=self.plugin.name)) + self.log_debug('{plugin} Add requested'.format(plugin=self.plugin.name)) service_item = self.service_manager.get_service_item() if not service_item: QtWidgets.QMessageBox.information(self, UiStrings().NISs, diff --git a/openlp/core/projectors/db.py b/openlp/core/projectors/db.py index 20759abf4..54c1f7583 100644 --- a/openlp/core/projectors/db.py +++ b/openlp/core/projectors/db.py @@ -36,10 +36,10 @@ The Projector table keeps track of entries for controlled projectors. import logging from sqlalchemy import Column, ForeignKey, Integer, MetaData, String, and_ -from sqlalchemy.ext.declarative import declarative_base, declared_attr +from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.orm import relationship -from openlp.core.lib.db import Manager, init_db, init_url +from openlp.core.lib.db import CommonMixin, Manager, init_db, init_url from openlp.core.projectors import upgrade from openlp.core.projectors.constants import PJLINK_DEFAULT_CODES @@ -51,17 +51,6 @@ log.debug('projector.lib.db module loaded') Base = declarative_base(MetaData()) -class CommonMixin(object): - """ - Base class to automate table name and ID column. - """ - @declared_attr - def __tablename__(self): - return self.__name__.lower() - - id = Column(Integer, primary_key=True) - - class Manufacturer(Base, CommonMixin): """ Projector manufacturer table. diff --git a/openlp/core/ui/folders.py b/openlp/core/ui/folders.py new file mode 100644 index 000000000..65ca3345e --- /dev/null +++ b/openlp/core/ui/folders.py @@ -0,0 +1,302 @@ +# -*- coding: utf-8 -*- + +########################################################################## +# OpenLP - Open Source Lyrics Projection # +# ---------------------------------------------------------------------- # +# Copyright (c) 2008-2021 OpenLP Developers # +# ---------------------------------------------------------------------- # +# 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 3 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, see . # +########################################################################## + +from PyQt5 import QtCore, QtWidgets + +from openlp.core.common.i18n import get_natural_key, translate +from openlp.core.lib.ui import create_button_box, critical_error_message_box + + +class FolderPopulateMixin(object): + """ + A mixin for common code between the two folder dialogs + """ + def populate_folders(self, parent_id=None, prefix=''): + """ + Recursively add folders to the combobox + + :param folders: A dictionary object of the folders, in a tree. + :param parent_id: The ID of the parent folder. + :param prefix: A string containing the prefix that will be added in front of the folder name for each + level of the tree. + """ + if parent_id is None: + self.folder_combobox.clear() + # I'm not sure what this is here for, I'm just leaving it in for now. + self.folder_combobox.top_level_folder_added = False + folders = self.db_manager.get_all_objects(self.folder_class, self.folder_class.parent_id == parent_id) + folders.sort(key=lambda folder: get_natural_key(folder.name)) + for folder in folders: + self.folder_combobox.addItem(prefix + folder.name, folder) + self.populate_folders(folder.id, prefix + ' ') + + +class AddFolderForm(QtWidgets.QDialog, FolderPopulateMixin): + """ + This class implements the 'Add folder' form for the plugins. + """ + def __init__(self, parent=None, db_manager=None, folder_class=None): + """ + Constructor + """ + super().__init__(parent, QtCore.Qt.WindowSystemMenuHint | QtCore.Qt.WindowTitleHint | + QtCore.Qt.WindowCloseButtonHint) + self.setup_ui() + self.db_manager = db_manager + self.folder_class = folder_class + + def setup_ui(self): + self.setObjectName('AddFolderDialog') + self.resize(300, 10) + self.dialog_layout = QtWidgets.QVBoxLayout(self) + self.dialog_layout.setObjectName('dialog_layout') + self.name_layout = QtWidgets.QFormLayout() + self.name_layout.setObjectName('name_layout') + self.parent_folder_label = QtWidgets.QLabel(self) + self.parent_folder_label.setObjectName('parent_folder_label') + self.folder_combobox = QtWidgets.QComboBox(self) + self.folder_combobox.setObjectName('folder_combobox') + self.name_layout.addRow(self.parent_folder_label, self.folder_combobox) + self.name_label = QtWidgets.QLabel(self) + self.name_label.setObjectName('name_label') + self.name_edit = QtWidgets.QLineEdit(self) + self.name_edit.setObjectName('name_edit') + self.name_label.setBuddy(self.name_edit) + self.name_layout.addRow(self.name_label, self.name_edit) + self.dialog_layout.addLayout(self.name_layout) + self.button_box = create_button_box(self, 'button_box', ['cancel', 'save']) + self.dialog_layout.addWidget(self.button_box) + self.retranslate_ui() + self.setMaximumHeight(self.sizeHint().height()) + + def retranslate_ui(self): + self.setWindowTitle(translate('OpenLP.AddFolderForm', 'Add folder')) + self.parent_folder_label.setText(translate('OpenLP.AddFolderForm', 'Parent folder:')) + self.name_label.setText(translate('OpenLP.AddFolderForm', 'Folder name:')) + + def exec(self, clear=True, show_top_level_folder=False, selected_folder=None): + """ + Show the form. + + :param clear: Set to False if the text input box should not be cleared when showing the dialog (default: True). + :param show_top_level_folder: Set to True when "-- Top level folder --" should be showed as first item + (default: False). + :param selected_folder: The ID of the folder that should be selected by default when showing the dialog. + """ + self.populate_folders() + if clear: + self.name_edit.clear() + self.name_edit.setFocus() + if show_top_level_folder and not self.folder_combobox.top_level_folder_added: + self.folder_combobox.insertItem(0, translate('OpenLP.AddFolderForm', '-- Top-level folder --'), 0) + self.folder_combobox.top_level_folder_added = True + if selected_folder is not None: + for i in range(self.folder_combobox.count()): + if self.folder_combobox.itemData(i) == selected_folder: + self.folder_combobox.setCurrentIndex(i) + return QtWidgets.QDialog.exec(self) + + def accept(self): + """ + Override the accept() method from QDialog to make sure something is entered in the text input box. + """ + if not self.name_edit.text(): + critical_error_message_box(message=translate('OpenLP.AddFolderForm', + 'You need to type in a folder name.')) + self.name_edit.setFocus() + return False + elif self.is_existing_folder(): + critical_error_message_box(message=translate('OpenLP.AddFolderForm', + 'This folder already exists, please use a different name.')) + return False + else: + return QtWidgets.QDialog.accept(self) + + @property + def parent_id(self): + """ + A property to get the parent folder id + """ + if self.folder_combobox.currentIndex() == 0: + return None + return self.folder_combobox.itemData(self.folder_combobox.currentIndex(), QtCore.Qt.UserRole).id + + @property + def folder_name(self): + """ + A property to return the folder name + """ + return self.name_edit.text() + + @property + def new_folder(self): + """ + A Folder object property + """ + return self.folder_class(parent_id=self.parent_id, name=self.folder_name) + + def is_existing_folder(self): + """ + Check if this folder already exists + """ + return self.db_manager.get_object_filtered(self.folder_class, + self.folder_class.parent_id == self.parent_id, + self.folder_class.name == self.folder_name) is not None + + +class ChooseFolderForm(QtWidgets.QDialog, FolderPopulateMixin): + """ + This class implements the 'Choose folder' form for the plugins. + """ + def __init__(self, parent=None, db_manager=None, folder_class=None): + """ + Constructor + """ + super().__init__(parent, QtCore.Qt.WindowSystemMenuHint | QtCore.Qt.WindowTitleHint | + QtCore.Qt.WindowCloseButtonHint) + self.setup_ui() + self.db_manager = db_manager + self.folder_class = folder_class + + def setup_ui(self): + """ + Set up the UI. + """ + self.setObjectName('ChooseFolderForm') + self.resize(399, 119) + self.choose_folder_layout = QtWidgets.QFormLayout(self) + self.choose_folder_layout.setFieldGrowthPolicy(QtWidgets.QFormLayout.ExpandingFieldsGrow) + self.choose_folder_layout.setContentsMargins(8, 8, 8, 8) + self.choose_folder_layout.setSpacing(8) + self.choose_folder_layout.setLabelAlignment(QtCore.Qt.AlignLeft | QtCore.Qt.AlignVCenter) + self.choose_folder_layout.setObjectName('choose_folder_layout') + self.folder_question_label = QtWidgets.QLabel(self) + self.folder_question_label.setWordWrap(True) + self.folder_question_label.setObjectName('folder_question_label') + self.choose_folder_layout.setWidget(1, QtWidgets.QFormLayout.SpanningRole, self.folder_question_label) + self.nofolder_radio_button = QtWidgets.QRadioButton(self) + self.nofolder_radio_button.setChecked(True) + self.nofolder_radio_button.setObjectName('nofolder_radio_button') + self.choose_folder_layout.setWidget(2, QtWidgets.QFormLayout.LabelRole, self.nofolder_radio_button) + self.existing_radio_button = QtWidgets.QRadioButton(self) + self.existing_radio_button.setChecked(False) + self.existing_radio_button.setObjectName('existing_radio_button') + self.choose_folder_layout.setWidget(3, QtWidgets.QFormLayout.LabelRole, self.existing_radio_button) + self.folder_combobox = QtWidgets.QComboBox(self) + self.folder_combobox.setObjectName('folder_combobox') + self.folder_combobox.activated.connect(self.on_folder_combobox_selected) + self.choose_folder_layout.setWidget(3, QtWidgets.QFormLayout.FieldRole, self.folder_combobox) + self.new_radio_button = QtWidgets.QRadioButton(self) + self.new_radio_button.setChecked(False) + self.new_radio_button.setObjectName('new_radio_button') + self.choose_folder_layout.setWidget(4, QtWidgets.QFormLayout.LabelRole, self.new_radio_button) + self.new_folder_edit = QtWidgets.QLineEdit(self) + self.new_folder_edit.setObjectName('new_folder_edit') + self.new_folder_edit.textEdited.connect(self.on_new_folder_edit_changed) + self.choose_folder_layout.setWidget(4, QtWidgets.QFormLayout.FieldRole, self.new_folder_edit) + self.folder_button_box = create_button_box(self, 'buttonBox', ['ok']) + self.choose_folder_layout.setWidget(5, QtWidgets.QFormLayout.FieldRole, self.folder_button_box) + + self.retranslate_ui() + QtCore.QMetaObject.connectSlotsByName(self) + + def retranslate_ui(self): + """ + Translate the UI on the fly. + + :param self: The form object (not the class). + """ + self.setWindowTitle(translate('OpenLP.ChooseFolderForm', 'Select Folder')) + self.folder_question_label.setText(translate('OpenLP.ChooseFolderForm', 'Add items to folder:')) + self.nofolder_radio_button.setText(translate('OpenLP.ChooseFolderForm', 'No folder')) + self.existing_radio_button.setText(translate('OpenLP.ChooseFolderForm', 'Existing folder')) + self.new_radio_button.setText(translate('OpenLP.ChooseFolderForm', 'New folder')) + + def exec(self, selected_folder=None): + """ + Show the form + + :param selected_folder: The ID of the folder that should be selected by default when showing the dialog. + """ + is_disabled = self.db_manager.get_object_count(self.folder_class) == 0 + self.existing_radio_button.setDisabled(is_disabled) + self.folder_combobox.setDisabled(is_disabled) + self.new_folder_edit.clear() + self.populate_folders() + if selected_folder is not None: + for index in range(self.folder_combobox.count()): + if self.folder_combobox.itemData(index) == selected_folder: + self.folder_combobox.setCurrentIndex(index) + self.existing_radio_button.setChecked(True) + return QtWidgets.QDialog.exec(self) + + def accept(self): + """ + Override the accept() method from QDialog to make sure something is entered in the text input box. + """ + if self.new_radio_button.isChecked() and not self.new_folder_edit.text(): + critical_error_message_box(message=translate('OpenLP.ChooseFolderForm', + 'You need to type in a folder name.')) + self.new_folder_edit.setFocus() + return False + else: + return QtWidgets.QDialog.accept(self) + + def on_folder_combobox_selected(self, index): + """ + Handles the activated signal from the existing folder combobox when the + user makes a selection + + :param index: position of the selected item in the combobox + """ + self.existing_radio_button.setChecked(True) + self.folder_combobox.setFocus() + + def on_new_folder_edit_changed(self, new_folder): + """ + Handles the textEdited signal from the new folder text input field + when the user enters a new folder name + + :param new_folder: new text entered by the user + """ + self.new_radio_button.setChecked(True) + + @property + def folder_name(self): + """ + A property to return the folder name + """ + return self.new_folder_edit.text() + + @property + def folder(self): + if self.existing_radio_button.isChecked() and self.folder_combobox.currentIndex() != -1: + return self.folder_combobox.currentData(QtCore.Qt.UserRole) + elif self.new_radio_button.isChecked() and self.new_folder_edit.text(): + return self.folder_class(name=self.new_folder_edit.text()) + return None + + @property + def is_new_folder(self): + """ + A property to indicate if the folder from the ``folder`` property is a new folder or an existing one + """ + return self.new_radio_button.isChecked() diff --git a/openlp/core/ui/icons.py b/openlp/core/ui/icons.py index dcee56499..c40485bd7 100644 --- a/openlp/core/ui/icons.py +++ b/openlp/core/ui/icons.py @@ -90,6 +90,7 @@ class UiIcons(metaclass=Singleton): 'error': {'icon': 'fa.exclamation', 'attr': 'red'}, 'exception': {'icon': 'fa.times-circle'}, 'exit': {'icon': 'fa.sign-out'}, + 'folder': {'icon': 'fa.folder'}, 'group': {'icon': 'fa.object-group'}, 'inactive': {'icon': 'fa.child', 'attr': 'lightGray'}, 'info': {'icon': 'fa.info'}, diff --git a/openlp/core/ui/library.py b/openlp/core/ui/library.py new file mode 100644 index 000000000..aabf40c37 --- /dev/null +++ b/openlp/core/ui/library.py @@ -0,0 +1,387 @@ +# -*- coding: utf-8 -*- + +########################################################################## +# OpenLP - Open Source Lyrics Projection # +# ---------------------------------------------------------------------- # +# Copyright (c) 2008-2021 OpenLP Developers # +# ---------------------------------------------------------------------- # +# 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 3 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, see . # +########################################################################## +""" +Provides additional classes for working in the library +""" +import os +from pathlib import Path + +from PyQt5 import QtCore, QtWidgets + +from openlp.core.common import sha256_file_hash +from openlp.core.common.i18n import UiStrings, get_natural_key, translate +from openlp.core.lib import check_item_selected +from openlp.core.lib.mediamanageritem import MediaManagerItem +from openlp.core.lib.ui import critical_error_message_box +from openlp.core.ui.folders import AddFolderForm, ChooseFolderForm +from openlp.core.ui.icons import UiIcons +from openlp.core.widgets.views import TreeWidgetWithDnD + + +class FolderLibraryItem(MediaManagerItem): + """ + This is a custom MediaManagerItem subclass with support for folders + """ + def __init__(self, parent, plugin, folder_class, item_class): + super(FolderLibraryItem, self).__init__(parent, plugin) + self.manager = self.plugin.manager + self.choose_folder_form = ChooseFolderForm(self, self.manager, folder_class) + self.add_folder_form = AddFolderForm(self, self.manager, folder_class) + self.folder_class = folder_class + self.item_class = item_class + + @property + def current_folder(self): + """ + Returns the currently active folder, or None + """ + selected_items = self.list_view.selectedItems() + selected_folder = None + if selected_items: + selected_item = selected_items[0] + if isinstance(selected_item.data(0, QtCore.Qt.UserRole), self.item_class): + selected_item = selected_item.parent() + if isinstance(selected_item, QtWidgets.QTreeWidgetItem) and \ + isinstance(selected_item.data(0, QtCore.Qt.UserRole), self.folder_class): + selected_folder = selected_item.data(0, QtCore.Qt.UserRole) + return selected_folder + + def retranslate_ui(self): + """ + This method is called automatically to provide OpenLP with the opportunity to translate the ``MediaManagerItem`` + to another language. + """ + self.add_folder_action.setText(UiStrings().AddFolder) + self.add_folder_action.setToolTip(UiStrings().AddFolderDot) + + def on_add_folder_click(self): + """ + Called to add a new folder + """ + # Syntactic sugar, plus a minor performance optimisation + Item = self.item_class + + if self.add_folder_form.exec(show_top_level_folder=True, selected_folder=self.current_folder): + new_folder = self.add_folder_form.new_folder + if self.manager.save_object(new_folder): + self.load_list(self.manager.get_all_objects(Item, order_by_ref=Item.file_path)) + self.expand_folder(new_folder.id) + else: + critical_error_message_box( + message=translate('OpenLP.FolderLibraryItem', 'Could not add the new folder.')) + + def on_delete_click(self): + """ + Remove an item from the list. + """ + # Syntactic sugar, plus a minor performance optimisation + Folder, Item = self.folder_class, self.item_class + + # Turn off auto preview triggers. + self.list_view.blockSignals(True) + if check_item_selected(self.list_view, + translate('OpenLP.FolderLibraryItem', 'You must select an item or folder to delete.')): + tree_item_list = self.list_view.selectedItems() + self.application.set_busy_cursor() + self.main_window.display_progress_bar(len(tree_item_list)) + for tree_item in tree_item_list: + if not tree_item: + self.main_window.increment_progress_bar() + continue + item = tree_item.data(0, QtCore.Qt.UserRole) + if isinstance(item, Item): + self.delete_item(item) + if not item.folder_id: + self.list_view.takeTopLevelItem(self.list_view.indexOfTopLevelItem(tree_item)) + else: + tree_item.parent().removeChild(tree_item) + self.manager.delete_object(Item, item.id) + elif isinstance(item, Folder): + if QtWidgets.QMessageBox.question( + self.list_view.parent(), + translate('OpenLP.FolderLibraryItem', 'Remove folder'), + translate('OpenLP.FolderLibraryItem', + 'Are you sure you want to remove "{name}" and everything in it?' + ).format(name=item.name) + ) == QtWidgets.QMessageBox.Yes: + self.recursively_delete_folder(item) + self.manager.delete_object(Folder, item.id) + if item.parent_id is None: + self.list_view.takeTopLevelItem(self.list_view.indexOfTopLevelItem(tree_item)) + else: + tree_item.parent().removeChild(tree_item) + self.main_window.increment_progress_bar() + self.main_window.finished_progress_bar() + self.application.set_normal_cursor() + self.list_view.blockSignals(False) + + def add_list_view_to_toolbar(self): + """ + Creates the main widget for listing items. + """ + # Add the List widget + self.list_view = TreeWidgetWithDnD(self, self.plugin.name) + self.list_view.setObjectName('{name}TreeView'.format(name=self.plugin.name)) + # Add to pageLayout + self.page_layout.addWidget(self.list_view) + # define and add the context menu + self.list_view.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) + + def add_middle_header_bar(self): + """ + Add buttons after the main buttons + """ + self.add_folder_action = self.toolbar.add_toolbar_action( + 'add_folder_action', icon=UiIcons().folder, triggers=self.on_add_folder_click) + + def add_sub_folders(self, folder_list, parent_id=None): + """ + Recursively add subfolders to the given parent folder in a QTreeWidget. + + :param folder_list: The List object that contains all QTreeWidgetItems. + :param parent_folder_id: The ID of the folder that will be added recursively. + """ + # Syntactic sugar, plus a minor performance optimisation + Folder = self.folder_class + + folders = self.manager.get_all_objects(Folder, Folder.parent_id == parent_id) + folders.sort(key=lambda folder_object: get_natural_key(folder_object.name)) + folder_icon = UiIcons().folder + for folder in folders: + folder_item = QtWidgets.QTreeWidgetItem() + folder_item.setText(0, folder.name) + folder_item.setData(0, QtCore.Qt.UserRole, folder) + folder_item.setIcon(0, folder_icon) + if parent_id is None: + self.list_view.addTopLevelItem(folder_item) + else: + folder_list[parent_id].addChild(folder_item) + folder_list[folder.id] = folder_item + self.add_sub_folders(folder_list, folder.id) + + def expand_folder(self, folder_id, root_item=None): + """ + Expand folders in the widget recursively. + + :param folder_id: The ID of the folder that will be expanded. + :param root_item: This option is only used for recursion purposes. + """ + return_value = False + if root_item is None: + root_item = self.list_view.invisibleRootItem() + for i in range(root_item.childCount()): + child = root_item.child(i) + if self.expand_folder(folder_id, child): + child.setExpanded(True) + return_value = True + if isinstance(root_item.data(0, QtCore.Qt.UserRole), self.folder_class): + if root_item.data(0, QtCore.Qt.UserRole).id == folder_id: + return True + return return_value + + def recursively_delete_folder(self, folder): + """ + Recursively deletes a folder and all folders and items in it. + + :param folder: The Folder instance of the folder that will be deleted. + """ + # Syntactic sugar, plus a minor performance optimisation + Folder, Item = self.folder_class, self.item_class + + items = self.manager.get_all_objects(Item, Item.folder_id == folder.id) + for item in items: + self.delete_item(item) + self.manager.delete_object(Item, item.id) + folders = self.manager.get_all_objects(Folder, Folder.parent_id == folder.id) + for child in folders: + self.recursively_delete_folder(child) + self.manager.delete_object(Folder, child.id) + + def file_to_item(self, filename): + """ + This method allows the media item to convert a string filename into an item class + + Override this method to customise your plugin's loading method + """ + if isinstance(filename, Path): + name = filename.name + filename = str(filename) + else: + name = os.path.basename(filename) + item = self.item_class(name=name, file_path=filename) + self.manager.save_object(item) + return item + + def load_item(self, item, is_initial_load=False): + """ + This method allows the media item to set up the QTreeWidgetItem the way it wants + """ + raise NotImplementedError('load_item needs to be implemented by the descendant class') + + def delete_item(self, item): + """ + This method allows the media item to delete the Item + """ + raise NotImplementedError('delete_item needs to be implemented by the descendant class') + + def load_list(self, items, is_initial_load=False, target_folder=None): + """ + Load the list of items into the tree view + + :param items: The items to load + :param target_folder: The folder to load the items into + """ + if not is_initial_load: + self.application.set_busy_cursor() + self.main_window.display_progress_bar(len(items)) + self.list_view.clear() + # Load the list of folders and add them to the tree view + folder_items = {} + self.add_sub_folders(folder_items, parent_id=None) + if target_folder is not None: + self.expand_folder(target_folder.id) + # Convert any filenames to items + for counter, filename in enumerate(items): + if isinstance(filename, (Path, str)): + items[counter] = self.file_to_item(filename) + # Sort the files by the filename + items.sort(key=lambda item: get_natural_key(item if isinstance(item, str) else item.file_path)) + for item in items: + self.log_debug('Loading item: {name}'.format(name=item.file_path)) + tree_item = self.load_item(item, is_initial_load) + if not tree_item: + continue + elif not item.folder_id: + self.list_view.addTopLevelItem(tree_item) + else: + folder_items[item.folder_id].addChild(tree_item) + if not is_initial_load: + self.main_window.increment_progress_bar() + if not is_initial_load: + self.main_window.finished_progress_bar() + self.application.set_normal_cursor() + + def format_search_result(self, item): + """ + Format an item for the search results. The default implementation simply returns + [item.file_path, item.file_path] + + :param Item item: An Item to be formatted + :return list[str, str]: A list of two items containing the full path and a pretty name + """ + return [item.file_path, item.file_path] + + def search(self, string, show_error): + """ + Performs a search for items containing ``string`` + + :param string: String to be displayed + :param show_error: Should the error be shown (True) + :return: The search result. + """ + string = string.lower() + items = self.manager.get_all_objects(self.item_class, self.item_class.file_path.match(string)) + return [self.format_search_result(item) for item in items] + + def validate_and_load(self, file_paths, target_folder=None): + """ + Process a list for files either from the File Dialog or from Drag and Drop. + This method is overloaded from MediaManagerItem. + + :param list[Path] file_paths: A List of paths to be loaded + :param target_group: The QTreeWidgetItem of the group that will be the parent of the added files + """ + self.application.set_normal_cursor() + if target_folder: + target_folder = target_folder.data(0, QtCore.Qt.UserRole) + elif self.current_folder: + target_folder = self.current_folder + if not target_folder and self.choose_folder_form.exec() == QtWidgets.QDialog.Accepted: + target_folder = self.choose_folder_form.folder + if self.choose_folder_form.is_new_folder: + self.manager.save_object(target_folder) + existing_files = [item.file_path for item in self.manager.get_all_objects(self.item_class)] + # Convert file paths to items + for file_path in file_paths: + if file_paths.count(file_path) > 1 or existing_files.count(file_path) > 0: + # If a file already exists in the items or has been selected twice, show an error message + critical_error_message_box(translate('OpenLP.FolderLibraryItem', 'File Exists'), + translate('OpenLP.FolderLibraryItem', + 'An item with that filename already exists.')) + continue + + self.log_debug('Adding new item: {name}'.format(name=file_path)) + item = self.item_class(name=str(file_path), file_path=str(file_path)) + if isinstance(file_path, Path) and file_path.exists(): + item.file_hash = sha256_file_hash(file_path) + if target_folder: + item.folder_id = target_folder.id + self.manager.save_object(item) + self.main_window.increment_progress_bar() + self.load_list(self.manager.get_all_objects(self.item_class, order_by_ref=self.item_class.file_path), + target_folder=target_folder) + last_dir = Path(file_paths[0]).parent + self.settings.setValue(self.settings_section + '/last directory', last_dir) + + def dnd_move_internal(self, target): + """ + Handle drag-and-drop moving of items within the media manager + + :param target: This contains the QTreeWidget that is the target of the DnD action + """ + items_to_move = self.list_view.selectedItems() + # Determine group to move images to + target_folder = target + if target_folder is not None and isinstance(target_folder.data(0, QtCore.Qt.UserRole), self.item_class): + target_folder = target.parent() + # Move to toplevel + if target_folder is None: + target_folder = self.list_view.invisibleRootItem() + target_folder.setData(0, QtCore.Qt.UserRole, self.folder_class()) + target_folder.data(0, QtCore.Qt.UserRole).id = 0 + # Move images in the treeview + items_to_save = [] + for item in items_to_move: + if isinstance(item.data(0, QtCore.Qt.UserRole), self.item_class): + if isinstance(item.parent(), QtWidgets.QTreeWidgetItem): + item.parent().removeChild(item) + else: + self.list_view.invisibleRootItem().removeChild(item) + target_folder.addChild(item) + item.setSelected(True) + item_data = item.data(0, QtCore.Qt.UserRole) + item_data.folder_id = target_folder.data(0, QtCore.Qt.UserRole).id + items_to_save.append(item_data) + target_folder.setExpanded(True) + # Update the folder ID's of the items in the database + self.manager.save_objects(items_to_save) + # Sort the target folder + sort_folders = [] + sort_items = [] + for item in target_folder.takeChildren(): + if isinstance(item.data(0, QtCore.Qt.UserRole), self.folder_class): + sort_folders.append(item) + if isinstance(item.data(0, QtCore.Qt.UserRole), self.item_class): + sort_items.append(item) + sort_folders.sort(key=lambda item: get_natural_key(item.text(0))) + target_folder.addChildren(sort_folders) + sort_items.sort(key=lambda item: get_natural_key(item.text(0))) + target_folder.addChildren(sort_items) diff --git a/openlp/core/ui/servicemanager.py b/openlp/core/ui/servicemanager.py index c68ef63c4..d621115f9 100644 --- a/openlp/core/ui/servicemanager.py +++ b/openlp/core/ui/servicemanager.py @@ -1691,6 +1691,8 @@ class ServiceManager(QtWidgets.QWidget, RegistryBase, Ui_ServiceManager, LogMixi else: self.drop_position = get_parent_item_data(item) - 1 Registry().execute('{plugin}_add_service_item'.format(plugin=plugin), replace) + else: + self.log_warning('Unrecognised item') def update_theme_list(self, theme_list): """ diff --git a/openlp/plugins/bibles/lib/manager.py b/openlp/plugins/bibles/lib/manager.py index a1068eea2..d3fad6b6b 100644 --- a/openlp/plugins/bibles/lib/manager.py +++ b/openlp/plugins/bibles/lib/manager.py @@ -18,6 +18,7 @@ # You should have received a copy of the GNU General Public License # # along with this program. If not, see . # ########################################################################## +import gc import logging from pathlib import Path @@ -141,7 +142,9 @@ class BibleManager(LogMixin, RegistryProperties): name = bible.get_name() # Remove corrupted files. if name is None: - bible.session.close_all() + bible.session.close() + bible.session = None + gc.collect() delete_file(self.path / file_path) continue log.debug('Bible Name: "{name}"'.format(name=name)) @@ -185,8 +188,9 @@ class BibleManager(LogMixin, RegistryProperties): """ log.debug('BibleManager.delete_bible("{name}")'.format(name=name)) bible = self.db_cache[name] - bible.session.close_all() + bible.session.close() bible.session = None + gc.collect() return delete_file(bible.path / '{name}{suffix}'.format(name=name, suffix=self.suffix)) def get_bibles(self): diff --git a/openlp/plugins/images/lib/mediaitem.py b/openlp/plugins/images/lib/mediaitem.py index f29e8ce4f..1115ef123 100644 --- a/openlp/plugins/images/lib/mediaitem.py +++ b/openlp/plugins/images/lib/mediaitem.py @@ -97,7 +97,7 @@ class ImageMediaItem(MediaManagerItem): """ Set which icons the media manager tab should show. """ - MediaManagerItem.required_icons(self) + super().required_icons() self.has_file_icon = True self.has_new_icon = False self.has_edit_icon = False diff --git a/openlp/plugins/media/lib/db.py b/openlp/plugins/media/lib/db.py new file mode 100644 index 000000000..946ea720c --- /dev/null +++ b/openlp/plugins/media/lib/db.py @@ -0,0 +1,46 @@ +# -*- coding: utf-8 -*- + +########################################################################## +# OpenLP - Open Source Lyrics Projection # +# ---------------------------------------------------------------------- # +# Copyright (c) 2008-2021 OpenLP Developers # +# ---------------------------------------------------------------------- # +# 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 3 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, see . # +########################################################################## +""" +The :mod:`~openlp.plugins.media.lib.db` module contains the database layer for the media plugin +""" +from sqlalchemy import MetaData +from sqlalchemy.ext.declarative import declarative_base + +from openlp.core.lib.db import FolderMixin, ItemMixin, init_db, init_url + +Base = declarative_base(MetaData()) + + +class Folder(Base, FolderMixin): + """A folder holds items or other folders""" + + +class Item(Base, ItemMixin): + """An item is something that can be contained within a folder""" + + +def init_schema(*args, **kwargs): + """ + Set up the media database and initialise the schema + """ + session, metadata = init_db(init_url('media'), base=Base) + metadata.create_all(checkfirst=True) + return session diff --git a/openlp/plugins/media/lib/mediaitem.py b/openlp/plugins/media/lib/mediaitem.py index f23352fb0..fc70d9e07 100644 --- a/openlp/plugins/media/lib/mediaitem.py +++ b/openlp/plugins/media/lib/mediaitem.py @@ -21,23 +21,28 @@ import logging import os +from pathlib import Path from PyQt5 import QtCore, QtWidgets +from sqlalchemy.sql.expression import or_ +from openlp.core.common import delete_file from openlp.core.common.applocation import AppLocation -from openlp.core.common.i18n import UiStrings, get_natural_key, translate -from openlp.core.common.mixins import RegistryProperties -from openlp.core.common.path import create_paths, path_to_str +from openlp.core.common.i18n import UiStrings, translate +from openlp.core.common.path import create_paths from openlp.core.common.registry import Registry -from openlp.core.lib import MediaType, ServiceItemContext, check_item_selected -from openlp.core.lib.mediamanageritem import MediaManagerItem +from openlp.core.lib import MediaType, ServiceItemContext +from openlp.core.lib.plugin import StringContent from openlp.core.lib.serviceitem import ItemCapabilities -from openlp.core.lib.ui import critical_error_message_box +from openlp.core.lib.ui import create_action, create_widget_action, critical_error_message_box from openlp.core.state import State from openlp.core.ui.icons import UiIcons +from openlp.core.ui.library import FolderLibraryItem from openlp.core.ui.media import parse_optical_path, parse_stream_path, format_milliseconds, AUDIO_EXT, VIDEO_EXT from openlp.core.ui.media.vlcplayer import get_vlc +from openlp.plugins.media.lib.db import Folder, Item + if get_vlc() is not None: from openlp.plugins.media.forms.mediaclipselectorform import MediaClipSelectorForm from openlp.plugins.media.forms.streamselectorform import StreamSelectorForm @@ -47,7 +52,7 @@ if get_vlc() is not None: log = logging.getLogger(__name__) -class MediaMediaItem(MediaManagerItem, RegistryProperties): +class MediaMediaItem(FolderLibraryItem): """ This is the custom media manager item for Media Slides. """ @@ -57,7 +62,28 @@ class MediaMediaItem(MediaManagerItem, RegistryProperties): def __init__(self, parent, plugin): self.setup() - super(MediaMediaItem, self).__init__(parent, plugin) + super(MediaMediaItem, self).__init__(parent, plugin, Folder, Item) + + def retranslate_ui(self): + """ + This method is called automatically to provide OpenLP with the opportunity to translate the ``MediaManagerItem`` + to another language. + """ + super().retranslate_ui() + self.on_new_prompt = translate('MediaPlugin.MediaItem', 'Select Media') + + def setup_item(self): + """ + Do some additional setup. + """ + self.media_go_live.connect(self.go_live_remote) + self.media_add_to_service.connect(self.add_to_service_remote) + self.single_service_item = False + self.has_search = True + self.media_object = None + Registry().register_function('mediaitem_media_rebuild', self.rebuild_players) + # Allow DnD from the desktop + self.list_view.activateDnD() def setup(self): """ @@ -69,33 +95,11 @@ class MediaMediaItem(MediaManagerItem, RegistryProperties): self.error_icon = UiIcons().delete self.clapperboard = UiIcons().clapperboard - def setup_item(self): - """ - Do some additional setup. - """ - self.media_go_live.connect(self.go_live_remote) - self.media_add_to_service.connect(self.add_to_service_remote) - self.single_service_item = False - self.has_search = True - self.media_object = None - # self.display_controller = DisplayController(self.parent()) - # Registry().register_function('video_background_replaced', self.video_background_replaced) - Registry().register_function('mediaitem_media_rebuild', self.rebuild_players) - # Allow DnD from the desktop - self.list_view.activateDnD() - - def retranslate_ui(self): - """ - This method is called automatically to provide OpenLP with the opportunity to translate the ``MediaManagerItem`` - to another language. - """ - self.on_new_prompt = translate('MediaPlugin.MediaItem', 'Select Media') - def required_icons(self): """ Set which icons the media manager tab should show """ - MediaManagerItem.required_icons(self) + super().required_icons() self.has_file_icon = True self.has_new_icon = False self.has_edit_icon = False @@ -106,37 +110,126 @@ class MediaMediaItem(MediaManagerItem, RegistryProperties): if State().check_preconditions('media_live'): self.can_make_live = True + def initialise(self): + """ + Initialize media item. + """ + self.log_debug('Initialise') + self.list_view.clear() + self.list_view.setIndentation(self.list_view.default_indentation) + self.list_view.allow_internal_dnd = True + self.service_path = AppLocation.get_section_data_path(self.settings_section) / 'thumbnails' + create_paths(self.service_path) + # Load images from the database + self.load_list(self.manager.get_all_objects(Item, order_by_ref=Item.file_path), is_initial_load=True) + self.service_path = AppLocation.get_section_data_path('media') / 'thumbnails' + self.rebuild_players() + def add_list_view_to_toolbar(self): """ Creates the main widget for listing items. """ - MediaManagerItem.add_list_view_to_toolbar(self) - # self.list_view.addAction(self.replace_action) + super().add_list_view_to_toolbar() + if self.has_edit_icon: + create_widget_action( + self.list_view, + text=self.plugin.get_string(StringContent.Edit)['title'], + icon=UiIcons().edit, + triggers=self.on_edit_click) + create_widget_action(self.list_view, separator=True) + create_widget_action( + self.list_view, + 'listView{name}{preview}Item'.format(name=self.plugin.name.title(), preview=StringContent.Preview.title()), + text=self.plugin.get_string(StringContent.Preview)['title'], + icon=UiIcons().preview, + can_shortcuts=True, + triggers=self.on_preview_click) + create_widget_action( + self.list_view, + 'listView{name}{live}Item'.format(name=self.plugin.name.title(), live=StringContent.Live.title()), + text=self.plugin.get_string(StringContent.Live)['title'], + icon=UiIcons().live, + can_shortcuts=True, + triggers=self.on_live_click) + create_widget_action( + self.list_view, + 'listView{name}{service}Item'.format(name=self.plugin.name.title(), service=StringContent.Service.title()), + can_shortcuts=True, + text=self.plugin.get_string(StringContent.Service)['title'], + icon=UiIcons().add, + triggers=self.on_add_click) + if self.add_to_service_item: + create_widget_action(self.list_view, separator=True) + create_widget_action( + self.list_view, + text=translate('OpenLP.MediaManagerItem', '&Add to selected Service Item'), + icon=UiIcons().add, + triggers=self.on_add_edit_click) + create_widget_action(self.list_view, separator=True) + if self.has_delete_icon: + create_widget_action( + self.list_view, + 'listView{name}{delete}Item'.format(name=self.plugin.name.title(), delete=StringContent.Delete.title()), + text=self.plugin.get_string(StringContent.Delete)['title'], + icon=UiIcons().delete, + can_shortcuts=True, triggers=self.on_delete_click) + self.add_custom_context_actions() + # Create the context menu and add all actions from the list_view. + self.menu = QtWidgets.QMenu() + self.menu.addActions(self.list_view.actions()) + self.list_view.doubleClicked.connect(self.on_double_clicked) + self.list_view.itemSelectionChanged.connect(self.on_selection_change) + self.list_view.customContextMenuRequested.connect(self.context_menu) - def add_start_header_bar(self): + def add_custom_context_actions(self): """ - Adds buttons to the start of the header bar. + Add custom actions to the context menu. """ + create_widget_action(self.list_view, separator=True) + create_widget_action( + self.list_view, + text=UiStrings().AddFolder, icon=UiIcons().folder, triggers=self.on_add_folder_click) + create_widget_action( + self.list_view, + text=translate('MediaPlugin', 'Add new media'), + icon=UiIcons().open, triggers=self.on_file_click) + create_widget_action(self.list_view, separator=True) + + def setup_ui(self): + """ + Re-implement to add a dropdown menu to the load icon + """ + super().setup_ui() if State().check_preconditions('media'): optical_button_text = translate('MediaPlugin.MediaItem', 'Load CD/DVD') optical_button_tooltip = translate('MediaPlugin.MediaItem', 'Load CD/DVD') - self.load_optical = self.toolbar.add_toolbar_action('load_optical', icon=UiIcons().optical, - text=optical_button_text, - tooltip=optical_button_tooltip, - triggers=self.on_load_optical) + self.load_optical = create_action(self, 'load_optical', + icon=UiIcons().optical, + text=optical_button_text, + tooltip=optical_button_tooltip, + triggers=self.on_load_optical) device_stream_button_text = translate('MediaPlugin.MediaItem', 'Open device stream') device_stream_button_tooltip = translate('MediaPlugin.MediaItem', 'Open device stream') - self.open_stream = self.toolbar.add_toolbar_action('open_device_stream', icon=UiIcons().device_stream, - text=device_stream_button_text, - tooltip=device_stream_button_tooltip, - triggers=self.on_open_device_stream) + self.open_stream = create_action(self, 'open_device_stream', + icon=UiIcons().device_stream, + text=device_stream_button_text, + tooltip=device_stream_button_tooltip, + triggers=self.on_open_device_stream) network_stream_button_text = translate('MediaPlugin.MediaItem', 'Open network stream') network_stream_button_tooltip = translate('MediaPlugin.MediaItem', 'Open network stream') - self.open_network_stream = self.toolbar.add_toolbar_action('open_network_stream', - icon=UiIcons().network_stream, - text=network_stream_button_text, - tooltip=network_stream_button_tooltip, - triggers=self.on_open_network_stream) + self.open_network_stream = create_action(self, 'open_network_stream', + icon=UiIcons().network_stream, + text=network_stream_button_text, + tooltip=network_stream_button_tooltip, + triggers=self.on_open_network_stream) + self.load_menu = QtWidgets.QMenu(self.toolbar) + self.load_menu.setObjectName('load_menu') + self.load_menu.addAction(self.load_optical) + self.load_menu.addAction(self.open_stream) + self.load_menu.addAction(self.open_network_stream) + self.toolbar.actions['mediaLoadAction'].setMenu(self.load_menu) + button = self.toolbar.widgetForAction(self.toolbar.actions['mediaLoadAction']) + button.setPopupMode(QtWidgets.QToolButton.MenuButtonPopup) def generate_slide_data(self, service_item, *, item=None, remote=False, context=ServiceItemContext.Service, **kwargs): @@ -153,7 +246,12 @@ class MediaMediaItem(MediaManagerItem, RegistryProperties): item = self.list_view.currentItem() if item is None: return False - filename = str(item.data(QtCore.Qt.UserRole)) + if isinstance(item, QtCore.QModelIndex): + item = self.list_view.itemFromIndex(item) + media_item = item.data(0, QtCore.Qt.UserRole) + if not isinstance(media_item, Item): + return False + filename = media_item.file_path # Special handling if the filename is a optical clip if filename.startswith('optical:'): (name, title, audio_track, subtitle_track, start, end, clip_name) = parse_optical_path(filename) @@ -205,16 +303,6 @@ class MediaMediaItem(MediaManagerItem, RegistryProperties): service_item.validate_item() return True - def initialise(self): - """ - Initialize media item. - """ - self.list_view.clear() - self.service_path = AppLocation.get_section_data_path('media') / 'thumbnails' - create_paths(self.service_path) - self.load_list([path_to_str(file) for file in self.settings.value('media/media files')]) - self.rebuild_players() - def rebuild_players(self): """ Rebuild the tab in the media manager when changes are made in the settings. @@ -226,68 +314,91 @@ class MediaMediaItem(MediaManagerItem, RegistryProperties): audio=' '.join(AUDIO_EXT), files=UiStrings().AllFiles) - def on_delete_click(self): + def file_to_item(self, filename): """ - Remove a media item from the list. + Override the inherited file_to_item() method to handle various tracks """ - if check_item_selected(self.list_view, - translate('MediaPlugin.MediaItem', 'You must select a media file to delete.')): - row_list = [item.row() for item in self.list_view.selectedIndexes()] - row_list.sort(reverse=True) - for row in row_list: - self.list_view.takeItem(row) - self.settings.setValue('media/media files', self.get_file_list()) + if isinstance(filename, Path): + name = filename.name + filename = str(filename) + elif filename.startswith('optical:'): + # Handle optical based item + _, _, _, _, _, _, name = parse_optical_path(filename) + elif filename.startswith('devicestream:') or filename.startswith('networkstream:'): + name, _, _ = parse_stream_path(filename) + else: + name = os.path.basename(filename) + item = self.item_class(name=name, file_path=filename) + self.manager.save_object(item) + return item - def load_list(self, media, target_group=None): + def load_item(self, item, is_initial_load=False): """ - Load the media list + Given an item object, return a QTreeWidgetItem + """ + track_str = str(item.file_path) + track_info = QtCore.QFileInfo(track_str) + tree_item = None + if track_str.startswith('optical:'): + # Handle optical based item + (file_name, title, audio_track, subtitle_track, start, end, clip_name) = parse_optical_path(track_str) + tree_item = QtWidgets.QTreeWidgetItem([clip_name]) + tree_item.setText(0, clip_name) + tree_item.setIcon(0, UiIcons().optical) + tree_item.setToolTip(0, '{name}@{start}-{end}'.format(name=file_name, + start=format_milliseconds(start), + end=format_milliseconds(end))) + elif track_str.startswith('devicestream:') or track_str.startswith('networkstream:'): + (name, mrl, options) = parse_stream_path(track_str) + tree_item = QtWidgets.QTreeWidgetItem([name]) + tree_item.setText(0, name) + if track_str.startswith('devicestream:'): + tree_item.setIcon(0, UiIcons().device_stream) + else: + tree_item.setIcon(0, UiIcons().network_stream) + tree_item.setToolTip(0, mrl) + elif not Path(item.file_path).exists(): + # File doesn't exist, mark as error. + file_name = Path(item.file_path).name + tree_item = QtWidgets.QTreeWidgetItem([file_name]) + tree_item.setText(0, file_name) + tree_item.setIcon(0, UiIcons().error) + tree_item.setToolTip(0, track_str) + elif track_info.isFile(): + # Normal media file handling. + file_name = Path(item.file_path).name + tree_item = QtWidgets.QTreeWidgetItem([file_name]) + tree_item.setText(0, file_name) + search = "*." + file_name.split('.')[-1].lower() + if search in AUDIO_EXT: + tree_item.setIcon(0, UiIcons().audio) + else: + tree_item.setIcon(0, UiIcons().video) + tree_item.setToolTip(0, track_str) + if tree_item: + tree_item.setData(0, QtCore.Qt.UserRole, item) + return tree_item - :param media: The media - :param target_group: + def delete_item(self, item): """ - media.sort(key=lambda file_path: get_natural_key(os.path.split(str(file_path))[1])) - for track in media: - track_str = str(track) - track_info = QtCore.QFileInfo(track_str) - item_name = None - if track_str.startswith('optical:'): - # Handle optical based item - (file_name, title, audio_track, subtitle_track, start, end, clip_name) = parse_optical_path(track_str) - item_name = QtWidgets.QListWidgetItem(clip_name) - item_name.setIcon(UiIcons().optical) - item_name.setData(QtCore.Qt.UserRole, track) - item_name.setToolTip('{name}@{start}-{end}'.format(name=file_name, - start=format_milliseconds(start), - end=format_milliseconds(end))) - elif track_str.startswith('devicestream:') or track_str.startswith('networkstream:'): - (name, mrl, options) = parse_stream_path(track_str) - item_name = QtWidgets.QListWidgetItem(name) - if track_str.startswith('devicestream:'): - item_name.setIcon(UiIcons().device_stream) - else: - item_name.setIcon(UiIcons().network_stream) - item_name.setData(QtCore.Qt.UserRole, track) - item_name.setToolTip(mrl) - elif not os.path.exists(track): - # File doesn't exist, mark as error. - file_name = os.path.split(track_str)[1] - item_name = QtWidgets.QListWidgetItem(file_name) - item_name.setIcon(UiIcons().error) - item_name.setData(QtCore.Qt.UserRole, track) - item_name.setToolTip(track_str) - elif track_info.isFile(): - # Normal media file handling. - file_name = os.path.split(track_str)[1] - item_name = QtWidgets.QListWidgetItem(file_name) - search = "*." + file_name.split('.')[-1].lower() - if search in AUDIO_EXT: - item_name.setIcon(UiIcons().audio) - else: - item_name.setIcon(UiIcons().video) - item_name.setData(QtCore.Qt.UserRole, track) - item_name.setToolTip(track_str) - if item_name: - self.list_view.addItem(item_name) + Delete any and all thumbnails and things associated with this media item + """ + delete_file(self.service_path / Path(item.file_path).name) + + def format_search_result(self, item): + """ + Format an item for the search results + + :param Item item: An Item to be formatted + :return list[str, str]: A list of two items containing the full path and a pretty name + """ + if Path(item.file_path).exists(): + return [item.file_path, Path(item.file_path).name] + elif item.file_path.startswith('device'): + (name, _, _) = parse_stream_path(item.file_path) + return [item.file_path, name] + else: + return super().format_search_result(item) def get_list(self, media_type=MediaType.Audio): """ @@ -296,40 +407,16 @@ class MediaMediaItem(MediaManagerItem, RegistryProperties): :param media_type: Type to get, defaults to audio. :return: The media list """ - media_file_paths = self.settings.value('media/media files') - media_file_paths.sort(key=lambda file_path: get_natural_key(os.path.split(str(file_path))[1])) if media_type == MediaType.Audio: - extension = AUDIO_EXT + extensions = AUDIO_EXT else: - extension = VIDEO_EXT - extension = [x[1:] for x in extension] - media = [x for x in media_file_paths if os.path.splitext(x)[1] in extension] - return media - - def search(self, string, show_error): - """ - Performs a search for items containing ``string`` - - :param string: String to be displayed - :param show_error: Should the error be shown (True) - :return: The search result. - """ - from pathlib import Path - results = [] - string = string.lower() - for file_path in self.settings.value('media/media files'): - if isinstance(file_path, Path): - file_name = file_path.name - if file_name.lower().find(string) > -1: - results.append([str(file_path), file_name]) - else: - if file_path.lower().find(string) > -1: - if file_path.startswith('device'): - (name, _, _) = parse_stream_path(file_path) - results.append([str(file_path), name]) - else: - results.append([str(file_path), file_path]) - return results + extensions = VIDEO_EXT + clauses = [] + for extension in extensions: + # Drop the initial * and add to the list of clauses + clauses.append(Item.file_path.endswith(extension[1:])) + items = self.manager.get_all_objects(Item, or_(*clauses)) + return [Path(item.file_path) for item in items] def on_load_optical(self): """ @@ -349,16 +436,7 @@ class MediaMediaItem(MediaManagerItem, RegistryProperties): :param optical: The clip to add. """ - file_paths = self.get_file_list() - # If the clip already is in the media list it isn't added and an error message is displayed. - if optical in file_paths: - critical_error_message_box(translate('MediaPlugin.MediaItem', 'Mediaclip already saved'), - translate('MediaPlugin.MediaItem', 'This mediaclip has already been saved')) - return - # Append the optical string to the media list - file_paths.append(optical) - self.load_list([str(optical)]) - self.settings.setValue('media/media files', file_paths) + self.validate_and_load([str(optical)]) def on_open_device_stream(self): """ @@ -378,16 +456,7 @@ class MediaMediaItem(MediaManagerItem, RegistryProperties): :param stream: The clip to add. """ - file_paths = self.get_file_list() - # If the clip already is in the media list it isn't added and an error message is displayed. - if stream in file_paths: - critical_error_message_box(translate('MediaPlugin.MediaItem', 'Stream already saved'), - translate('MediaPlugin.MediaItem', 'This stream has already been saved')) - return - # Append the device stream string to the media list - file_paths.append(stream) - self.load_list([str(stream)]) - self.settings.setValue('media/media files', file_paths) + self.validate_and_load([str(stream)]) def on_open_network_stream(self): """ @@ -407,13 +476,4 @@ class MediaMediaItem(MediaManagerItem, RegistryProperties): :param stream: The clip to add. """ - file_paths = self.get_file_list() - # If the clip already is in the media list it isn't added and an error message is displayed. - if stream in file_paths: - critical_error_message_box(translate('MediaPlugin.MediaItem', 'Stream already saved'), - translate('MediaPlugin.MediaItem', 'This stream has already been saved')) - return - # Append the device stream string to the media list - file_paths.append(stream) - self.load_list([str(stream)]) - self.settings.setValue('media/media files', file_paths) + self.validate_and_load([str(stream)]) diff --git a/openlp/plugins/media/mediaplugin.py b/openlp/plugins/media/mediaplugin.py index 31b4083a6..4dc05d740 100644 --- a/openlp/plugins/media/mediaplugin.py +++ b/openlp/plugins/media/mediaplugin.py @@ -22,12 +22,19 @@ The Media plugin """ import logging +import os +from pathlib import Path -from openlp.core.state import State +from openlp.core.common import sha256_file_hash from openlp.core.common.i18n import translate -from openlp.core.ui.icons import UiIcons from openlp.core.lib import build_icon +from openlp.core.lib.db import Manager from openlp.core.lib.plugin import Plugin, StringContent +from openlp.core.state import State +from openlp.core.ui.icons import UiIcons +from openlp.core.ui.media import parse_optical_path, parse_stream_path + +from openlp.plugins.media.lib.db import Item, init_schema from openlp.plugins.media.lib.mediaitem import MediaMediaItem @@ -43,7 +50,8 @@ class MediaPlugin(Plugin): log.info('{name} MediaPlugin loaded'.format(name=__name__)) def __init__(self): - super(MediaPlugin, self).__init__('media', MediaMediaItem) + super().__init__('media', MediaMediaItem) + self.manager = Manager(plugin_name='media', init_schema=init_schema) self.weight = -6 self.icon_path = UiIcons().video self.icon = build_icon(self.icon_path) @@ -56,14 +64,29 @@ class MediaPlugin(Plugin): """ Override the inherited initialise() method in order to upgrade the media before trying to load it """ + media_files = self.settings.value('media/media files') or [] + for filename in media_files: + if not isinstance(filename, (Path, str)): + continue + if isinstance(filename, Path): + name = filename.name + filename = str(filename) + elif filename.startswith('devicestream:') or filename.startswith('networkstream:'): + name, _, _ = parse_stream_path(filename) + elif filename.startswith('optical:'): + _, _, _, _, _, _, name = parse_optical_path(filename) + else: + name = os.path.basename(filename) + item = Item(name=name, file_path=filename) + if not filename.startswith('devicestream:') and not filename.startswith('networkstream:') \ + and not filename.startswith('optical:'): + file_path = Path(filename) + if file_path.is_file() and file_path.exists(): + item.file_hash = sha256_file_hash(file_path) + self.manager.save_object(item) + self.settings.remove('media/media files') super().initialise() - def app_startup(self): - """ - Override app_startup() in order to do nothing - """ - pass - def check_pre_conditions(self): """ Check the plugin can run and the media controller is available. diff --git a/openlp/plugins/presentations/lib/db.py b/openlp/plugins/presentations/lib/db.py new file mode 100644 index 000000000..4e9642e88 --- /dev/null +++ b/openlp/plugins/presentations/lib/db.py @@ -0,0 +1,46 @@ +# -*- coding: utf-8 -*- + +########################################################################## +# OpenLP - Open Source Lyrics Projection # +# ---------------------------------------------------------------------- # +# Copyright (c) 2008-2021 OpenLP Developers # +# ---------------------------------------------------------------------- # +# 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 3 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, see . # +########################################################################## +""" +The :mod:`~openlp.plugins.presentations.lib.db` module contains the database layer for the presentations plugin +""" +from sqlalchemy import MetaData +from sqlalchemy.ext.declarative import declarative_base + +from openlp.core.lib.db import FolderMixin, ItemMixin, init_db, init_url + +Base = declarative_base(MetaData()) + + +class Folder(Base, FolderMixin): + """A folder holds items or other folders""" + + +class Item(Base, ItemMixin): + """An item is something that can be contained within a folder""" + + +def init_schema(*args, **kwargs): + """ + Set up the media database and initialise the schema + """ + session, metadata = init_db(init_url('presentations'), base=Base) + metadata.create_all(checkfirst=True) + return session diff --git a/openlp/plugins/presentations/lib/mediaitem.py b/openlp/plugins/presentations/lib/mediaitem.py index 9c8d3d1a7..087e4c790 100644 --- a/openlp/plugins/presentations/lib/mediaitem.py +++ b/openlp/plugins/presentations/lib/mediaitem.py @@ -26,14 +26,15 @@ from PyQt5 import QtCore, QtWidgets from pathlib import Path from openlp.core.common import sha256_file_hash -from openlp.core.common.i18n import UiStrings, get_natural_key, translate -from openlp.core.common.path import path_to_str +from openlp.core.common.i18n import UiStrings, translate from openlp.core.common.registry import Registry -from openlp.core.lib import ServiceItemContext, build_icon, check_item_selected, create_thumb, validate_thumb -from openlp.core.lib.mediamanageritem import MediaManagerItem +from openlp.core.lib import ServiceItemContext, build_icon, create_thumb, validate_thumb +from openlp.core.ui.library import FolderLibraryItem from openlp.core.lib.serviceitem import ItemCapabilities from openlp.core.lib.ui import create_horizontal_adjusting_combo_box, critical_error_message_box from openlp.core.ui.icons import UiIcons + +from openlp.plugins.presentations.lib.db import Folder, Item from openlp.plugins.presentations.lib.messagelistener import MessageListener from openlp.plugins.presentations.lib.pdfcontroller import PDF_CONTROLLER_FILETYPES @@ -41,7 +42,7 @@ from openlp.plugins.presentations.lib.pdfcontroller import PDF_CONTROLLER_FILETY log = logging.getLogger(__name__) -class PresentationMediaItem(MediaManagerItem): +class PresentationMediaItem(FolderLibraryItem): """ This is the Presentation media manager item for Presentation Items. It can present files using Openoffice and Powerpoint @@ -56,12 +57,13 @@ class PresentationMediaItem(MediaManagerItem): """ self.icon_path = 'presentations/presentation' self.controllers = controllers - super(PresentationMediaItem, self).__init__(parent, plugin) + super(PresentationMediaItem, self).__init__(parent, plugin, Folder, Item) def retranslate_ui(self): """ The name of the plugin media displayed in UI """ + super().retranslate_ui() self.on_new_prompt = translate('PresentationPlugin.MediaItem', 'Select Presentation(s)') self.automatic = translate('PresentationPlugin.MediaItem', 'Automatic') self.display_type_label.setText(translate('PresentationPlugin.MediaItem', 'Present using:')) @@ -80,35 +82,31 @@ class PresentationMediaItem(MediaManagerItem): # Allow DnD from the desktop self.list_view.activateDnD() - def build_file_mask_string(self): - """ - Build the list of file extensions to be used in the Open file dialog. - """ - file_type_string = '' - for controller in self.controllers: - if self.controllers[controller].enabled(): - file_types = self.controllers[controller].supports + self.controllers[controller].also_supports - for file_type in file_types: - if file_type not in file_type_string: - file_type_string += '*.{text} '.format(text=file_type) - file_type_string = file_type_string.strip() - self.service_manager.supported_suffixes(file_type_string.split(' ')) - self.on_new_file_masks = translate('PresentationPlugin.MediaItem', - 'Presentations ({text})').format(text=file_type_string) - def required_icons(self): """ Set which icons the media manager tab should show. """ - MediaManagerItem.required_icons(self) + super().required_icons() self.has_file_icon = True self.has_new_icon = False self.has_edit_icon = False + def initialise(self): + """ + Populate the media manager tab + """ + self.list_view.clear() + self.list_view.setIndentation(self.list_view.default_indentation) + self.list_view.allow_internal_dnd = True + self.list_view.setIconSize(QtCore.QSize(88, 50)) + self.load_list(self.manager.get_all_objects(Item, order_by_ref=Item.file_path), is_initial_load=True) + self.populate_display_types() + def add_middle_header_bar(self): """ Display custom media manager items for presentations. """ + super().add_middle_header_bar() self.presentation_widget = QtWidgets.QWidget(self) self.presentation_widget.setObjectName('presentation_widget') self.display_layout = QtWidgets.QFormLayout(self.presentation_widget) @@ -124,14 +122,21 @@ class PresentationMediaItem(MediaManagerItem): # Add the Presentation widget to the page layout. self.page_layout.addWidget(self.presentation_widget) - def initialise(self): + def build_file_mask_string(self): """ - Populate the media manager tab + Build the list of file extensions to be used in the Open file dialog. """ - self.list_view.setIconSize(QtCore.QSize(88, 50)) - file_paths = self.settings.value('presentations/presentations files') - self.load_list(file_paths, initial_load=True) - self.populate_display_types() + file_type_string = '' + for controller in self.controllers: + if self.controllers[controller].enabled(): + file_types = self.controllers[controller].supports + self.controllers[controller].also_supports + for file_type in file_types: + if file_type not in file_type_string: + file_type_string += '*.{text} '.format(text=file_type) + file_type_string = file_type_string.strip() + self.service_manager.supported_suffixes(file_type_string.split(' ')) + self.on_new_file_masks = translate('PresentationPlugin.MediaItem', + 'Presentations ({text})').format(text=file_type_string) def populate_display_types(self): """ @@ -154,96 +159,57 @@ class PresentationMediaItem(MediaManagerItem): else: self.presentation_widget.hide() - def load_list(self, file_paths, target_group=None, initial_load=False): + def load_item(self, item, is_initial_load=False): """ - Add presentations into the media manager. This is called both on initial load of the plugin to populate with - existing files, and when the user adds new files via the media manager. - - :param list[pathlib.Path] file_paths: List of file paths to add to the media manager. - :param list[pathlib.Path] target_group: Group to load. - :param boolean initial_load: Is this the initial load of the list at start up + Given an item object, return a QTreeWidgetItem """ - current_paths = self.get_file_list() - titles = [file_path.name for file_path in current_paths] - self.application.set_busy_cursor() - if not initial_load: - self.main_window.display_progress_bar(len(file_paths)) - # Sort the presentations by its filename considering language specific characters. - file_paths.sort(key=lambda file_path: get_natural_key(file_path.name)) - for file_path in file_paths: - if not initial_load: - self.main_window.increment_progress_bar() - if current_paths.count(file_path) > 0: - continue - file_name = file_path.name - if not file_path.exists(): - item_name = QtWidgets.QListWidgetItem(file_name) - item_name.setIcon(UiIcons().delete) - item_name.setData(QtCore.Qt.UserRole, file_path) - item_name.setToolTip(str(file_path)) - self.list_view.addItem(item_name) - else: - if titles.count(file_name) > 0: - if not initial_load: - critical_error_message_box(translate('PresentationPlugin.MediaItem', 'File Exists'), - translate('PresentationPlugin.MediaItem', - 'A presentation with that filename already exists.')) - continue - controller_name = self.find_controller_by_type(file_path) - if controller_name: - controller = self.controllers[controller_name] - doc = controller.add_document(file_path) - thumbnail_path = doc.get_thumbnail_folder() / 'icon.png' + tree_item = None + file_path = Path(item.file_path) + file_name = file_path.name + if not file_path.exists(): + tree_item = QtWidgets.QTreeWidgetItem([file_name]) + tree_item.setIcon(0, UiIcons().delete) + tree_item.setData(0, QtCore.Qt.UserRole, item) + tree_item.setToolTip(0, str(file_path)) + else: + controller_name = self.find_controller_by_type(file_path) + if controller_name: + controller = self.controllers[controller_name] + doc = controller.add_document(file_path) + thumbnail_path = doc.get_thumbnail_folder() / 'icon.png' + preview_path = doc.get_thumbnail_path(1, True) + if not preview_path and not is_initial_load: + doc.load_presentation() preview_path = doc.get_thumbnail_path(1, True) - if not preview_path and not initial_load: - doc.load_presentation() - preview_path = doc.get_thumbnail_path(1, True) - doc.close_presentation() - if not (preview_path and preview_path.exists()): - icon = UiIcons().delete - else: - if validate_thumb(preview_path, thumbnail_path): - icon = build_icon(thumbnail_path) - else: - icon = create_thumb(preview_path, thumbnail_path) + doc.close_presentation() + if not (preview_path and preview_path.exists()): + icon = UiIcons().delete else: - if initial_load: - icon = UiIcons().delete + if validate_thumb(preview_path, thumbnail_path): + icon = build_icon(thumbnail_path) else: - critical_error_message_box(UiStrings().UnsupportedFile, - translate('PresentationPlugin.MediaItem', - 'This type of presentation is not supported.')) - continue - item_name = QtWidgets.QListWidgetItem(file_name) - item_name.setData(QtCore.Qt.UserRole, file_path) - item_name.setIcon(icon) - item_name.setToolTip(str(file_path)) - self.list_view.addItem(item_name) - if not initial_load: - self.main_window.finished_progress_bar() - self.application.set_normal_cursor() + icon = create_thumb(preview_path, thumbnail_path) + else: + if is_initial_load: + icon = UiIcons().delete + else: + critical_error_message_box(UiStrings().UnsupportedFile, + translate('PresentationPlugin.MediaItem', + 'This type of presentation is not supported.')) + return None + tree_item = QtWidgets.QTreeWidgetItem([file_name]) + tree_item.setData(0, QtCore.Qt.UserRole, item) + tree_item.setIcon(0, icon) + tree_item.setToolTip(0, str(file_path)) + return tree_item - def on_delete_click(self): + def delete_item(self, item): """ Remove a presentation item from the list. """ - if check_item_selected(self.list_view, UiStrings().SelectDelete): - items = self.list_view.selectedIndexes() - row_list = [item.row() for item in items] - row_list.sort(reverse=True) - self.application.set_busy_cursor() - self.main_window.display_progress_bar(len(row_list)) - for item in items: - file_path = item.data(QtCore.Qt.UserRole) - # cleaning thumbnails depends on being able to calculate sha256 hash of the actual file - if file_path.exists(): - self.clean_up_thumbnails(file_path) - self.main_window.increment_progress_bar() - self.main_window.finished_progress_bar() - for row in row_list: - self.list_view.takeItem(row) - self.settings.setValue('presentations/presentations files', self.get_file_list()) - self.application.set_normal_cursor() + file_path = Path(item.file_path) + if file_path.exists(): + self.clean_up_thumbnails(file_path) def clean_up_thumbnails(self, file_path, clean_for_update=False): """ @@ -318,8 +284,10 @@ class PresentationMediaItem(MediaManagerItem): items = self.list_view.selectedItems() if len(items) > 1: return False + items = [self.list_view.itemFromIndex(item) if isinstance(item, QtCore.QModelIndex) else item + for item in items] if file_path is None: - file_path = Path(items[0].data(QtCore.Qt.UserRole)) + file_path = Path(items[0].data(0, QtCore.Qt.UserRole).file_path) file_type = file_path.suffix.lower()[1:] if not self.display_type_combo_box.currentText(): return False @@ -335,7 +303,7 @@ class PresentationMediaItem(MediaManagerItem): service_item.theme = -1 for bitem in items: if file_path is None: - file_path = bitem.data(QtCore.Qt.UserRole) + file_path = Path(bitem.data(0, QtCore.Qt.UserRole).file_path) path, file_name = file_path.parent, file_path.name service_item.title = file_name if file_path.exists(): @@ -372,7 +340,7 @@ class PresentationMediaItem(MediaManagerItem): service_item.processor = self.display_type_combo_box.currentText() service_item.add_capability(ItemCapabilities.ProvidesOwnDisplay) for bitem in items: - file_path = Path(bitem.data(QtCore.Qt.UserRole)) + file_path = Path(bitem.data(0, QtCore.Qt.UserRole).file_path) path, file_name = file_path.parent, file_path.name service_item.title = file_name if file_path.exists(): @@ -448,20 +416,3 @@ class PresentationMediaItem(MediaManagerItem): if file_type in self.controllers[controller].also_supports: return controller return None - - def search(self, string, show_error): - """ - Search in files - - :param string: name to be found - :param show_error: not used - :return: - """ - file_paths = self.settings.value('presentations/presentations files') - results = [] - string = string.lower() - for file_path in file_paths: - file_name = file_path.name - if file_name.lower().find(string) > -1: - results.append([path_to_str(file_path), file_name]) - return results diff --git a/openlp/plugins/presentations/presentationplugin.py b/openlp/plugins/presentations/presentationplugin.py index 5ac951eb2..7e86c7928 100644 --- a/openlp/plugins/presentations/presentationplugin.py +++ b/openlp/plugins/presentations/presentationplugin.py @@ -26,12 +26,15 @@ import logging import os -from openlp.core.common import extension_loader +from openlp.core.common import extension_loader, sha256_file_hash from openlp.core.common.i18n import translate from openlp.core.lib import build_icon +from openlp.core.lib.db import Manager from openlp.core.lib.plugin import Plugin, StringContent from openlp.core.state import State from openlp.core.ui.icons import UiIcons + +from openlp.plugins.presentations.lib.db import Item, init_schema from openlp.plugins.presentations.lib.presentationcontroller import PresentationController from openlp.plugins.presentations.lib.mediaitem import PresentationMediaItem from openlp.plugins.presentations.lib.presentationtab import PresentationTab @@ -51,12 +54,13 @@ class PresentationPlugin(Plugin): """ PluginPresentation constructor. """ - log.debug('Initialised') - self.controllers = {} - Plugin.__init__(self, 'presentations', None) + super().__init__('presentations', PresentationMediaItem) + self.manager = Manager(plugin_name='media', init_schema=init_schema) self.weight = -8 self.icon_path = UiIcons().presentation self.icon = build_icon(self.icon_path) + self.controllers = {} + self.dnd_id = 'Presentations' State().add_service('presentation', self.weight, is_plugin=True) State().update_pre_conditions('presentation', self.check_pre_conditions()) @@ -73,7 +77,25 @@ class PresentationPlugin(Plugin): Initialise the plugin. Determine which controllers are enabled are start their processes. """ log.info('Presentations Initialising') - super(PresentationPlugin, self).initialise() + # Check if the thumbnail scheme needs to be updated + has_old_scheme = False + if self.settings.value('presentations/thumbnail_scheme') != 'sha256file': + self.settings.setValue('presentations/thumbnail_scheme', 'sha256file') + has_old_scheme = True + # Migrate each file + presentation_paths = self.settings.value('presentations/presentations files') or [] + for path in presentation_paths: + # check to see if the file exists before trying to process it. + if not path.exists(): + continue + item = Item(name=path.name, file_path=str(path)) + self.media_item.clean_up_thumbnails(path, clean_for_update=True) + item.file_hash = sha256_file_hash(path) + if has_old_scheme: + self.media_item.update_thumbnail_scheme(path) + self.manager.save_object(item) + self.settings.remove('presentations/presentations files') + super().initialise() for controller in self.controllers: if self.controllers[controller].enabled(): try: @@ -128,26 +150,6 @@ class PresentationPlugin(Plugin): self.register_controllers(controller) return bool(self.controllers) - def app_startup(self): - """ - Perform tasks on application startup. - """ - # TODO: Can be removed when the upgrade path to OpenLP 3.0 is no longer needed, also ensure code in - # PresentationDocument.get_thumbnail_folder and PresentationDocument.get_temp_folder is removed - super().app_startup() - presentation_paths = self.settings.value('presentations/presentations files') - for path in presentation_paths: - # check to see if the file exists before trying to process it. - if path.exists(): - self.media_item.clean_up_thumbnails(path, clean_for_update=True) - self.media_item.list_view.clear() - # Update the thumbnail scheme if needed - if self.settings.value('presentations/thumbnail_scheme') != 'sha256file': - for path in presentation_paths: - self.media_item.update_thumbnail_scheme(path) - self.settings.setValue('presentations/thumbnail_scheme', 'sha256file') - self.media_item.validate_and_load(presentation_paths) - @staticmethod def about(): """ diff --git a/tests/openlp_plugins/bibles/test_manager.py b/tests/openlp_plugins/bibles/test_manager.py index 4efed5d8c..cf717c3e0 100644 --- a/tests/openlp_plugins/bibles/test_manager.py +++ b/tests/openlp_plugins/bibles/test_manager.py @@ -36,9 +36,9 @@ def test_delete_bible(settings): patch('openlp.plugins.bibles.lib.manager.delete_file', return_value=True) as mocked_delete_file: instance = BibleManager(MagicMock()) # We need to keep a reference to the mock for close_all as it gets set to None later on! - mocked_close_all = MagicMock() + mocked_close = MagicMock() mocked_bible = MagicMock(file_path='KJV.sqlite', path=Path('bibles'), - **{'session.close_all': mocked_close_all}) + **{'session.close': mocked_close}) instance.db_cache = {'KJV': mocked_bible} # WHEN: Calling delete_bible with 'KJV' @@ -47,6 +47,6 @@ def test_delete_bible(settings): # THEN: The session should have been closed and set to None, the bible should be deleted, and the result of # the deletion returned. assert result is True - mocked_close_all.assert_called_once_with() + mocked_close.assert_called_once_with() assert mocked_bible.session is None mocked_delete_file.assert_called_once_with(Path('bibles') / 'KJV.sqlite') diff --git a/tests/openlp_plugins/media/test_mediaitem.py b/tests/openlp_plugins/media/test_mediaitem.py index 90fac5f02..554da47dc 100644 --- a/tests/openlp_plugins/media/test_mediaitem.py +++ b/tests/openlp_plugins/media/test_mediaitem.py @@ -23,9 +23,11 @@ Test the media plugin """ import pytest from pathlib import Path -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock, call, patch +from openlp.core.lib import MediaType from openlp.core.common.registry import Registry +from openlp.core.ui.media import AUDIO_EXT, VIDEO_EXT from openlp.plugins.media.lib.mediaitem import MediaMediaItem @@ -37,9 +39,10 @@ def media_item(settings): Registry().register('main_window', mocked_main_window) Registry().register('live_controller', MagicMock()) mocked_plugin = MagicMock() - with patch('openlp.plugins.media.lib.mediaitem.MediaManagerItem._setup'), \ + with patch('openlp.plugins.media.lib.mediaitem.FolderLibraryItem._setup'), \ patch('openlp.plugins.media.lib.mediaitem.MediaMediaItem.setup_item'): m_item = MediaMediaItem(None, mocked_plugin) + m_item.manager = MagicMock() return m_item @@ -48,21 +51,11 @@ def test_search_found(media_item): Media Remote Search Successful find """ # GIVEN: The Mediaitem set up a list of media - media_item.settings.setValue('media/media files', [Path('test.mp3'), Path('test.mp4')]) + media_item.manager.get_all_objects.return_value = [MagicMock(file_path='test.mp4')] + # WHEN: Retrieving the test file result = media_item.search('test.mp4', False) - # THEN: a file should be found - assert result == [['test.mp4', 'test.mp4']], 'The result file contain the file name' - -def test_search_found_mixed(media_item): - """ - Media Remote Search Successful find with Paths and Strings - """ - # GIVEN: The Mediaitem set up a list of media - media_item.settings.setValue('media/media files', [Path('test.mp3'), 'test.mp4']) - # WHEN: Retrieving the test file - result = media_item.search('test.mp4', False) # THEN: a file should be found assert result == [['test.mp4', 'test.mp4']], 'The result file contain the file name' @@ -72,8 +65,91 @@ def test_search_not_found(media_item): Media Remote Search not find """ # GIVEN: The Mediaitem set up a list of media - media_item.settings.setValue('media/media files', [Path('test.mp3'), Path('test.mp4')]) + media_item.manager.get_all_objects.return_value = [] + # WHEN: Retrieving the test file result = media_item.search('test.mpx', False) + # THEN: a file should be found assert result == [], 'The result file should be empty' + + +@patch('openlp.plugins.media.lib.mediaitem.or_') +@patch('openlp.plugins.media.lib.mediaitem.Item') +def test_get_list_audio(MockItem, mocked_or, media_item): + """ + Test the ``MediaMediaItem.get_list()`` method to return all audio items + """ + # GIVEN: A mocked Item class and some mocked items to return + media_item.manager.get_all_objects.return_value = [MagicMock(file_path='test1.mp3'), + MagicMock(file_path='test2.ogg')] + MockItem.file_path.endswith.side_effect = lambda ext: ext + mocked_or.side_effect = lambda *ext: list(ext) + + # WHEN: get_list() is called + media_list = media_item.get_list(MediaType.Audio) + + # THEN: All the clauses should have been created, and the right extensions used + expected_extensions = [ext[1:] for ext in AUDIO_EXT] + expected_calls = [call(ext) for ext in expected_extensions] + MockItem.file_path.endswith.assert_has_calls(expected_calls) + mocked_or.assert_called_once_with(*expected_extensions) + media_item.manager.get_all_objects.assert_called_once_with(MockItem, expected_extensions) + assert media_list == [Path('test1.mp3'), Path('test2.ogg')] + + +@patch('openlp.plugins.media.lib.mediaitem.or_') +@patch('openlp.plugins.media.lib.mediaitem.Item') +def test_get_list_video(MockItem, mocked_or, media_item): + """ + Test the ``MediaMediaItem.get_list()`` method to return all audio items + """ + # GIVEN: A mocked Item class and some mocked items to return + media_item.manager.get_all_objects.return_value = [MagicMock(file_path='test1.mp4'), + MagicMock(file_path='test2.ogv')] + MockItem.file_path.endswith.side_effect = lambda ext: ext + mocked_or.side_effect = lambda *ext: list(ext) + + # WHEN: get_list() is called + media_list = media_item.get_list(MediaType.Video) + + # THEN: All the clauses should have been created, and the right extensions used + expected_extensions = [ext[1:] for ext in VIDEO_EXT] + expected_calls = [call(ext) for ext in expected_extensions] + MockItem.file_path.endswith.assert_has_calls(expected_calls) + mocked_or.assert_called_once_with(*expected_extensions) + media_item.manager.get_all_objects.assert_called_once_with(MockItem, expected_extensions) + assert media_list == [Path('test1.mp4'), Path('test2.ogv')] + + +def test_add_optical_clip(media_item): + """Test that the ``MediaMediaItem.add_optical_clip()`` method calls validate_and_load()""" + # GIVEN: A MediaMediaItem object, with a mocked out `validate_and_load` method + with patch.object(media_item, 'validate_and_load') as mocked_validate_and_load: + # WHEN: add_optical_clip is called + media_item.add_optical_clip('optical:/dev/sr0') + + # THEN: The mocked method should have been called + mocked_validate_and_load.assert_called_once_with(['optical:/dev/sr0']) + + +def test_add_device_stream(media_item): + """Test that the ``MediaMediaItem.add_device_stream()`` method calls validate_and_load()""" + # GIVEN: A MediaMediaItem object, with a mocked out `validate_and_load` method + with patch.object(media_item, 'validate_and_load') as mocked_validate_and_load: + # WHEN: add_device_stream is called + media_item.add_device_stream('devicestream:/dev/v4l') + + # THEN: The mocked method should have been called + mocked_validate_and_load.assert_called_once_with(['devicestream:/dev/v4l']) + + +def test_add_network_stream(media_item): + """Test that the ``MediaMediaItem.add_network_stream()`` method calls validate_and_load()""" + # GIVEN: A MediaMediaItem object, with a mocked out `validate_and_load` method + with patch.object(media_item, 'validate_and_load') as mocked_validate_and_load: + # WHEN: add_network_stream is called + media_item.add_network_stream('networkstream:rmtp://localhost') + + # THEN: The mocked method should have been called + mocked_validate_and_load.assert_called_once_with(['networkstream:rmtp://localhost']) diff --git a/tests/openlp_plugins/presentations/conftest.py b/tests/openlp_plugins/presentations/conftest.py index b910058fe..beea363a5 100644 --- a/tests/openlp_plugins/presentations/conftest.py +++ b/tests/openlp_plugins/presentations/conftest.py @@ -29,13 +29,13 @@ from openlp.plugins.presentations.lib.mediaitem import PresentationMediaItem @pytest.fixture -def media_item(settings): +def media_item(settings, mock_plugin): """Local test setup""" Registry().register('service_manager', MagicMock()) Registry().register('main_window', MagicMock()) - with patch('openlp.plugins.presentations.lib.mediaitem.MediaManagerItem._setup'), \ + with patch('openlp.plugins.presentations.lib.mediaitem.FolderLibraryItem._setup'), \ patch('openlp.plugins.presentations.lib.mediaitem.PresentationMediaItem.setup_item'): - m_item = PresentationMediaItem(None, MagicMock, MagicMock()) + m_item = PresentationMediaItem(None, mock_plugin, MagicMock()) m_item.settings_section = 'media' return m_item @@ -44,4 +44,5 @@ def media_item(settings): def mock_plugin(temp_folder): m_plugin = MagicMock() m_plugin.settings_section = temp_folder + m_plugin.manager = MagicMock() yield m_plugin diff --git a/tests/openlp_plugins/presentations/test_mediaitem.py b/tests/openlp_plugins/presentations/test_mediaitem.py index f537f555b..93367049a 100644 --- a/tests/openlp_plugins/presentations/test_mediaitem.py +++ b/tests/openlp_plugins/presentations/test_mediaitem.py @@ -24,7 +24,6 @@ This module contains tests for the lib submodule of the Presentations plugin. from pathlib import Path from unittest.mock import MagicMock, PropertyMock, call, patch -from openlp.core.common.registry import Registry from openlp.core.lib import ServiceItemContext from openlp.core.lib.serviceitem import ItemCapabilities from openlp.plugins.presentations.lib.mediaitem import PresentationMediaItem @@ -146,24 +145,37 @@ def test_pdf_generate_slide_data(media_item): mocked_service_item.add_capability.assert_any_call(ItemCapabilities.ProvidesOwnTheme) -@patch('openlp.plugins.presentations.lib.mediaitem.MediaManagerItem._setup') +@patch('openlp.plugins.presentations.lib.mediaitem.FolderLibraryItem._setup') @patch('openlp.plugins.presentations.lib.mediaitem.PresentationMediaItem.setup_item') -def test_search(mock_setup, mock_item, registry): +def test_search_found(mock_setup, mock_item, registry): """ - Test that the search method finds the correct results + Test that the search method works correctly """ - # GIVEN: A mocked Settings class which returns a list of Path objects, - # and an instance of the PresentationMediaItem - path_1 = Path('some_dir', 'Impress_file_1') - path_2 = Path('some_other_dir', 'impress_file_2') - path_3 = Path('another_dir', 'ppt_file') - mocked_returned_settings = MagicMock() - mocked_returned_settings.value.return_value = [path_1, path_2, path_3] - Registry().register('settings', mocked_returned_settings) + # GIVEN: The Mediaitem set up a list of presentations media_item = PresentationMediaItem(None, MagicMock(), None) + media_item.manager = MagicMock() + media_item.manager.get_all_objects.return_value = [MagicMock(file_path='test.odp')] - # WHEN: Calling search - results = media_item.search('IMPRE', False) + # WHEN: Retrieving the test file + result = media_item.search('test.odp', False) - # THEN: The first two results should have been returned - assert results == [[str(path_1), 'Impress_file_1'], [str(path_2), 'impress_file_2']] + # THEN: a file should be found + assert result == [['test.odp', 'test.odp']], 'The result file contain the file name' + + +@patch('openlp.plugins.presentations.lib.mediaitem.FolderLibraryItem._setup') +@patch('openlp.plugins.presentations.lib.mediaitem.PresentationMediaItem.setup_item') +def test_search_not_found(mock_setup, mock_item, registry): + """ + Test that the search doesn't find anything + """ + # GIVEN: The Mediaitem set up a list of media + media_item = PresentationMediaItem(None, MagicMock(), None) + media_item.manager = MagicMock() + media_item.manager.get_all_objects.return_value = [] + + # WHEN: Retrieving the test file + result = media_item.search('test.pptx', False) + + # THEN: a file should be found + assert result == [], 'The result file should be empty'