Merge lp:~tomasgroth/openlp/presentation-load-speedup into lp:openlp

Proposed by Tomas Groth
Status: Needs review
Proposed branch: lp:~tomasgroth/openlp/presentation-load-speedup
Merge into: lp:openlp
Diff against target: 964 lines (+279/-81)
12 files modified
openlp/core/common/__init__.py (+15/-0)
openlp/core/lib/serviceitem.py (+80/-19)
openlp/core/ui/servicemanager.py (+77/-29)
openlp/plugins/images/lib/db.py (+3/-1)
openlp/plugins/images/lib/mediaitem.py (+3/-2)
openlp/plugins/images/lib/upgrade.py (+22/-3)
openlp/plugins/presentations/lib/mediaitem.py (+2/-1)
openlp/plugins/presentations/lib/presentationcontroller.py (+29/-4)
openlp/plugins/presentations/presentationplugin.py (+1/-1)
tests/functional/openlp_core/lib/test_serviceitem.py (+24/-14)
tests/functional/openlp_plugins/images/test_lib.py (+19/-5)
tests/interfaces/openlp_core/widgets/test_views.py (+4/-2)
To merge this branch: bzr merge lp:~tomasgroth/openlp/presentation-load-speedup
Reviewer Review Type Date Requested Status
Raoul Snyman Needs Information
Review via email: mp+367933@code.launchpad.net

Commit message

WIP!
Fundamental change to how service files are storing data.

Description of the change

