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
This commit is contained in:
Raoul Snyman 2020-10-10 23:41:03 -07:00
parent b5a5cf82c4
commit 4d3ade20c7
Signed by: raoul
GPG Key ID: F55BCED79626AE9C
21 changed files with 1378 additions and 418 deletions

View File

@ -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')

View File

@ -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,

View File

@ -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

View File

@ -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,

View File

@ -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.

302
openlp/core/ui/folders.py Normal file
View File

@ -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 <https://www.gnu.org/licenses/>. #
##########################################################################
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()

View File

@ -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'},

387
openlp/core/ui/library.py Normal file
View File

@ -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 <https://www.gnu.org/licenses/>. #
##########################################################################
"""
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)

View File

@ -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):
"""

View File

@ -18,6 +18,7 @@
# You should have received a copy of the GNU General Public License #
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
##########################################################################
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):

View File

@ -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

View File

@ -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 <https://www.gnu.org/licenses/>. #
##########################################################################
"""
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

View File

@ -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)])

View File

@ -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.

View File

@ -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 <https://www.gnu.org/licenses/>. #
##########################################################################
"""
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

View File

@ -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

View File

@ -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():
"""

View File

@ -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')

View File

@ -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'])

View File

@ -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

View File

@ -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'