WIP!
Fundamental change to how service files are storing data.
When we add a file to a service file, instead of adding it with its name,
we should rename it so its name reflects the sha256 checksum of the
filecontent. So is a file is called "church-presentation.ppt" it is saved
as "505911856c191c5a7c4a0dc4858a8627e21b7171a1d9189bd8bb39090eac982f.ppt",
and no path/folders will be included. The new filename will also be added
to the service-item data. Doing this will avoid any problems with paths,
and if any files has the same hash it will be because they are the same, so
no duplicate content.
Avoiding folders from included files also allow us to use folders for
something else, namely thumbnails (for presentations). So reusing the the
example above, we could create a folder in the servicefile named
"505911856c191c5a7c4a0dc4858a8627e21b7171a1d9189bd8bb39090eac982f" which
contains all the thumbnails (and slide notes and titles), which can then be
reused when loading the servicefile, thereby skipping the step of
recreating the thumbnails (and thereby fixing bug #1677037).

To post a comment you must log in.
Revision history for this message
Raoul Snyman (raoul-snyman) wrote :

Linux tests failed, please see https://ci.openlp.io/job/MP-02-Linux_Tests/170/ for more details

2829. By Tomas Groth

A few fixes

Revision history for this message
Phill (phill-ridout) wrote :

Just a comment on implementation.

2830. By Tomas Groth

merge trunk

2831. By Tomas Groth

Added preliminary support for saving image thumbnails in servicefiles.

Revision history for this message
Raoul Snyman (raoul-snyman) wrote :

Just a question about os.path vs Path

review: Needs Information
2832. By Tomas Groth

Make image plugin use hashes for id and thumbnails

2833. By Tomas Groth

merge trunk

2834. By Tomas Groth

merge trunk

2835. By Tomas Groth

More work on saving and loading images to servicefile with new file hashes.

2836. By Tomas Groth

minor stuff

2837. By Tomas Groth

merge trunk

2838. By Tomas Groth

Loading presentation thumbnails from servicefiles now works. Also introduced a new service file header attribute: stored_filename.

2839. By Tomas Groth

merge trunk

2840. By Tomas Groth

Change tests to match new implementation.

2841. By Tomas Groth

merge trunk

Unmerged revisions

2841. By Tomas Groth

merge trunk

2840. By Tomas Groth

Change tests to match new implementation.

2839. By Tomas Groth

merge trunk

2838. By Tomas Groth

Loading presentation thumbnails from servicefiles now works. Also introduced a new service file header attribute: stored_filename.

2837. By Tomas Groth

merge trunk

2836. By Tomas Groth

minor stuff

2835. By Tomas Groth

More work on saving and loading images to servicefile with new file hashes.

2834. By Tomas Groth

merge trunk

2833. By Tomas Groth

merge trunk

2832. By Tomas Groth

Make image plugin use hashes for id and thumbnails

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'openlp/core/common/__init__.py'
2--- openlp/core/common/__init__.py 2019-07-27 06:37:16 +0000
3+++ openlp/core/common/__init__.py 2019-09-15 09:05:37 +0000
4@@ -282,6 +282,21 @@
5 return hash_value
6
7
8+def sha256_file_hash(filename):
9+ """
10+ Returns the hashed output of sha256 on the file content using Python3 hashlib
11+
12+ :param filename: Name of the file to hash
13+ :returns: str
14+ """
15+ log.debug('sha256_hash(filename="{filename}")'.format(filename=filename))
16+ hash_obj = hashlib.sha256()
17+ with open(filename, 'rb') as f:
18+ for chunk in iter(lambda: f.read(65536), b''):
19+ hash_obj.update(chunk)
20+ return hash_obj.hexdigest()
21+
22+
23 def qmd5_hash(salt=None, data=None):
24 """
25 Returns the hashed output of MD5Sum on salt, data
26
27=== modified file 'openlp/core/lib/serviceitem.py'
28--- openlp/core/lib/serviceitem.py 2019-08-30 21:32:26 +0000
29+++ openlp/core/lib/serviceitem.py 2019-09-15 09:05:37 +0000
30@@ -30,11 +30,12 @@
31 import uuid
32 from copy import deepcopy
33 from pathlib import Path
34+from shutil import copytree
35
36 from PyQt5 import QtGui
37
38 from openlp.core.state import State
39-from openlp.core.common import md5_hash
40+from openlp.core.common import sha256_file_hash
41 from openlp.core.common.applocation import AppLocation
42 from openlp.core.common.i18n import translate
43 from openlp.core.common.mixins import RegistryProperties
44@@ -113,6 +114,8 @@
45 self.timed_slide_interval = 0
46 self.will_auto_start = False
47 self.has_original_files = True
48+ self.sha256_file_hash = None
49+ self.stored_filename = None
50 self._new_item()
51 self.metadata = []
52
53@@ -238,7 +241,7 @@
54 self._print_slides.append(slide)
55 return self._print_slides
56
57- def add_from_image(self, path, title, background=None, thumbnail=None):
58+ def add_from_image(self, path, title, background=None, thumbnail=None, file_hash=None):
59 """
60 Add an image slide to the service item.
61
62@@ -250,7 +253,9 @@
63 if background:
64 self.image_border = background
65 self.service_item_type = ServiceItemType.Image
66- slide = {'title': title, 'path': path}
67+ if not file_hash:
68+ file_hash = sha256_file_hash(path)
69+ slide = {'title': title, 'path': path, 'file_hash': file_hash}
70 if thumbnail:
71 slide['thumbnail'] = thumbnail
72 self.slides.append(slide)
73@@ -274,7 +279,7 @@
74 self.slides.append({'title': title, 'text': text, 'verse': verse_tag})
75 self._new_item()
76
77- def add_from_command(self, path, file_name, image, display_title=None, notes=None):
78+ def add_from_command(self, path, file_name, image, display_title=None, notes=None, file_hash=None):
79 """
80 Add a slide from a command.
81
82@@ -283,18 +288,23 @@
83 :param image: The command of/for the slide.
84 :param display_title: Title to show in gui/webinterface, optional.
85 :param notes: Notes to show in the webinteface, optional.
86+ :param file_hash: Sha256 hash checksum of the file.
87 """
88 self.service_item_type = ServiceItemType.Command
89 # If the item should have a display title but this frame doesn't have one, we make one up
90 if self.is_capable(ItemCapabilities.HasDisplayTitle) and not display_title:
91 display_title = translate('OpenLP.ServiceItem',
92 '[slide {frame:d}]').format(frame=len(self.slides) + 1)
93+ if file_hash:
94+ self.sha256_file_hash = file_hash
95+ else:
96+ file_location = Path(path) / file_name
97+ self.sha256_file_hash = sha256_file_hash(file_location)
98+ self.stored_filename = '{hash}{ext}'.format(hash=self.sha256_file_hash, ext=os.path.splitext(file_name)[1])
99 # Update image path to match servicemanager location if file was loaded from service
100 if image and not self.has_original_files and self.name == 'presentations':
101- file_location = os.path.join(path, file_name)
102- file_location_hash = md5_hash(file_location.encode('utf-8'))
103- image = os.path.join(AppLocation.get_section_data_path(self.name), 'thumbnails', file_location_hash,
104- ntpath.basename(image)) # TODO: Pathlib
105+ image = os.path.join(str(AppLocation.get_section_data_path(self.name)), 'thumbnails',
106+ self.sha256_file_hash, ntpath.basename(image))
107 self.slides.append({'title': file_name, 'image': image, 'path': path, 'display_title': display_title,
108 'notes': notes, 'thumbnail': image})
109 # if self.is_capable(ItemCapabilities.HasThumbnails):
110@@ -305,6 +315,10 @@
111 """
112 This method returns some text which can be saved into the service file to represent this item.
113 """
114+ if self.sha256_file_hash:
115+ stored_filename = '{hash}{ext}'.format(hash=self.sha256_file_hash, ext=os.path.splitext(self.title)[1])
116+ else:
117+ stored_filename = None
118 service_header = {
119 'name': self.name,
120 'plugin': self.name,
121@@ -329,7 +343,9 @@
122 'theme_overwritten': self.theme_overwritten,
123 'will_auto_start': self.will_auto_start,
124 'processor': self.processor,
125- 'metadata': self.metadata
126+ 'metadata': self.metadata,
127+ 'sha256_file_hash': self.sha256_file_hash,
128+ 'stored_filename': stored_filename
129 }
130 service_data = []
131 if self.service_item_type == ServiceItemType.Text:
132@@ -341,12 +357,16 @@
133 elif self.service_item_type == ServiceItemType.Image:
134 if lite_save:
135 for slide in self.slides:
136- service_data.append({'title': slide['title'], 'path': slide['path']})
137+ service_data.append({'title': slide['title'], 'path': slide['path'],
138+ 'file_hash': slide['file_hash']})
139 else:
140- service_data = [slide['title'] for slide in self.slides]
141+ for slide in self.slides:
142+ image_path = os.path.relpath(slide['thumbnail'], AppLocation().get_data_path())
143+ service_data.append({'title': slide['title'], 'image': image_path, 'file_hash': slide['file_hash']})
144 elif self.service_item_type == ServiceItemType.Command:
145 for slide in self.slides:
146- service_data.append({'title': slide['title'], 'image': slide['image'], 'path': slide['path'],
147+ image_path = os.path.relpath(slide['image'], AppLocation().get_data_path())
148+ service_data.append({'title': slide['title'], 'image': image_path, 'path': slide['path'],
149 'display_title': slide['display_title'], 'notes': slide['notes']})
150 return {'header': service_header, 'data': service_data}
151
152@@ -357,7 +377,7 @@
153 self._display_slides = []
154 self._rendered_slides = []
155
156- def set_from_service(self, service_item, path=None):
157+ def set_from_service(self, service_item, path=None, version=2):
158 """
159 This method takes a service item from a saved service file (passed from the ServiceManager) and extracts the
160 data actually required.
161@@ -365,6 +385,7 @@
162 :param service_item: The item to extract data from.
163 :param path: Defaults to *None*. This is the service manager path for things which have their files saved
164 with them or None when the saved service is lite and the original file paths need to be preserved.
165+ :param version: Format version of the data.
166 """
167 log.debug('set_from_service called with path {path}'.format(path=path))
168 header = service_item['serviceitem']['header']
169@@ -392,6 +413,8 @@
170 self.processor = header.get('processor', None)
171 self.has_original_files = True
172 self.metadata = header.get('item_meta_data', [])
173+ self.sha256_file_hash = header.get('sha256_file_hash', None)
174+ self.stored_filename = header.get('stored_filename', self.title)
175 if 'background_audio' in header and State().check_preconditions('media'):
176 self.background_audio = []
177 for file_path in header['background_audio']:
178@@ -412,11 +435,24 @@
179 if path:
180 self.has_original_files = False
181 for text_image in service_item['serviceitem']['data']:
182- file_path = path / text_image
183- self.add_from_image(file_path, text_image, background)
184+ file_hash = None
185+ if version >= 3:
186+ text = text_image['title']
187+ file_hash = text_image['file_hash']
188+ file_path = path / '{base}{ext}'.format(base=file_hash, ext=os.path.splitext(text)[1])
189+ else:
190+ text = text_image
191+ file_path = path / text
192+ self.add_from_image(file_path, text, background, file_hash=file_hash)
193 else:
194 for text_image in service_item['serviceitem']['data']:
195- self.add_from_image(text_image['path'], text_image['title'], background)
196+ file_hash = None
197+ if version >= 3:
198+ text = text_image['title']
199+ file_hash = text_image['file_hash']
200+ else:
201+ text = text_image
202+ self.add_from_image(text_image['path'], text, background, file_hash=file_hash)
203 elif self.service_item_type == ServiceItemType.Command:
204 for text_image in service_item['serviceitem']['data']:
205 if not self.title:
206@@ -426,10 +462,22 @@
207 self.add_from_command(text_image['path'], text_image['title'], text_image['image'])
208 elif path:
209 self.has_original_files = False
210+ # Copy any bundled thumbnails into the plugin thumbnail folder
211+ if version >= 3 and os.path.exists(path / self.sha256_file_hash) and \
212+ os.path.isdir(path / self.sha256_file_hash):
213+ try:
214+ copytree(path / self.sha256_file_hash,
215+ AppLocation.get_section_data_path(self.name) / 'thumbnails' /
216+ self.sha256_file_hash)
217+ except FileExistsError:
218+ # Files already exists, just skip
219+ pass
220 self.add_from_command(path, text_image['title'], text_image['image'],
221- text_image.get('display_title', ''), text_image.get('notes', ''))
222+ text_image.get('display_title', ''), text_image.get('notes', ''),
223+ file_hash=self.sha256_file_hash)
224 else:
225- self.add_from_command(Path(text_image['path']), text_image['title'], text_image['image'])
226+ self.add_from_command(Path(text_image['path']), text_image['title'], text_image['image'],
227+ file_hash=self.sha256_file_hash)
228 self._new_item()
229
230 def get_display_title(self):
231@@ -565,6 +613,8 @@
232 return ''
233 if self.is_image() or self.is_capable(ItemCapabilities.IsOptical):
234 path_from = frame['path']
235+ elif self.is_command():
236+ path_from = os.path.join(frame['path'], self.stored_filename)
237 else:
238 path_from = os.path.join(frame['path'], frame['title'])
239 if isinstance(path_from, str):
240@@ -645,7 +695,7 @@
241 self.is_valid = False
242 break
243 else:
244- file_name = os.path.join(slide['path'], slide['title'])
245+ file_name = os.path.join(slide['path'], self.stored_filename)
246 if not os.path.exists(file_name):
247 self.is_valid = False
248 break
249@@ -654,3 +704,14 @@
250 if file_suffix.lower() not in suffixes:
251 self.is_valid = False
252 break
253+
254+ def get_thumbnail_path(self):
255+ """
256+ Returns the thumbnail folder. Should only be used for items that support thumbnails.
257+ """
258+ if self.is_capable(ItemCapabilities.HasThumbnails):
259+ if self.is_command() and self.slides:
260+ return os.path.dirname(self.slides[0]['image'])
261+ elif self.is_image() and self.slides:
262+ return os.path.dirname(self.slides[0]['thumbnail'])
263+ return None
264
265=== modified file 'openlp/core/ui/servicemanager.py'
266--- openlp/core/ui/servicemanager.py 2019-08-30 11:28:10 +0000
267+++ openlp/core/ui/servicemanager.py 2019-09-15 09:05:37 +0000
268@@ -34,8 +34,8 @@
269
270 from PyQt5 import QtCore, QtGui, QtWidgets
271
272+from openlp.core.common import ThemeLevel, delete_file, sha256_file_hash
273 from openlp.core.state import State
274-from openlp.core.common import ThemeLevel, delete_file
275 from openlp.core.common.actions import ActionList, CategoryOrder
276 from openlp.core.common.applocation import AppLocation
277 from openlp.core.common.i18n import UiStrings, format_time, translate
278@@ -43,10 +43,10 @@
279 from openlp.core.common.mixins import LogMixin, RegistryProperties
280 from openlp.core.common.registry import Registry, RegistryBase
281 from openlp.core.common.settings import Settings
282-from openlp.core.lib import build_icon
283+from openlp.core.lib import build_icon, ItemCapabilities
284 from openlp.core.lib.exceptions import ValidationError
285 from openlp.core.lib.plugin import PluginStatus
286-from openlp.core.lib.serviceitem import ItemCapabilities, ServiceItem, ServiceItemType
287+from openlp.core.lib.serviceitem import ServiceItem, ServiceItemType
288 from openlp.core.lib.ui import create_widget_action, critical_error_message_box, find_and_set_in_combo_box
289 from openlp.core.ui.icons import UiIcons
290 from openlp.core.ui.media import AUDIO_EXT, VIDEO_EXT
291@@ -331,6 +331,7 @@
292 self._service_path = None
293 self.service_has_all_original_files = True
294 self.list_double_clicked = False
295+ self.servicefile_version = None
296
297 def bootstrap_initialise(self):
298 """
299@@ -516,9 +517,15 @@
300 :return: service array
301 """
302 service = []
303+ # Regarding openlp-servicefile-version:
304+ # 1: OpenLP 1? Not used.
305+ # 2: OpenLP 2 (default when loading a service file without openlp-servicefile-version)
306+ # 3: The new format introduced in OpenLP 3.0.
307+ # Note that the servicefile-version numbering is not expected to follow the OpenLP version numbering.
308 core = {
309 'lite-service': self._save_lite,
310- 'service-theme': self.service_theme
311+ 'service-theme': self.service_theme,
312+ 'openlp-servicefile-version': 3
313 }
314 service.append({'openlp_core': core})
315 return service
316@@ -536,16 +543,38 @@
317 if item['service_item'].uses_file():
318 for frame in item['service_item'].get_frames():
319 path_from = item['service_item'].get_frame_path(frame=frame)
320- if path_from in write_list or path_from in missing_list:
321+ path_from_path = Path(path_from)
322+ if item['service_item'].stored_filename:
323+ sha256_file_name = item['service_item'].stored_filename
324+ else:
325+ sha256_file_name = sha256_file_hash(path_from_path) + os.path.splitext(path_from)[1]
326+ path_from_tuple = (path_from_path, sha256_file_name)
327+ if path_from_tuple in write_list or path_from_path in missing_list:
328 continue
329 if not os.path.exists(path_from):
330- missing_list.append(Path(path_from))
331+ missing_list.append(path_from_path)
332 else:
333- write_list.append(Path(path_from))
334+ write_list.append(path_from_tuple)
335+ # For items that has thumbnails, add them to the list
336+ if item['service_item'].is_capable(ItemCapabilities.HasThumbnails):
337+ if item['service_item'].is_command():
338+ thumbnail_path = item['service_item'].get_thumbnail_path()
339+ # Run through everything in the thumbnail folder and add pictures
340+ thumbnail_path_parent = Path(thumbnail_path).parent
341+ for filename in os.listdir(thumbnail_path):
342+ # Skip non-pictures
343+ if os.path.splitext(filename)[1] not in ['.png', '.jpg']:
344+ continue
345+ filename_path = Path(thumbnail_path) / Path(filename)
346+ # Create a thumbnail path in the zip/service file
347+ service_path = filename_path.relative_to(thumbnail_path_parent)
348+ write_list.append((filename_path, service_path))
349+ # TODO: For images only copy the relevant thumbnails, not all thumbnails, skipped for now
350 for audio_path in item['service_item'].background_audio:
351- if audio_path in write_list:
352+ audio_path_tuple = (audio_path, audio_path)
353+ if audio_path_tuple in write_list:
354 continue
355- write_list.append(audio_path)
356+ write_list.append(audio_path_tuple)
357 return write_list, missing_list
358
359 def save_file(self):
360@@ -567,7 +596,6 @@
361
362 if not self._save_lite:
363 write_list, missing_list = self.get_write_file_list()
364-
365 if missing_list:
366 self.application.set_normal_cursor()
367 title = translate('OpenLP.ServiceManager', 'Service File(s) Missing')
368@@ -594,8 +622,8 @@
369 service_content = json.dumps(service, cls=OpenLPJSONEncoder)
370 service_content_size = len(bytes(service_content, encoding='utf-8'))
371 total_size = service_content_size
372- for file_item in write_list:
373- total_size += file_item.stat().st_size
374+ for local_file_item, zip_file_item in write_list:
375+ total_size += local_file_item.stat().st_size
376 self.log_debug('ServiceManager.save_file - ZIP contents size is %i bytes' % total_size)
377 self.main_window.display_progress_bar(total_size)
378 try:
379@@ -605,9 +633,9 @@
380 zip_file.writestr('service_data.osj', service_content)
381 self.main_window.increment_progress_bar(service_content_size)
382 # Finally add all the listed media files.
383- for write_path in write_list:
384- zip_file.write(write_path, write_path)
385- self.main_window.increment_progress_bar(write_path.stat().st_size)
386+ for local_file_item, zip_file_item in write_list:
387+ zip_file.write(str(local_file_item), str(zip_file_item))
388+ self.main_window.increment_progress_bar(local_file_item.stat().st_size)
389 with suppress(FileNotFoundError):
390 file_path.unlink()
391 os.link(temp_file.name, file_path)
392@@ -696,11 +724,30 @@
393 service_data = None
394 self.application.set_busy_cursor()
395 try:
396- with zipfile.ZipFile(file_path) as zip_file:
397+ # TODO: figure out a way to use the presentation thumbnails from the service file
398+ with zipfile.ZipFile(str(file_path)) as zip_file:
399 compressed_size = 0
400 for zip_info in zip_file.infolist():
401 compressed_size += zip_info.compress_size
402 self.main_window.display_progress_bar(compressed_size)
403+ # First find the osj-file to find out how to handle the file
404+ for zip_info in zip_file.infolist():
405+ # The json file has been called 'service_data.osj' since OpenLP 3.0
406+ if zip_info.filename == 'service_data.osj' or zip_info.filename.endswith('osj'):
407+ with zip_file.open(zip_info, 'r') as json_file:
408+ service_data = json_file.read()
409+ break
410+ if service_data:
411+ items = json.loads(service_data, cls=OpenLPJSONDecoder)
412+ else:
413+ raise ValidationError(msg='No service data found')
414+ # Extract the service file version
415+ for item in items:
416+ if 'openlp_core' in item:
417+ item = item['openlp_core']
418+ self.servicefile_version = item.get('openlp-servicefile-version', 2)
419+ break
420+ print('service format version: %d' % self.servicefile_version)
421 for zip_info in zip_file.infolist():
422 self.log_debug('Extract file: {name}'.format(name=zip_info.filename))
423 # The json file has been called 'service_data.osj' since OpenLP 3.0
424@@ -708,19 +755,19 @@
425 with zip_file.open(zip_info, 'r') as json_file:
426 service_data = json_file.read()
427 else:
428- zip_info.filename = os.path.basename(zip_info.filename)
429- zip_file.extract(zip_info, self.service_path)
430+ # Service files from earlier versions than 3 expects that all files are extracted
431+ # into the root of the service folder.
432+ if self.servicefile_version and self.servicefile_version < 3:
433+ zip_info.filename = os.path.basename(zip_info.filename.replace('/', os.path.sep))
434+ zip_file.extract(zip_info, str(self.service_path))
435 self.main_window.increment_progress_bar(zip_info.compress_size)
436- if service_data:
437- items = json.loads(service_data, cls=OpenLPJSONDecoder)
438+ # Handle the content
439 self.new_file()
440 self.process_service_items(items)
441 self.set_file_name(file_path)
442 self.main_window.add_recent_file(file_path)
443 self.set_modified(False)
444 Settings().setValue('servicemanager/last file', file_path)
445- else:
446- raise ValidationError(msg='No service data found')
447 except (NameError, OSError, ValidationError, zipfile.BadZipFile):
448 self.application.set_normal_cursor()
449 self.log_exception('Problem loading service file {name}'.format(name=file_path))
450@@ -752,9 +799,9 @@
451 self.service_theme = theme
452 else:
453 if self._save_lite:
454- service_item.set_from_service(item)
455+ service_item.set_from_service(item, version=self.servicefile_version)
456 else:
457- service_item.set_from_service(item, self.service_path)
458+ service_item.set_from_service(item, self.service_path, self.servicefile_version)
459 service_item.validate_item(self.suffixes)
460 if service_item.is_capable(ItemCapabilities.OnLoadUpdate):
461 new_item = Registry().get(service_item.name).service_load(service_item)
462@@ -1259,11 +1306,12 @@
463 """
464 Empties the service_path of temporary files on system exit.
465 """
466- for file_path in self.service_path.iterdir():
467- delete_file(file_path)
468- audio_path = self.service_path / 'audio'
469- if audio_path.exists():
470- shutil.rmtree(audio_path, True)
471+ for file_name in os.listdir(self.service_path):
472+ file_path = Path(self.service_path, file_name)
473+ if os.path.isdir(file_path):
474+ shutil.rmtree(file_path, True)
475+ else:
476+ delete_file(file_path)
477
478 def on_theme_combo_box_selected(self, current_index):
479 """
480
481=== modified file 'openlp/plugins/images/lib/db.py'
482--- openlp/plugins/images/lib/db.py 2019-04-13 13:00:22 +0000
483+++ openlp/plugins/images/lib/db.py 2019-09-15 09:05:37 +0000
484@@ -65,6 +65,7 @@
485 * id
486 * group_id
487 * file_path
488+ * file_hash
489 """
490 session, metadata = init_db(url)
491
492@@ -79,7 +80,8 @@
493 image_filenames_table = Table('image_filenames', metadata,
494 Column('id', types.Integer(), primary_key=True),
495 Column('group_id', types.Integer(), ForeignKey('image_groups.id'), default=None),
496- Column('file_path', PathType(), nullable=False)
497+ Column('file_path', PathType(), nullable=False),
498+ Column('file_hash', types.Unicode(128), nullable=False)
499 )
500
501 mapper(ImageGroups, image_groups_table)
502
503=== modified file 'openlp/plugins/images/lib/mediaitem.py'
504--- openlp/plugins/images/lib/mediaitem.py 2019-05-23 19:33:46 +0000
505+++ openlp/plugins/images/lib/mediaitem.py 2019-09-15 09:05:37 +0000
506@@ -25,7 +25,7 @@
507
508 from PyQt5 import QtCore, QtGui, QtWidgets
509
510-from openlp.core.common import delete_file, get_images_filter
511+from openlp.core.common import delete_file, get_images_filter, sha256_file_hash
512 from openlp.core.common.applocation import AppLocation
513 from openlp.core.common.i18n import UiStrings, get_natural_key, translate
514 from openlp.core.common.path import create_paths
515@@ -347,7 +347,7 @@
516 :rtype: Path
517 """
518 ext = image.file_path.suffix.lower()
519- return self.service_path / '{name:d}{ext}'.format(name=image.id, ext=ext)
520+ return self.service_path / '{name:s}{ext}'.format(name=image.file_hash, ext=ext)
521
522 def load_full_list(self, images, initial_load=False, open_group=None):
523 """
524@@ -492,6 +492,7 @@
525 image_file = ImageFilenames()
526 image_file.group_id = group_id
527 image_file.file_path = image_path
528+ image_file.file_hash = sha256_file_hash(image_path)
529 self.manager.save_object(image_file)
530 self.main_window.increment_progress_bar()
531 if reload_list and image_paths:
532
533=== modified file 'openlp/plugins/images/lib/upgrade.py'
534--- openlp/plugins/images/lib/upgrade.py 2019-05-22 06:47:00 +0000
535+++ openlp/plugins/images/lib/upgrade.py 2019-09-15 09:05:37 +0000
536@@ -26,16 +26,17 @@
537 import logging
538 from pathlib import Path
539
540-from sqlalchemy import Column, Table
541+from sqlalchemy import Column, Table, types
542
543+from openlp.core.common import sha256_file_hash
544 from openlp.core.common.applocation import AppLocation
545 from openlp.core.common.db import drop_columns
546-from openlp.core.common.json import OpenLPJSONEncoder
547+from openlp.core.common.json import OpenLPJSONEncoder, OpenLPJSONDecoder
548 from openlp.core.lib.db import PathType, get_upgrade_op
549
550
551 log = logging.getLogger(__name__)
552-__version__ = 2
553+__version__ = 3
554
555
556 def upgrade_1(session, metadata):
557@@ -68,3 +69,21 @@
558 else:
559 op.drop_constraint('image_filenames', 'foreignkey')
560 op.drop_column('image_filenames', 'filenames')
561+
562+
563+def upgrade_3(session, metadata):
564+ """
565+ Version 3 upgrade - add sha256 hash
566+ """
567+ log.debug('Starting upgrade_3 for adding sha256 hashes')
568+ old_table = Table('image_filenames', metadata, autoload=True)
569+ if 'file_hash' not in [col.name for col in old_table.c.values()]:
570+ op = get_upgrade_op(session)
571+ op.add_column('image_filenames', Column('file_hash', types.Unicode(128)))
572+ conn = op.get_bind()
573+ results = conn.execute('SELECT * FROM image_filenames')
574+ for row in results.fetchall():
575+ file_path = json.loads(row.file_path, cls=OpenLPJSONDecoder)
576+ hash = sha256_file_hash(file_path)
577+ sql = 'UPDATE image_filenames SET file_hash = \'{hash}\' WHERE id = {id}'.format(hash=hash, id=row.id)
578+ conn.execute(sql)
579
580=== modified file 'openlp/plugins/presentations/lib/mediaitem.py'
581--- openlp/plugins/presentations/lib/mediaitem.py 2019-06-30 23:28:38 +0000
582+++ openlp/plugins/presentations/lib/mediaitem.py 2019-09-15 09:05:37 +0000
583@@ -359,7 +359,8 @@
584 note = ''
585 if notes and len(notes) >= i:
586 note = notes[i - 1]
587- service_item.add_from_command(str(path), file_name, str(thumbnail_path), title, note)
588+ service_item.add_from_command(str(path), file_name, str(thumbnail_path), title, note,
589+ doc.get_sha256_file_hash())
590 i += 1
591 thumbnail_path = doc.get_thumbnail_path(i, True)
592 doc.close_presentation()
593
594=== modified file 'openlp/plugins/presentations/lib/presentationcontroller.py'
595--- openlp/plugins/presentations/lib/presentationcontroller.py 2019-06-21 20:53:42 +0000
596+++ openlp/plugins/presentations/lib/presentationcontroller.py 2019-09-15 09:05:37 +0000
597@@ -25,12 +25,12 @@
598
599 from PyQt5 import QtCore
600
601-from openlp.core.common import md5_hash
602+from openlp.core.common import md5_hash, sha256_file_hash
603 from openlp.core.common.applocation import AppLocation
604 from openlp.core.common.path import create_paths
605 from openlp.core.common.registry import Registry
606 from openlp.core.common.settings import Settings
607-from openlp.core.lib import create_thumb, validate_thumb
608+from openlp.core.lib import create_thumb
609
610
611 log = logging.getLogger(__name__)
612@@ -98,6 +98,7 @@
613 :rtype: None
614 """
615 self.controller = controller
616+ self._sha256_file_hash = None
617 self._setup(document_path)
618
619 def _setup(self, document_path):
620@@ -146,6 +147,12 @@
621 # get_temp_folder and PresentationPluginapp_startup is removed
622 if Settings().value('presentations/thumbnail_scheme') == 'md5':
623 folder = md5_hash(bytes(self.file_path))
624+ elif Settings().value('presentations/thumbnail_scheme') == 'sha256file':
625+ if self._sha256_file_hash:
626+ folder = self._sha256_file_hash
627+ else:
628+ self._sha256_file_hash = sha256_file_hash(self.file_path)
629+ folder = self._sha256_file_hash
630 else:
631 folder = self.file_path.name
632 return Path(self.controller.thumbnail_folder, folder)
633@@ -161,13 +168,20 @@
634 # get_thumbnail_folder and PresentationPluginapp_startup is removed
635 if Settings().value('presentations/thumbnail_scheme') == 'md5':
636 folder = md5_hash(bytes(self.file_path))
637+ elif Settings().value('presentations/thumbnail_scheme') == 'sha256file':
638+ if self._sha256_file_hash:
639+ folder = self._sha256_file_hash
640+ else:
641+ self._sha256_file_hash = sha256_file_hash(self.file_path)
642+ folder = self._sha256_file_hash
643 else:
644 folder = self.file_path.name
645 return Path(self.controller.temp_folder, folder)
646
647 def check_thumbnails(self):
648 """
649- Check that the last thumbnail image exists and is valid and are more recent than the powerpoint file.
650+ Check that the last thumbnail image exists and is valid. It is not checked if presentation file is newer than
651+ thumbnail since the path is based on the file hash, so if it exists it is by definition up to date.
652
653 :return: If the thumbnail is valid
654 :rtype: bool
655@@ -175,7 +189,7 @@
656 last_image_path = self.get_thumbnail_path(self.get_slide_count(), True)
657 if not (last_image_path and last_image_path.is_file()):
658 return False
659- return validate_thumb(Path(self.file_path), Path(last_image_path))
660+ return True
661
662 def close_presentation(self):
663 """
664@@ -360,6 +374,17 @@
665 notes_path = self.get_thumbnail_folder() / 'slideNotes{number:d}.txt'.format(number=slide_no)
666 notes_path.write_text(note)
667
668+ def get_sha256_file_hash(self):
669+ """
670+ Returns the sha256 file hash for the file.
671+
672+ :return: The sha256 file hash
673+ :rtype: str
674+ """
675+ if not self._sha256_file_hash:
676+ self._sha256_file_hash = sha256_file_hash(self.file_path)
677+ return self._sha256_file_hash
678+
679
680 class PresentationController(object):
681 """
682
683=== modified file 'openlp/plugins/presentations/presentationplugin.py'
684--- openlp/plugins/presentations/presentationplugin.py 2019-05-18 04:56:15 +0000
685+++ openlp/plugins/presentations/presentationplugin.py 2019-09-15 09:05:37 +0000
686@@ -158,7 +158,7 @@
687 for path in presentation_paths:
688 self.media_item.clean_up_thumbnails(path, clean_for_update=True)
689 self.media_item.list_view.clear()
690- Settings().setValue('presentations/thumbnail_scheme', 'md5')
691+ Settings().setValue('presentations/thumbnail_scheme', 'sha256file')
692 self.media_item.validate_and_load(presentation_paths)
693
694 @staticmethod
695
696=== modified file 'tests/functional/openlp_core/lib/test_serviceitem.py'
697--- tests/functional/openlp_core/lib/test_serviceitem.py 2019-05-22 06:47:00 +0000
698+++ tests/functional/openlp_core/lib/test_serviceitem.py 2019-09-15 09:05:37 +0000
699@@ -135,17 +135,19 @@
700 assert 'Slide 2' == service_item.get_frame_title(1), '"Slide 2" has been returned as the title'
701 assert '' == service_item.get_frame_title(2), 'Blank has been returned as the title of slide 3'
702
703- def test_service_item_load_image_from_service(self):
704+ @patch('openlp.core.lib.serviceitem.sha256_file_hash')
705+ def test_service_item_load_image_from_service(self, mocked_sha256_file_hash):
706 """
707 Test the Service Item - adding an image from a saved service
708 """
709 # GIVEN: A new service item and a mocked add icon function
710 image_name = 'image_1.jpg'
711 test_file = TEST_PATH / image_name
712- frame_array = {'path': test_file, 'title': image_name}
713+ frame_array = {'path': test_file, 'title': image_name, 'file_hash': 'fake_file_hash'}
714
715 service_item = ServiceItem(None)
716 service_item.add_icon = MagicMock()
717+ mocked_sha256_file_hash.return_value = 'fake_file_hash'
718
719 # WHEN: adding an image from a saved Service and mocked exists
720 line = convert_file_service_item(TEST_PATH, 'serviceitem_image_1.osj')
721@@ -174,9 +176,11 @@
722 assert service_item.is_capable(ItemCapabilities.CanAppend) is True, \
723 'This service item should be able to have new items added to it'
724
725+ @patch('openlp.core.lib.serviceitem.sha256_file_hash')
726 @patch('openlp.core.lib.serviceitem.os.path.exists')
727 @patch('openlp.core.lib.serviceitem.AppLocation.get_section_data_path')
728- def test_service_item_load_image_from_local_service(self, mocked_get_section_data_path, mocked_exists):
729+ def test_service_item_load_image_from_local_service(self, mocked_get_section_data_path, mocked_exists,
730+ mocked_sha256_file_hash):
731 """
732 Test the Service Item - adding an image from a saved local service
733 """
734@@ -187,12 +191,13 @@
735 image_name2 = 'image_2.jpg'
736 test_file1 = os.path.join('/home', 'openlp', image_name1)
737 test_file2 = os.path.join('/home', 'openlp', image_name2)
738- frame_array1 = {'path': test_file1, 'title': image_name1}
739- frame_array2 = {'path': test_file2, 'title': image_name2}
740+ frame_array1 = {'path': test_file1, 'file_hash': 'fake_file_hash', 'title': {'path': test_file1, 'title': image_name1}}
741+ frame_array2 = {'path': test_file2, 'file_hash': 'fake_file_hash', 'title': {'path': test_file2, 'title': image_name2}}
742 service_item = ServiceItem(None)
743 service_item.add_icon = MagicMock()
744 service_item2 = ServiceItem(None)
745 service_item2.add_icon = MagicMock()
746+ mocked_sha256_file_hash.return_value = 'fake_file_hash'
747
748 # WHEN: adding an image from a saved Service and mocked exists
749 line = convert_file_service_item(TEST_PATH, 'serviceitem_image_2.osj')
750@@ -215,8 +220,8 @@
751 'The frame path should match the full path to the image'
752 assert test_file2 == str(service_item2.get_frame_path(0)), \
753 'The frame path should match the full path to the image'
754- assert image_name1 == service_item.get_frame_title(0), 'The 1st frame title should match the image name'
755- assert image_name2 == service_item2.get_frame_title(0), 'The 2nd frame title should match the image name'
756+ assert image_name1 == service_item.get_frame_title(0)['title'], 'The 1st frame title should match the image name'
757+ assert image_name2 == service_item2.get_frame_title(0)['title'], 'The 2nd frame title should match the image name'
758 assert service_item.name == service_item.title.lower(), \
759 'The plugin name should match the display title, as there are > 1 Images'
760 assert service_item.is_image() is True, 'This service item should be of an "image" type'
761@@ -229,7 +234,8 @@
762 assert service_item.is_capable(ItemCapabilities.CanAppend) is True, \
763 'This service item should be able to have new items added to it'
764
765- def test_add_from_command_for_a_presentation(self):
766+ @patch('openlp.core.lib.serviceitem.sha256_file_hash')
767+ def test_add_from_command_for_a_presentation(self, mocked_sha256_file_hash):
768 """
769 Test the Service Item - adding a presentation
770 """
771@@ -249,7 +255,8 @@
772 assert service_item.service_item_type == ServiceItemType.Command, 'It should be a Command'
773 assert service_item.get_frames()[0] == frame, 'Frames should match'
774
775- def test_add_from_command_without_display_title_and_notes(self):
776+ @patch('openlp.core.lib.serviceitem.sha256_file_hash')
777+ def test_add_from_comamnd_without_display_title_and_notes(self, mocked_sha256_file_hash):
778 """
779 Test the Service Item - add from command, but not presentation
780 """
781@@ -267,13 +274,16 @@
782 assert service_item.service_item_type == ServiceItemType.Command, 'It should be a Command'
783 assert service_item.get_frames()[0] == frame, 'Frames should match'
784
785- @patch(u'openlp.core.lib.serviceitem.ServiceItem.image_manager')
786+ @patch('openlp.core.lib.serviceitem.ServiceItem.image_manager')
787 @patch('openlp.core.lib.serviceitem.AppLocation.get_section_data_path')
788- def test_add_from_command_for_a_presentation_thumb(self, mocked_get_section_data_path, mocked_image_manager):
789+ @patch('openlp.core.lib.serviceitem.sha256_file_hash')
790+ def test_add_from_command_for_a_presentation_thumb(self, mocked_sha256_file_hash, mocked_get_section_data_path,
791+ mocked_image_manager):
792 """
793 Test the Service Item - adding a presentation, updating the thumb path & adding the thumb to image_manager
794 """
795 # GIVEN: A service item, a mocked AppLocation and presentation data
796+ mocked_sha256_file_hash.return_value = 'fake_file_hash'
797 mocked_get_section_data_path.return_value = Path('mocked') / 'section' / 'path'
798 service_item = ServiceItem(None)
799 service_item.add_capability(ItemCapabilities.HasThumbnails)
800@@ -283,8 +293,7 @@
801 thumb = Path('tmp') / 'test' / 'thumb.png'
802 display_title = 'DisplayTitle'
803 notes = 'Note1\nNote2\n'
804- expected_thumb_path = Path('mocked') / 'section' / 'path' / 'thumbnails' / \
805- md5_hash(str(TEST_PATH / presentation_name).encode('utf8')) / 'thumb.png'
806+ expected_thumb_path = Path('mocked') / 'section' / 'path' / 'thumbnails' / 'fake_file_hash' / 'thumb.png'
807 frame = {'title': presentation_name, 'image': str(expected_thumb_path), 'path': str(TEST_PATH),
808 'display_title': display_title, 'notes': notes, 'thumbnail': str(expected_thumb_path)}
809
810@@ -296,7 +305,8 @@
811 assert service_item.get_frames()[0] == frame, 'Frames should match'
812 # assert 1 == mocked_image_manager.add_image.call_count, 'image_manager should be used'
813
814- def test_service_item_load_optical_media_from_service(self):
815+ @patch('openlp.core.lib.serviceitem.sha256_file_hash')
816+ def test_service_item_load_optical_media_from_service(self, mocked_sha256_file_hash):
817 """
818 Test the Service Item - load an optical media item
819 """
820
821=== modified file 'tests/functional/openlp_plugins/images/test_lib.py'
822--- tests/functional/openlp_plugins/images/test_lib.py 2019-05-22 06:47:00 +0000
823+++ tests/functional/openlp_plugins/images/test_lib.py 2019-09-15 09:05:37 +0000
824@@ -101,8 +101,9 @@
825 assert self.media_item.manager.save_object.call_count == 0, \
826 'The save_object() method should not have been called'
827
828+ @patch('openlp.plugins.images.lib.mediaitem.sha256_file_hash')
829 @patch('openlp.plugins.images.lib.mediaitem.ImageMediaItem.load_full_list')
830- def test_save_new_images_list_single_image_with_reload(self, mocked_load_full_list):
831+ def test_save_new_images_list_single_image_with_reload(self, mocked_load_full_list, mocked_sha256_file_hash):
832 """
833 Test that the save_new_images_list() calls load_full_list() when reload_list is set to True
834 """
835@@ -110,6 +111,7 @@
836 image_list = [Path('test_image.jpg')]
837 ImageFilenames.file_path = None
838 self.media_item.manager = MagicMock()
839+ mocked_sha256_file_hash.return_value = 'fake_file_hash'
840
841 # WHEN: We run save_new_images_list with reload_list=True
842 self.media_item.save_new_images_list(image_list, reload_list=True)
843@@ -120,14 +122,16 @@
844 # CLEANUP: Remove added attribute from ImageFilenames
845 delattr(ImageFilenames, 'file_path')
846
847+ @patch('openlp.plugins.images.lib.mediaitem.sha256_file_hash')
848 @patch('openlp.plugins.images.lib.mediaitem.ImageMediaItem.load_full_list')
849- def test_save_new_images_list_single_image_without_reload(self, mocked_load_full_list):
850+ def test_save_new_images_list_single_image_without_reload(self, mocked_load_full_list, mocked_sha256_file_hash):
851 """
852 Test that the save_new_images_list() doesn't call load_full_list() when reload_list is set to False
853 """
854 # GIVEN: A list with 1 image and a mocked out manager
855 image_list = [Path('test_image.jpg')]
856 self.media_item.manager = MagicMock()
857+ mocked_sha256_file_hash.return_value = 'fake_file_hash'
858
859 # WHEN: We run save_new_images_list with reload_list=False
860 self.media_item.save_new_images_list(image_list, reload_list=False)
861@@ -135,14 +139,16 @@
862 # THEN: load_full_list() should not have been called
863 assert mocked_load_full_list.call_count == 0, 'load_full_list() should not have been called'
864
865+ @patch('openlp.plugins.images.lib.mediaitem.sha256_file_hash')
866 @patch('openlp.plugins.images.lib.mediaitem.ImageMediaItem.load_full_list')
867- def test_save_new_images_list_multiple_images(self, mocked_load_full_list):
868+ def test_save_new_images_list_multiple_images(self, mocked_load_full_list, mocked_sha256_file_hash):
869 """
870 Test that the save_new_images_list() saves all images in the list
871 """
872 # GIVEN: A list with 3 images
873 image_list = [Path('test_image_1.jpg'), Path('test_image_2.jpg'), Path('test_image_3.jpg')]
874 self.media_item.manager = MagicMock()
875+ mocked_sha256_file_hash.return_value = 'fake_file_hash'
876
877 # WHEN: We run save_new_images_list with the list of 3 images
878 self.media_item.save_new_images_list(image_list, reload_list=False)
879@@ -151,14 +157,16 @@
880 assert self.media_item.manager.save_object.call_count == 3, \
881 'load_full_list() should have been called three times'
882
883+ @patch('openlp.plugins.images.lib.mediaitem.sha256_file_hash')
884 @patch('openlp.plugins.images.lib.mediaitem.ImageMediaItem.load_full_list')
885- def test_save_new_images_list_other_objects_in_list(self, mocked_load_full_list):
886+ def test_save_new_images_list_other_objects_in_list(self, mocked_load_full_list, mocked_sha256_file_hash):
887 """
888 Test that the save_new_images_list() ignores everything in the provided list except strings
889 """
890 # GIVEN: A list with images and objects
891 image_list = [Path('test_image_1.jpg'), None, True, ImageFilenames(), Path('test_image_2.jpg')]
892 self.media_item.manager = MagicMock()
893+ mocked_sha256_file_hash.return_value = 'fake_file_hash'
894
895 # WHEN: We run save_new_images_list with the list of images and objects
896 self.media_item.save_new_images_list(image_list, reload_list=False)
897@@ -217,12 +225,15 @@
898 returned_object1 = ImageFilenames()
899 returned_object1.id = 1
900 returned_object1.file_path = Path('/', 'tmp', 'test_file_1.jpg')
901+ returned_object1.file_hash = 'fake_file_hash'
902 returned_object2 = ImageFilenames()
903 returned_object2.id = 2
904 returned_object2.file_path = Path('/', 'tmp', 'test_file_2.jpg')
905+ returned_object2.file_hash = 'fake_file_hash'
906 returned_object3 = ImageFilenames()
907 returned_object3.id = 3
908 returned_object3.file_path = Path('/', 'tmp', 'test_file_3.jpg')
909+ returned_object3.file_hash = 'fake_file_hash'
910 return [returned_object1, returned_object2, returned_object3]
911 if args[1] == ImageGroups and args[2]:
912 # Change the parent_id that is matched so we don't get into an endless loop
913@@ -233,9 +244,10 @@
914 return [returned_object1]
915 return []
916
917+ @patch('openlp.plugins.images.lib.mediaitem.sha256_file_hash')
918 @patch('openlp.plugins.images.lib.mediaitem.delete_file')
919 @patch('openlp.plugins.images.lib.mediaitem.check_item_selected')
920- def test_on_delete_click(self, mocked_check_item_selected, mocked_delete_file):
921+ def test_on_delete_click(self, mocked_check_item_selected, mocked_delete_file, mocked_sha256_file_hash):
922 """
923 Test that on_delete_click() works
924 """
925@@ -245,6 +257,7 @@
926 test_image.id = 1
927 test_image.group_id = 1
928 test_image.file_path = Path('imagefile.png')
929+ test_image.file_hash = 'fake_file_hash'
930 self.media_item.manager = MagicMock()
931 self.media_item.service_path = Path()
932 self.media_item.list_view = MagicMock()
933@@ -252,6 +265,7 @@
934 mocked_row_item.data.return_value = test_image
935 mocked_row_item.text.return_value = ''
936 self.media_item.list_view.selectedItems.return_value = [mocked_row_item]
937+ mocked_sha256_file_hash.return_value = 'fake_file_hash'
938
939 # WHEN: Calling on_delete_click
940 self.media_item.on_delete_click()
941
942=== modified file 'tests/interfaces/openlp_core/widgets/test_views.py'
943--- tests/interfaces/openlp_core/widgets/test_views.py 2019-04-13 13:00:22 +0000
944+++ tests/interfaces/openlp_core/widgets/test_views.py 2019-09-15 09:05:37 +0000
945@@ -79,7 +79,8 @@
946 # THEN: The number of the current item should be -1.
947 assert self.preview_widget.current_slide_number() == -1, 'The slide number should be -1.'
948
949- def test_replace_service_item(self):
950+ @patch('openlp.core.lib.serviceitem.sha256_file_hash')
951+ def test_replace_service_item(self, mocked_sha256_file_hash):
952 """
953 Test item counts and current number with a service item.
954 """
955@@ -94,7 +95,8 @@
956 assert self.preview_widget.slide_count() == 2, 'The slide count should be 2.'
957 assert self.preview_widget.current_slide_number() == 1, 'The current slide number should be 1.'
958
959- def test_change_slide(self):
960+ @patch('openlp.core.lib.serviceitem.sha256_file_hash')
961+ def test_change_slide(self, mocked_sha256_file_hash):
962 """
963 Test the change_slide method.
964 """