code:
import os, wx

from lib.Actions.Import.Options import CheckerOptions

from lib.GUI.Common import BetterBitmapButton, BetterButton, BetterStaticText, \
	StaticBox, WrapInGrid, WrapInText
from lib.GUI.ScrolledPanels import EditPanel, EditSingleCtrlPanel
from lib.GUI.TopLevelWindows import DialogEdit, PostSizeChangedEvent

from lib.Hydrus.Data import TimeDeltaToPrettyTimeDelta
from lib.Hydrus.Data import GetDefaultCheckerOptions

### CONSTANTS ###

import lib.Hydrus.Constants.Local as CC

### CLASSES ###

class EditCheckerOptions( EditPanel ):
	def __init__( self, parent, checker_options ):
		EditPanel.__init__( self, parent )

		help_button = BetterBitmapButton( self, CC.GlobalBMPs.help, self._ShowHelp )
		help_button.SetToolTip( 'Show help regarding these checker options.' )

		help_hbox = WrapInText( help_button, self, 'help for this panel -->', wx.Colour( 0, 0, 255 ) )

		defaults_panel = StaticBox( self, 'reasonable defaults' )

		defaults_1 = BetterButton( defaults_panel, 'thread', self.SetValue, GetDefaultCheckerOptions( 'thread' ) )
		defaults_2 = BetterButton( defaults_panel, 'slow thread', self.SetValue, GetDefaultCheckerOptions( 'slow thread' ) )
		defaults_3 = BetterButton( defaults_panel, 'faster tag subscription', self.SetValue, GetDefaultCheckerOptions( 'fast tag subscription' ) )
		defaults_4 = BetterButton( defaults_panel, 'medium tag/artist subscription', self.SetValue, GetDefaultCheckerOptions( 'artist subscription' ) )
		defaults_5 = BetterButton( defaults_panel, 'slower tag subscription', self.SetValue, GetDefaultCheckerOptions( 'slow tag subscription' ) )

		#

		# add statictext or whatever that will update on any updates above to say 'given velocity of blah and last check at blah, next check in 5 mins'
		# or indeed this could just take the file_seed cache and last check of the caller, if there is one
		# this would be more useful to the user, to know 'right, on ok, it'll refresh in 30 mins'
		# this is actually more complicated--it also needs last check time to calc a fresh file velocity based on new death_file_velocity

		#

		self._death_file_velocity = VelocityCtrl( self, min_time_delta = 60, days = True, hours = True, minutes = True, per_phrase = 'in', unit = 'files' )

		self._flat_check_period_checkbox = wx.CheckBox( self )

		#

		self._reactive_check_panel = StaticBox( self, 'reactive checking' )

		self._intended_files_per_check = wx.SpinCtrl( self._reactive_check_panel, min = 1, max = 1000 )

		self._never_faster_than = TimeDeltaCtrl( self._reactive_check_panel, min = 30, days = True, hours = True, minutes = True, seconds = True )

		self._never_slower_than = TimeDeltaCtrl( self._reactive_check_panel, min = 600, days = True, hours = True, minutes = True )

		#

		self._static_check_panel = StaticBox( self, 'static checking' )

		self._flat_check_period = TimeDeltaCtrl( self._static_check_panel, min = 180, days = True, hours = True, minutes = True )

		#

		self.SetValue( checker_options )

		#

		defaults_panel.Add( defaults_1, CC.FLAGS_EXPAND_PERPENDICULAR )
		defaults_panel.Add( defaults_2, CC.FLAGS_EXPAND_PERPENDICULAR )
		defaults_panel.Add( defaults_3, CC.FLAGS_EXPAND_PERPENDICULAR )
		defaults_panel.Add( defaults_4, CC.FLAGS_EXPAND_PERPENDICULAR )
		defaults_panel.Add( defaults_5, CC.FLAGS_EXPAND_PERPENDICULAR )

		#

		rows = []

		rows.append( ( 'intended new files per check: ', self._intended_files_per_check ) )
		rows.append( ( 'never check faster than once per: ', self._never_faster_than ) )
		rows.append( ( 'never check slower than once per: ', self._never_slower_than ) )

		gridbox = WrapInGrid( self._reactive_check_panel, rows )

		self._reactive_check_panel.Add( gridbox, CC.FLAGS_EXPAND_SIZER_PERPENDICULAR )

		#

		rows = []

		rows.append( ( 'check period: ', self._flat_check_period ) )

		gridbox = WrapInGrid( self._static_check_panel, rows )

		self._static_check_panel.Add( gridbox, CC.FLAGS_EXPAND_SIZER_PERPENDICULAR )

		#

		rows = []

		rows.append( ( 'stop checking if new files found falls below: ', self._death_file_velocity ) )
		rows.append( ( 'just check at a static, regular interval: ', self._flat_check_period_checkbox ) )

		gridbox = WrapInGrid( self, rows )

		vbox = wx.BoxSizer( wx.VERTICAL )

		vbox.Add( help_hbox, CC.FLAGS_BUTTON_SIZER )
		vbox.Add( defaults_panel, CC.FLAGS_EXPAND_PERPENDICULAR )
		vbox.Add( gridbox, CC.FLAGS_EXPAND_SIZER_PERPENDICULAR )
		vbox.Add( self._reactive_check_panel, CC.FLAGS_EXPAND_PERPENDICULAR )
		vbox.Add( self._static_check_panel, CC.FLAGS_EXPAND_PERPENDICULAR )

		self.SetSizer( vbox )

		#

		self._flat_check_period_checkbox.Bind( wx.EVT_CHECKBOX, self.EventFlatPeriodCheck )

	def _UpdateEnabledControls( self ):
		if self._flat_check_period_checkbox.GetValue() is True:
			self._reactive_check_panel.Hide()
			self._static_check_panel.Show()

		else:
			self._reactive_check_panel.Show()
			self._static_check_panel.Hide()

		self.Layout()

		PostSizeChangedEvent( self )

	def EventFlatPeriodCheck( self, event ):
		self._UpdateEnabledControls()

	def SetValue( self, checker_options ):
		( intended_files_per_check, never_faster_than, never_slower_than, death_file_velocity ) = checker_options.ToTuple()

		self._intended_files_per_check.SetValue( intended_files_per_check )
		self._never_faster_than.SetValue( never_faster_than )
		self._never_slower_than.SetValue( never_slower_than )
		self._death_file_velocity.SetValue( death_file_velocity )

		self._flat_check_period.SetValue( never_faster_than )

		self._flat_check_period_checkbox.SetValue( never_faster_than == never_slower_than )

		self._UpdateEnabledControls()

	def _ShowHelp( self ):
		help = 'The intention of this object is to govern how frequently the watcher or subscription checks for new files--and when it should stop completely.'
		help += os.linesep * 2
		help += 'PROTIP: Do not change anything here unless you understand what it means!'
		help += os.linesep * 2
		help += 'In general, checkers can and should be set up to check faster or slower based on how fast new files are coming in. This is polite to the server you are talking to and saves you CPU and bandwidth. The rate of new files is called the \'file velocity\' and is based on how many files appeared in a certain period before the _most recent check time_.'
		help += os.linesep * 2
		help += 'Once the first check is done and an initial file velocity is established, the time to the next check will be based on what you set for the \'intended files per check\'. If the current file velocity is 10 files per 24 hours, and you set the intended files per check to 5 files, the checker will set the next check time to be 12 hours after the previous check time.'
		help += os.linesep * 2
		help += 'After a check is completed, the new file velocity and next check time is calculated, so when files are being posted frequently, it will check more often. When things are slow, it will slow down as well. There are also minimum and maximum check periods to smooth out the bumps.'
		help += os.linesep * 2
		help += 'But if you would rather just check at a fixed rate, check the checkbox and you will get a simpler \'static checking\' panel.'
		help += os.linesep * 2
		help += 'If the \'file velocity\' drops below a certain amount, the checker considers the source of files dead and will stop checking. If it falls into this state but you think there might have since been a rush of new files, hit the watcher or subscription\'s \'check now\' button in an attempt to revive the checker. If there are new files, it will start checking again until they drop off once more.'
		help += os.linesep * 2
		help += 'If you are still not comfortable with how this system works, the \'reasonable defaults\' are good fallbacks. Most of the time, setting some reasonable rules and leaving checkers to do their work is the best way to deal with this stuff, rather than obsessing over the exact perfect values you want for each situation.'

		wx.MessageBox( help )

	def GetValue( self ):
		death_file_velocity = self._death_file_velocity.GetValue()

		intended_files_per_check = self._intended_files_per_check.GetValue()

		if self._flat_check_period_checkbox.GetValue() is True:
			never_faster_than = self._flat_check_period.GetValue()
			never_slower_than = never_faster_than

		else:
			never_faster_than = self._never_faster_than.GetValue()
			never_slower_than = self._never_slower_than.GetValue()

		return CheckerOptions( intended_files_per_check, never_faster_than, never_slower_than, death_file_velocity )

( TimeDeltaEvent, EVT_TIME_DELTA ) = wx.lib.newevent.NewCommandEvent()

class TimeDeltaButton( wx.Button ):
	def __init__( self, parent, min = 1, days = False, hours = False, minutes = False, seconds = False, monthly_allowed = False ):
		wx.Button.__init__( self, parent )

		self._min = min
		self._show_days = days
		self._show_hours = hours
		self._show_minutes = minutes
		self._show_seconds = seconds
		self._monthly_allowed = monthly_allowed

		self._value = self._min

		self.SetLabelText( 'initialising' )

		self.Bind( wx.EVT_BUTTON, self.EventButton )

	def _RefreshLabel( self ):
		value = self._value

		if value is None:
			text = 'monthly'

		else:
			text = TimeDeltaToPrettyTimeDelta( value )

		self.SetLabelText( text )

	def EventButton( self, event ):
		with DialogEdit( self, 'edit time delta' ) as dlg:
			panel = EditSingleCtrlPanel( dlg )

			control = TimeDeltaCtrl( panel, min = self._min, days = self._show_days, hours = self._show_hours, minutes = self._show_minutes, seconds = self._show_seconds, monthly_allowed = self._monthly_allowed )

			control.SetValue( self._value )

			panel.SetControl( control )

			dlg.SetPanel( panel )

			if dlg.ShowModal() == wx.ID_OK:
				value = panel.GetValue()

				self.SetValue( value )

				new_event = TimeDeltaEvent( 0 )

				wx.QueueEvent( self.GetEventHandler(), new_event )

	def GetValue( self ):
		return self._value

	def SetValue( self, value ):
		self._value = value

		self._RefreshLabel()

		self.GetParent().Layout()

class TimeDeltaCtrl( wx.Panel ):
	def __init__( self, parent, min = 1, days = False, hours = False, minutes = False, seconds = False, monthly_allowed = False, monthly_label = 'monthly' ):
		wx.Panel.__init__( self, parent )

		self._min = min
		self._show_days = days
		self._show_hours = hours
		self._show_minutes = minutes
		self._show_seconds = seconds
		self._monthly_allowed = monthly_allowed

		hbox = wx.BoxSizer( wx.HORIZONTAL )

		if self._show_days:
			self._days = wx.SpinCtrl( self, min = 0, max = 3653, size = ( 50, -1 ) )
			self._days.Bind( wx.EVT_SPINCTRL, self.EventChange )

			hbox.Add( self._days, CC.FLAGS_VCENTER )
			hbox.Add( BetterStaticText( self, 'days' ), CC.FLAGS_VCENTER )

		if self._show_hours:
			self._hours = wx.SpinCtrl( self, min = 0, max = 23, size = ( 45, -1 ) )
			self._hours.Bind( wx.EVT_SPINCTRL, self.EventChange )

			hbox.Add( self._hours, CC.FLAGS_VCENTER )
			hbox.Add( BetterStaticText( self, 'hours' ), CC.FLAGS_VCENTER )

		if self._show_minutes:
			self._minutes = wx.SpinCtrl( self, min = 0, max = 59, size = ( 45, -1 ) )
			self._minutes.Bind( wx.EVT_SPINCTRL, self.EventChange )

			hbox.Add( self._minutes, CC.FLAGS_VCENTER )
			hbox.Add( BetterStaticText( self, 'minutes' ), CC.FLAGS_VCENTER )

		if self._show_seconds:
			self._seconds = wx.SpinCtrl( self, min = 0, max = 59, size = ( 45, -1 ) )
			self._seconds.Bind( wx.EVT_SPINCTRL, self.EventChange )

			hbox.Add( self._seconds, CC.FLAGS_VCENTER )
			hbox.Add( BetterStaticText( self, 'seconds' ), CC.FLAGS_VCENTER )

		if self._monthly_allowed:
			self._monthly = wx.CheckBox( self )
			self._monthly.Bind( wx.EVT_CHECKBOX, self.EventChange )

			hbox.Add( self._monthly, CC.FLAGS_VCENTER )
			hbox.Add( BetterStaticText( self, monthly_label ), CC.FLAGS_VCENTER )

		self.SetSizer( hbox )

	def _UpdateEnables( self ):
		value = self.GetValue()

		if value is None:
			if self._show_days:
				self._days.Disable()

			if self._show_hours:
				self._hours.Disable()

			if self._show_minutes:
				self._minutes.Disable()

			if self._show_seconds:
				self._seconds.Disable()

		else:
			if self._show_days:
				self._days.Enable()

			if self._show_hours:
				self._hours.Enable()

			if self._show_minutes:
				self._minutes.Enable()

			if self._show_seconds:
				self._seconds.Enable()

	def EventChange( self, event ):
		value = self.GetValue()

		if value is not None and value < self._min:
			self.SetValue( self._min )

		self._UpdateEnables()

		new_event = TimeDeltaEvent( 0 )

		wx.QueueEvent( self.GetEventHandler(), new_event )

	def GetValue( self ):
		if self._monthly_allowed and self._monthly.GetValue():
			return None

		value = 0

		if self._show_days:
			value += self._days.GetValue() * 86400

		if self._show_hours:
			value += self._hours.GetValue() * 3600

		if self._show_minutes:
			value += self._minutes.GetValue() * 60

		if self._show_seconds:
			value += self._seconds.GetValue()

		return value

	def SetValue( self, value ):
		if self._monthly_allowed:
			if value is None:
				self._monthly.SetValue( True )

			else:
				self._monthly.SetValue( False )

		if value is not None:
			if value < self._min:
				value = self._min

			if self._show_days:
				self._days.SetValue( value // 86400 )

				value %= 86400

			if self._show_hours:
				self._hours.SetValue( value // 3600 )

				value %= 3600

			if self._show_minutes:
				self._minutes.SetValue( value // 60 )

				value %= 60

			if self._show_seconds:
				self._seconds.SetValue( value )

		self._UpdateEnables()

class VelocityCtrl( wx.Panel ):
	def __init__( self, parent, min_time_delta = 60, days = False, hours = False, minutes = False, seconds = False, per_phrase = 'per', unit = None ):
		wx.Panel.__init__( self, parent )

		self._num = wx.SpinCtrl( self, min = 0, max = 1000, size = ( 60, -1 ) )

		self._times = TimeDeltaCtrl( self, min = min_time_delta, days = days, hours = hours, minutes = minutes, seconds = seconds )

		#

		hbox = wx.BoxSizer( wx.HORIZONTAL )

		hbox.Add( self._num, CC.FLAGS_VCENTER )

		mid_text = per_phrase

		if unit is not None:
			mid_text = unit + ' ' + mid_text

		hbox.Add( BetterStaticText( self, mid_text ), CC.FLAGS_VCENTER )

		hbox.Add( self._times, CC.FLAGS_VCENTER )

		self.SetSizer( hbox )

	def GetValue( self ):
		num = self._num.GetValue()
		time_delta = self._times.GetValue()

		return ( num, time_delta )

	def SetToolTip( self, text ):
		wx.Panel.SetToolTip( self, text )

		for c in self.GetChildren():
			c.SetToolTip( text )

	def SetValue( self, velocity ):
		( num, time_delta ) = velocity

		self._num.SetValue( num )

		self._times.SetValue( time_delta )

license: wtfpl


code:
import wx

from lib.GUI.Menus import MenuIsDead
from lib.GUI.Shortcuts import ConvertKeyEventToSimpleTuple

from lib.Hydrus.Data import DebugPrint, PrintException, ShowText
from lib.Hydrus.Exceptions import VetoException

### CONSTANTS ###
import lib.Hydrus.Globals as HG
import lib.Hydrus.Constants as HC
import lib.Hydrus.Constants.Local as CC

( OKEvent, EVT_OK ) = wx.lib.newevent.NewCommandEvent()

CHILD_POSITION_PADDING = 24
FUZZY_PADDING = 15

### FUNCTIONS ###

def GetDisplayPosition( window ):
	display_index = wx.Display.GetFromWindow( window )

	if display_index == wx.NOT_FOUND:
		display_index = 0 # default to primary

	display = wx.Display( display_index )

	rect = display.GetClientArea()

	return tuple( rect.GetPosition() )

def GetDisplaySize( window ):
	display_index = wx.Display.GetFromWindow( window )

	if display_index == wx.NOT_FOUND:
		display_index = 0 # default to primary

	display = wx.Display( display_index )

	rect = display.GetClientArea()

	return tuple( rect.GetSize() )

def GetSafePosition( position ):
	( p_x, p_y ) = position

	# some window managers size the windows just off screen to cut off borders
	# so choose a test position that's a little more lenient
	( test_x, test_y ) = ( p_x + FUZZY_PADDING, p_y + FUZZY_PADDING )

	display_index = wx.Display.GetFromPoint( ( test_x, test_y ) )

	if display_index == wx.NOT_FOUND:
		return wx.DefaultPosition

	else:
		return position

def GetSafeSize( tlw, min_size, gravity ):
	( min_width, min_height ) = min_size

	parent = tlw.GetParent()

	if parent is None:
		width = min_width
		height = min_height

	else:
		( parent_window_width, parent_window_height ) = parent.GetTopLevelParent().GetSize()

		( width_gravity, height_gravity ) = gravity

		if width_gravity == -1:
			width = min_width

		else:
			max_width = parent_window_width - 2 * CHILD_POSITION_PADDING

			width = int( width_gravity * max_width )

		if height_gravity == -1:
			height = min_height

		else:
			max_height = parent_window_height - 2 * CHILD_POSITION_PADDING

			height = int( height_gravity * max_height )

	( display_width, display_height ) = GetDisplaySize( tlw )

	width = min( display_width, width )
	height = min( display_height, height )

	return ( width, height )

def ExpandTLWIfPossible( tlw, frame_key, desired_size_delta ):
	new_options = HG.client_controller.new_options

	( remember_size, remember_position, last_size, last_position, default_gravity, default_position, maximised, fullscreen ) = new_options.GetFrameLocation( frame_key )

	if not tlw.IsMaximized() and not tlw.IsFullScreen():
		( current_width, current_height ) = tlw.GetSize()

		( desired_delta_width, desired_delta_height ) = desired_size_delta

		desired_width = current_width + desired_delta_width + FUZZY_PADDING
		desired_height = current_height + desired_delta_height + FUZZY_PADDING

		( width, height ) = GetSafeSize( tlw, ( desired_width, desired_height ), default_gravity )

		if width > current_width or height > current_height:
			tlw.SetSize( ( width, height ) )

			SlideOffScreenTLWUpAndLeft( tlw )

def MouseIsOnMyDisplay( window ):
	window_display_index = wx.Display.GetFromWindow( window )

	mouse_display_index = wx.Display.GetFromPoint( wx.GetMousePosition() )

	return window_display_index == mouse_display_index

def PostSizeChangedEvent( window ):
	event = CC.SizeChangedEvent( -1 )

	wx.QueueEvent( window.GetEventHandler(), event )

def SaveTLWSizeAndPosition( tlw, frame_key ):
	new_options = HG.client_controller.new_options

	( remember_size, remember_position, last_size, last_position, default_gravity, default_position, maximised, fullscreen ) = new_options.GetFrameLocation( frame_key )

	maximised = tlw.IsMaximized()
	fullscreen = tlw.IsFullScreen()

	if not ( maximised or fullscreen ):
		safe_position = GetSafePosition( tuple( tlw.GetPosition() ) )

		if safe_position != wx.DefaultPosition:
			last_size = tuple( tlw.GetSize() )
			last_position = safe_position

	new_options.SetFrameLocation( frame_key, remember_size, remember_position, last_size, last_position, default_gravity, default_position, maximised, fullscreen )

def SetInitialTLWSizeAndPosition( tlw, frame_key ):
	new_options = HG.client_controller.new_options

	( remember_size, remember_position, last_size, last_position, default_gravity, default_position, maximised, fullscreen ) = new_options.GetFrameLocation( frame_key )

	parent = tlw.GetParent()

	if remember_size and last_size is not None:
		( width, height ) = last_size

	else:
		( min_width, min_height ) = tlw.GetEffectiveMinSize()

		min_width += FUZZY_PADDING
		min_height += FUZZY_PADDING

		( width, height ) = GetSafeSize( tlw, ( min_width, min_height ), default_gravity )

	tlw.SetInitialSize( ( width, height ) )

	min_width = min( 240, width )
	min_height = min( 240, height )

	tlw.SetMinSize( ( min_width, min_height ) )

	#

	if remember_position and last_position is not None:
		safe_position = GetSafePosition( last_position )

		tlw.SetPosition( safe_position )

	elif default_position == 'topleft':
		if parent is not None:
			if isinstance( parent, wx.TopLevelWindow ):
				parent_tlp = parent

			else:
				parent_tlp = parent.GetTopLevelParent()

			( parent_x, parent_y ) = parent_tlp.GetPosition()

			tlw.SetPosition( ( parent_x + CHILD_POSITION_PADDING, parent_y + CHILD_POSITION_PADDING ) )

		else:
			safe_position = GetSafePosition( ( 0 + CHILD_POSITION_PADDING, 0 + CHILD_POSITION_PADDING ) )

			tlw.SetPosition( safe_position )

		SlideOffScreenTLWUpAndLeft( tlw )

	elif default_position == 'center':
		wx.CallAfter( tlw.Center )

	# if these aren't callafter, the size and pos calls don't stick if a restore event happens

	if maximised:
		wx.CallAfter( tlw.Maximize )

	if fullscreen:
		wx.CallAfter( tlw.ShowFullScreen, True, wx.FULLSCREEN_ALL )

def SlideOffScreenTLWUpAndLeft( tlw ):
	( tlw_width, tlw_height ) = tlw.GetSize()
	( tlw_x, tlw_y ) = tlw.GetPosition()

	tlw_right = tlw_x + tlw_width
	tlw_bottom = tlw_y + tlw_height

	( display_width, display_height ) = GetDisplaySize( tlw )
	( display_x, display_y ) = GetDisplayPosition( tlw )

	display_right = display_x + display_width
	display_bottom = display_y + display_height

	move_x = tlw_right > display_right
	move_y = tlw_bottom > display_bottom

	if move_x or move_y:
		delta_x = min( display_right - tlw_right, 0 )
		delta_y = min( display_bottom - tlw_bottom, 0 )

		tlw.SetPosition( ( tlw_x + delta_x, tlw_y + delta_y ) )

class NewDialog( wx.Dialog ):
	def __init__( self, parent, title, style_override = None ):

		if style_override is None:
			style = wx.DEFAULT_DIALOG_STYLE | wx.RESIZE_BORDER

			if not HC.PLATFORM_LINUX and parent is not None:
				style |= wx.FRAME_FLOAT_ON_PARENT

		else:
			style = style_override

		wx.Dialog.__init__( self, parent, title = title, style = style )

		self._new_options = HG.client_controller.new_options

		self.SetBackgroundColour( wx.SystemSettings.GetColour( wx.SYS_COLOUR_FRAMEBK ) )

		self.SetIcon( HG.client_controller.frame_icon )

		self.Bind( wx.EVT_BUTTON, self.EventDialogButton )

		self.Bind( wx.EVT_MENU_CLOSE, self.EventMenuClose )
		self.Bind( wx.EVT_MENU_HIGHLIGHT_ALL, self.EventMenuHighlight )
		self.Bind( wx.EVT_MENU_OPEN, self.EventMenuOpen )

		self._menu_stack = []
		self._menu_text_stack = []

		HG.client_controller.ResetIdleTimer()

		self.Bind( wx.EVT_CHAR_HOOK, self.EventCharHook )

	def _CanCancel( self ):
		return True

	def _CanOK( self ):
		return True

	def _ReadyToClose( self, value ):
		return True

	def _SaveOKPosition( self ):
		pass

	def _TryEndModal( self, value ):
		if not self.IsModal(): # in some rare cases (including spammy AutoHotkey, looks like ), this can be fired before the dialog can clean itself up

			return

		if not self._ReadyToClose( value ):
			return

		if value == wx.ID_CANCEL:
			if not self._CanCancel():
				return

		if value == wx.ID_OK:
			if not self._CanOK():
				return

			self._SaveOKPosition()

		try:
			self.EndModal( value )

		except Exception as e:
			ShowText( 'This dialog seems to have been unable to close for some reason. I am printing the stack to the log. The dialog may have already closed, or may attempt to close now. Please inform hydrus dev of this situation. I recommend you restart the client if you can. If the UI is locked, you will have to kill it via task manager.' )

			PrintException( e )

			import traceback

			DebugPrint( ''.join( traceback.format_stack() ) )

			try:
				self.Close()

			except Exception:
				ShowText( 'The dialog would not close on command.' )

			try:
				self.Destroy()

			except Exception:
				ShowText( 'The dialog would not destroy on command.' )

	def DoOK( self ):
		self._TryEndModal( wx.ID_OK )

	def EventCharHook( self, event ):
		( modifier, key ) = ConvertKeyEventToSimpleTuple( event )

		if key == wx.WXK_ESCAPE:
			self._TryEndModal( wx.ID_CANCEL )

		else:
			event.Skip()

	def EventClose( self, event ):
		if not self:
			return

		self._TryEndModal( wx.ID_CANCEL )

	def EventDialogButton( self, event ):
		if not self:
			return

		event_id = event.GetId()

		if event_id == wx.ID_ANY:
			event.Skip()

			return

		event_object = event.GetEventObject()

		if event_object is not None:
			tlp = event_object.GetTopLevelParent()

			if tlp != self:
				event.Skip()

				return

		self._TryEndModal( event_id )

	def EventMenuClose( self, event ):
		menu = event.GetMenu()

		if menu is not None and menu in self._menu_stack:
			index = self._menu_stack.index( menu )

			del self._menu_stack[ index ]

			previous_text = self._menu_text_stack.pop()

			status_bar = HG.client_controller.GetGUI().GetStatusBar()

			status_bar.SetStatusText( previous_text )

	def EventMenuHighlight( self, event ):
		if len( self._menu_stack ) > 0:
			text = ''

			menu = self._menu_stack[-1]

			while MenuIsDead( menu ):
				if len( self._menu_stack ) == 0:
					return

				del self._menu_stack[-1]

				menu = self._menu_stack[-1]

			menu_item = menu.FindItemById( event.GetMenuId() )

			if menu_item is not None:
				text = menu_item.GetHelp()

			status_bar = HG.client_controller.GetGUI().GetStatusBar()

			status_bar.SetStatusText( text )

	def EventMenuOpen( self, event ):
		menu = event.GetMenu()

		if menu is not None:
			status_bar = HG.client_controller.GetGUI().GetStatusBar()

			previous_text = status_bar.GetStatusText()

			self._menu_stack.append( menu )

			self._menu_text_stack.append( previous_text )

	def EventOK( self, event ):
		if not self:
			return

		self._TryEndModal( wx.ID_OK )

class DialogThatResizes( NewDialog ):
	def __init__( self, parent, title, frame_key, style_override = None ):

		self._frame_key = frame_key

		NewDialog.__init__( self, parent, title, style_override = style_override )

	def _SaveOKPosition( self ):
		SaveTLWSizeAndPosition( self, self._frame_key )

class DialogThatTakesScrollablePanel( DialogThatResizes ):
	def __init__( self, parent, title, frame_key = 'regular_dialog', style_override = None, hide_buttons = False ):
		self._panel = None
		self._hide_buttons = hide_buttons

		DialogThatResizes.__init__( self, parent, title, frame_key, style_override = style_override )

		self._InitialiseButtons()

		self.Bind( EVT_OK, self.EventOK )
		self.Bind( CC.EVT_SIZE_CHANGED, self.EventChildSizeChanged )

	def _CanCancel( self ):
		return self._panel.CanCancel()

	def _CanOK( self ):
		return self._panel.CanOK()

	def _GetButtonBox( self ):
		raise NotImplementedError()

	def _InitialiseButtons( self ):
		raise NotImplementedError()

	def EventChildSizeChanged( self, event ):
		if self._panel is not None:
			# the min size here is to compensate for wx.Notebook and anything else that don't update virtualsize on page change

			( current_panel_width, current_panel_height ) = self._panel.GetSize()
			( desired_panel_width, desired_panel_height ) = self._panel.GetVirtualSize()
			( min_panel_width, min_panel_height ) = self._panel.GetEffectiveMinSize()

			desired_delta_width = max( 0, desired_panel_width - current_panel_width, min_panel_width - current_panel_width )
			desired_delta_height = max( 0, desired_panel_height - current_panel_height, min_panel_height - current_panel_height )

			if desired_delta_width > 0 or desired_delta_height > 0:
				ExpandTLWIfPossible( self, self._frame_key, ( desired_delta_width, desired_delta_height ) )

	def SetPanel( self, panel ):
		self._panel = panel

		buttonbox = self._GetButtonBox()

		vbox = wx.BoxSizer( wx.VERTICAL )

		vbox.Add( self._panel, CC.FLAGS_EXPAND_BOTH_WAYS )
		vbox.Add( buttonbox, CC.FLAGS_BUTTON_SIZER )

		self.SetSizer( vbox )

		SetInitialTLWSizeAndPosition( self, self._frame_key )

		self._panel.SetupScrolling( scrollIntoView = False ) # this changes geteffectiveminsize calc, so it needs to be below settlwsizeandpos

		PostSizeChangedEvent( self ) # helps deal with some Linux/otherscrollbar weirdness where setupscrolling changes inherent virtual size

class DialogNullipotent( DialogThatTakesScrollablePanel ):
	def _GetButtonBox( self ):
		buttonbox = wx.BoxSizer( wx.HORIZONTAL )

		buttonbox.Add( self._close, CC.FLAGS_VCENTER )

		return buttonbox

	def _InitialiseButtons( self ):
		self._close = wx.Button( self, id = wx.ID_OK, label = 'close' )
		self._close.Bind( wx.EVT_BUTTON, self.EventOK )

		if self._hide_buttons:
			self._close.Hide()

			self.Bind( wx.EVT_CLOSE, self.EventOK ) # the close event no longer goes to the default button, since it is hidden, wew

	def _ReadyToClose( self, value ):
		try:
			self._panel.TryToClose()

			return True

		except VetoException as e:
			message = str( e )

			if len( message ) > 0:
				wx.MessageBox( message )

			return False

class DialogApplyCancel( DialogThatTakesScrollablePanel ):
	def _GetButtonBox( self ):
		buttonbox = wx.BoxSizer( wx.HORIZONTAL )

		buttonbox.Add( self._apply, CC.FLAGS_VCENTER )
		buttonbox.Add( self._cancel, CC.FLAGS_VCENTER )

		return buttonbox

	def _InitialiseButtons( self ):
		self._apply = wx.Button( self, id = wx.ID_OK, label = 'apply' )
		self._apply.SetForegroundColour( ( 0, 128, 0 ) )
		#self._apply.Bind( wx.EVT_BUTTON, self.EventOK )

		self._cancel = wx.Button( self, id = wx.ID_CANCEL, label = 'cancel' )
		self._cancel.SetForegroundColour( ( 128, 0, 0 ) )

		if self._hide_buttons:
			self._apply.Hide()
			self._cancel.Hide()

			self.Bind( wx.EVT_CLOSE, self.EventClose ) # the close event no longer goes to the default button, since it is hidden, wew

class DialogEdit( DialogApplyCancel ):
	def __init__( self, parent, title, frame_key = 'regular_dialog', hide_buttons = False ):
		DialogApplyCancel.__init__( self, parent, title, frame_key = frame_key, hide_buttons = hide_buttons )

	def _ReadyToClose( self, value ):
		if value != wx.ID_OK:
			return True

		try:
			value = self._panel.GetValue()

			return True

		except VetoException as e:
			message = str( e )

			if len( message ) > 0:
				wx.MessageBox( message )

			return False

class DialogManage( DialogApplyCancel ):
	def _ReadyToClose( self, value ):
		if value != wx.ID_OK:
			return True

		try:
			self._panel.CommitChanges()

			return True

		except VetoException as e:
			message = str( e )

			if len( message ) > 0:
				wx.MessageBox( message )

			return False

class Frame( wx.Frame ):
	def __init__( self, parent, title, float_on_parent = True ):
		style = wx.DEFAULT_FRAME_STYLE

		if float_on_parent:
			style |= wx.FRAME_FLOAT_ON_PARENT

		wx.Frame.__init__( self, parent, title = title, style = style )

		self._new_options = HG.client_controller.new_options

		self.SetBackgroundColour( wx.SystemSettings.GetColour( wx.SYS_COLOUR_FRAMEBK ) )

		self.SetIcon( HG.client_controller.frame_icon )

		self.Bind( wx.EVT_MENU_CLOSE, self.EventMenuClose )
		self.Bind( wx.EVT_MENU_HIGHLIGHT_ALL, self.EventMenuHighlight )
		self.Bind( wx.EVT_MENU_OPEN, self.EventMenuOpen )

		self._menu_stack = []
		self._menu_text_stack = []

		HG.client_controller.ResetIdleTimer()

	def EventMenuClose( self, event ):
		menu = event.GetMenu()

		if menu is not None and menu in self._menu_stack:
			index = self._menu_stack.index( menu )

			del self._menu_stack[ index ]

			previous_text = self._menu_text_stack.pop()

			status_bar = HG.client_controller.GetGUI().GetStatusBar()

			status_bar.SetStatusText( previous_text )

	def EventMenuHighlight( self, event ):
		if len( self._menu_stack ) > 0:
			text = ''

			menu = self._menu_stack[-1]

			while MenuIsDead( menu ):
				del self._menu_stack[-1]

				if len( self._menu_stack ) == 0:
					return

				menu = self._menu_stack[-1]

			menu_item = menu.FindItemById( event.GetMenuId() )

			if menu_item is not None:
				text = menu_item.GetHelp()

			status_bar = HG.client_controller.GetGUI().GetStatusBar()

			status_bar.SetStatusText( text )

	def EventMenuOpen( self, event ):
		menu = event.GetMenu()

		if menu is not None:
			status_bar = HG.client_controller.GetGUI().GetStatusBar()

			previous_text = status_bar.GetStatusText()

			self._menu_stack.append( menu )

			self._menu_text_stack.append( previous_text )

class FrameThatResizes( Frame ):
	def __init__( self, parent, title, frame_key, float_on_parent = True ):
		self._frame_key = frame_key

		Frame.__init__( self, parent, title, float_on_parent )

		self.Bind( wx.EVT_SIZE, self.EventSizeAndPositionChanged )
		self.Bind( wx.EVT_MOVE_END, self.EventSizeAndPositionChanged )
		self.Bind( wx.EVT_CLOSE, self.EventSizeAndPositionChanged )
		self.Bind( wx.EVT_MAXIMIZE, self.EventSizeAndPositionChanged )

	def EventSizeAndPositionChanged( self, event ):
		SaveTLWSizeAndPosition( self, self._frame_key )

		event.Skip()

class FrameThatTakesScrollablePanel( FrameThatResizes ):
	def __init__( self, parent, title, frame_key = 'regular_dialog', float_on_parent = True ):
		self._panel = None

		FrameThatResizes.__init__( self, parent, title, frame_key, float_on_parent )

		self._ok = wx.Button( self, id = wx.ID_OK, label = 'close' )
		self._ok.Bind( wx.EVT_BUTTON, self.EventClose )

		self.Bind( EVT_OK, self.EventClose )
		self.Bind( CC.EVT_SIZE_CHANGED, self.EventChildSizeChanged )

		self.Bind( wx.EVT_CHAR_HOOK, self.EventCharHook )

	def EventCharHook( self, event ):
		( modifier, key ) = ConvertKeyEventToSimpleTuple( event )

		if key == wx.WXK_ESCAPE:
			self.Close()

		else:
			event.Skip()

	def EventClose( self, event ):
		self.Close()

	def EventChildSizeChanged( self, event ):
		if self._panel is not None:
			# the min size here is to compensate for wx.Notebook and anything else that don't update virtualsize on page change

			( current_panel_width, current_panel_height ) = self._panel.GetSize()
			( desired_panel_width, desired_panel_height ) = self._panel.GetVirtualSize()
			( min_panel_width, min_panel_height ) = self._panel.GetEffectiveMinSize()

			desired_delta_width = max( 0, desired_panel_width - current_panel_width, min_panel_width - current_panel_width )
			desired_delta_height = max( 0, desired_panel_height - current_panel_height, min_panel_height - current_panel_height )

			if desired_delta_width > 0 or desired_delta_height > 0:
				ExpandTLWIfPossible( self, self._frame_key, ( desired_delta_width, desired_delta_height ) )

	def SetPanel( self, panel ):
		self._panel = panel

		vbox = wx.BoxSizer( wx.VERTICAL )

		vbox.Add( self._panel, CC.FLAGS_EXPAND_BOTH_WAYS )
		vbox.Add( self._ok, CC.FLAGS_LONE_BUTTON )

		self.SetSizer( vbox )

		SetInitialTLWSizeAndPosition( self, self._frame_key )

		self.Show( True )

		self._panel.SetupScrolling( scrollIntoView = False ) # this changes geteffectiveminsize calc, so it needs to be below settlwsizeandpos

		PostSizeChangedEvent( self ) # helps deal with some Linux/otherscrollbar weirdness where setupscrolling changes inherent virtual size


code:
from .GUI import *

FolderModules = [
	"Dialogs",
	"ScrolledPanels"
]

FileModules = [
	"ACDropdown",
	"API",
	"Canvas",
	"Common",
	"Controls",
	"DragDrop",
	"FileSeedCache",
	"GallerySeedLog",
	"HoverFrames",
	"ListBoxes",
	"ListCtrl",
	"Login",
	"Management",
	"MatPlotLib",
	"Media",
	"Menus",
	"OptionsPanel",
	"Pages",
	"Panels",
	"Parsing",
	"PopupMessages",
	"Predicates",
	"Rendering",
	"Shortcuts",
	"Time",
	"TopLevelWindows"
]

__all__ = FolderModules + FileModules

from . import *


code:
import lib.Hydrus.Constants as HC

import os, threading, time, traceback

#if HC.PLATFORM_WINDOWS:
	import mp3play

# There used to be hsaudiotag duration stuff here, but I moved it all to FFMPEG

'''

### FUNCTIONS ###

def PlayNoise( name ):
	if HC.PLATFORM_OSX:
		return

	if name not in parsed_noises:
		if name == 'success':
			filename = 'success.mp3'
		elif name == 'error':
			filename = 'error.mp3'

		path = os.path.join( HC.STATIC_DIR, filename )

		noise = mp3play.load( path )

		parsed_noises[ name ] = noise

	noise = parsed_noises[ name ]

	noise.play()
	'''


code:
import re

### FUNCTIONS ###

def GetNumWordsFromString( s ):
	s = re.sub( '[\\s]+', ' ', s ) # turns multiple spaces into single spaces

	num_words = len( s.split( ' ' ) )

	return num_words

def GetPDFNumWords( path ):
	# I discovered a pdf that pulled this into an infinite loop due to malformed header.
	# This gives bunk data anyway, so let's just cut it out until we have a better solution here all around

	return None

	try:
		pass
		'''
		with open( path, 'rb' ) as f:
			pdf_object = PyPDF2.PdfFileReader( f, strict = False )

			# get.extractText() gives kooky and unreliable results
			# num_words = sum( [ GetNumWordsFromString( page.extractText() ) for page in pdf_object.pages ] )

			# so let's just estimate

			return pdf_object.numPages * 350

		'''
	except Exception:
		num_words = 0

	return num_words


code:
import os, subprocess, time, traceback

import lib.Hydrus.Constants as HC

from lib.Handling import hexagonitswfheader

from lib.Hydrus.Data import GetNow, GetSubprocessKWArgs, TimeHasPassed

### CONSTANTS ###
if HC.PLATFORM_LINUX:
	SWFRENDER_PATH = os.path.join( HC.BIN_DIR, 'swfrender_linux' )

elif HC.PLATFORM_OSX:
	SWFRENDER_PATH = os.path.join( HC.BIN_DIR, 'swfrender_osx' )

elif HC.PLATFORM_WINDOWS:
	SWFRENDER_PATH = os.path.join( HC.BIN_DIR, 'swfrender_win32.exe' )

# to all out there who write libraries:
# hexagonit.swfheader is a perfect library. it is how you are supposed to do it.

### FUNCTIONS ###

def GetFlashProperties( path ):
	with open( path, 'rb' ) as f:
		metadata = hexagonitswfheader.parse( f )

		width = metadata[ 'width' ]
		height = metadata[ 'height' ]

		num_frames = metadata[ 'frames' ]
		fps = metadata[ 'fps' ]

		duration = ( 1000 * num_frames ) // fps

		return ( ( width, height ), duration, num_frames )

def RenderPageToFile( path, temp_path, page_index ):
	cmd = [ SWFRENDER_PATH, path,  '-o', temp_path, '-p', str( page_index ) ]

	timeout = GetNow() + 60

	sbp_kwargs = GetSubprocessKWArgs()

	p = subprocess.Popen( cmd, **sbp_kwargs )

	while p.poll() is None:
		if TimeHasPassed( timeout ):
			p.terminate()

			raise Exception( 'Could not render the swf page within 60 seconds!' )

		time.sleep( 0.5 )

	p.communicate()


code:
import os, io, hashlib

from lib.Handling.Document import GetPDFNumWords
from lib.Handling.Flash import GetFlashProperties, RenderPageToFile
from lib.Handling.Image import Dequantize, GeneratePILImage, GeneratePILImageFromNumpyImage, \
	GetImageProperties, GetPSDResolution, GetThumbnailResolution, ResizePILImage
from lib.Handling.Video import GetFFMPEGInfoLines, GetFFMPEGVideoProperties, GetVideoMime, \
	HasVideoStream, ParseFFMPEGDuration, VideoRendererFFMPEG

from lib.Hydrus.Data import Print, PrintException
from lib.Hydrus.Exceptions import MimeException, SizeException
from lib.Hydrus.Paths import CleanUpTempPath, GetTempPath, ReadFileLikeAsBlocks
from lib.Hydrus.Text import LooksLikeHTML

### CONSTANTS ###
import lib.Hydrus.Constants as HC

# Mime

header_and_mime = [
	( 0, b'\xff\xd8', HC.IMAGE_JPEG ),
	( 0, b'GIF87a', HC.IMAGE_GIF ),
	( 0, b'GIF89a', HC.IMAGE_GIF ),
	( 0, b'\x89PNG', HC.UNDETERMINED_PNG ),
	( 8, b'WEBP', HC.IMAGE_WEBP ),
	( 0, b'II*\x00', HC.IMAGE_TIFF ),
	( 0, b'MM\x00*', HC.IMAGE_TIFF ),
	( 0, b'BM', HC.IMAGE_BMP ),
	( 0, b'CWS', HC.APPLICATION_FLASH ),
	( 0, b'FWS', HC.APPLICATION_FLASH ),
	( 0, b'ZWS', HC.APPLICATION_FLASH ),
	( 0, b'FLV', HC.VIDEO_FLV ),
	( 0, b'%PDF', HC.APPLICATION_PDF ),
	( 0, b'8BPS\x00\x01', HC.APPLICATION_PSD ),
	( 0, b'8BPS\x00\x02', HC.APPLICATION_PSD ), # PSB, which is basically PSD v2 and does giganto resolution
	( 0, b'PK\x03\x04', HC.APPLICATION_ZIP ),
	( 0, b'PK\x05\x06', HC.APPLICATION_ZIP ),
	( 0, b'PK\x07\x08', HC.APPLICATION_ZIP ),
	( 0, b'7z\xBC\xAF\x27\x1C', HC.APPLICATION_7Z ),
	( 0, b'\x52\x61\x72\x21\x1A\x07\x00', HC.APPLICATION_RAR ),
	( 0, b'\x52\x61\x72\x21\x1A\x07\x01\x00', HC.APPLICATION_RAR ),
	( 0, b'hydrus encrypted zip', HC.APPLICATION_HYDRUS_ENCRYPTED_ZIP ),
	( 4, b'ftypmp4', HC.VIDEO_MP4 ),
	( 4, b'ftypisom', HC.VIDEO_MP4 ),
	( 4, b'ftypM4V', HC.VIDEO_MP4 ),
	( 4, b'ftypMSNV', HC.VIDEO_MP4 ),
	( 4, b'ftypavc1', HC.VIDEO_MP4 ),
	( 4, b'ftypFACE', HC.VIDEO_MP4 ),
	( 4, b'ftypdash', HC.VIDEO_MP4 ),
	( 4, b'ftypqt', HC.VIDEO_MOV ),
	( 0, b'fLaC', HC.AUDIO_FLAC ),
	( 8, b'AVI ', HC.VIDEO_AVI ),
	( 0, b'\x30\x26\xB2\x75\x8E\x66\xCF\x11\xA6\xD9\x00\xAA\x00\x62\xCE\x6C', HC.UNDETERMINED_WM )
]

### FUNCTIONS ###

def SaveThumbnailToStreamPIL( pil_image, bounding_dimensions, f ):
	# when the palette is limited, the thumbnail antialias won't add new colours, so you get nearest-neighbour-like behaviour

	original_file_was_png = pil_image.format == 'PNG'

	pil_image = Dequantize( pil_image )

	thumbnail_dimensions = GetThumbnailResolution( pil_image.size, bounding_dimensions )

	ResizePILImage( pil_image, thumbnail_dimensions )

	if original_file_was_png or pil_image.mode == 'RGBA':
		pil_image.save( f, 'PNG' )

	else:
		pil_image.save( f, 'JPEG', quality = 92 )

def GenerateThumbnailBytes( path, bounding_dimensions, mime, width, height, duration, num_frames, percentage_in = 35 ):
	if mime in ( HC.IMAGE_JPEG, HC.IMAGE_PNG, HC.IMAGE_GIF, HC.IMAGE_WEBP, HC.IMAGE_TIFF ):
		thumbnail_bytes = GenerateThumbnailBytesFromStaticImagePath( path, bounding_dimensions, mime )

	else:
		f = io.BytesIO()

		if mime == HC.APPLICATION_FLASH:
			( os_file_handle, temp_path ) = GetTempPath()

			try:
				RenderPageToFile( path, temp_path, 1 )

				pil_image = GeneratePILImage( temp_path )

				SaveThumbnailToStreamPIL( pil_image, bounding_dimensions, f )

			except Exception:
				flash_default_path = os.path.join( HC.STATIC_DIR, 'flash.png' )

				pil_image = GeneratePILImage( flash_default_path )

				SaveThumbnailToStreamPIL( pil_image, bounding_dimensions, f )

			finally:
				del pil_image

				CleanUpTempPath( os_file_handle, temp_path )

		else:
			cropped_dimensions = GetThumbnailResolution( ( width, height ), bounding_dimensions )

			renderer = VideoRendererFFMPEG( path, mime, duration, num_frames, cropped_dimensions )

			renderer.read_frame() # this initialises the renderer and loads the first frame as a fallback

			desired_thumb_frame = int( ( percentage_in / 100.0 ) * num_frames )

			renderer.set_position( desired_thumb_frame )

			numpy_image = renderer.read_frame()

			if numpy_image is None:
				raise Exception( 'Could not create a thumbnail from that video!' )

			pil_image = GeneratePILImageFromNumpyImage( numpy_image )

			SaveThumbnailToStreamPIL( pil_image, bounding_dimensions, f )

			renderer.Stop()

			del renderer

		f.seek( 0 )

		thumbnail_bytes = f.read()

		f.close()

	return thumbnail_bytes

def GenerateThumbnailBytesFromPIL( pil_image, bounding_dimensions, mime ):
	f = io.BytesIO()

	SaveThumbnailToStreamPIL( pil_image, bounding_dimensions, f )

	f.seek( 0 )

	thumbnail_bytes = f.read()

	f.close()

	return thumbnail_bytes

def GenerateThumbnailBytesFromStaticImagePathPIL( path, bounding_dimensions, mime ):
	pil_image = GeneratePILImage( path )

	return GenerateThumbnailBytesFromPIL( pil_image, bounding_dimensions, mime )

GenerateThumbnailBytesFromStaticImagePath = GenerateThumbnailBytesFromStaticImagePathPIL

def GetExtraHashesFromPath( path ):
	h_md5 = hashlib.md5()
	h_sha1 = hashlib.sha1()
	h_sha512 = hashlib.sha512()

	with open( path, 'rb' ) as f:
		for block in ReadFileLikeAsBlocks( f ):
			h_md5.update( block )
			h_sha1.update( block )
			h_sha512.update( block )

	md5 = h_md5.digest()
	sha1 = h_sha1.digest()
	sha512 = h_sha512.digest()

	return ( md5, sha1, sha512 )

def GetFileInfo( path, mime = None ):
	size = os.path.getsize( path )

	if size == 0:
		raise SizeException( 'File is of zero length!' )

	if mime is None:
		mime = GetMime( path )

	if mime not in HC.ALLOWED_MIMES:
		if mime == HC.TEXT_HTML:
			raise MimeException( 'Looks like HTML -- maybe the client needs to be taught how to parse this?' )

		elif mime == HC.APPLICATION_UNKNOWN:
			raise MimeException( 'Unknown filetype!' )

		else:
			raise MimeException( 'Filetype is not permitted!' )

	width = None
	height = None
	duration = None
	num_frames = None
	num_words = None

	if mime in ( HC.IMAGE_JPEG, HC.IMAGE_PNG, HC.IMAGE_GIF, HC.IMAGE_WEBP, HC.IMAGE_TIFF ):
		( ( width, height ), duration, num_frames ) = GetImageProperties( path, mime )

	elif mime == HC.APPLICATION_FLASH:
		( ( width, height ), duration, num_frames ) = GetFlashProperties( path )

	elif mime in ( HC.IMAGE_APNG, HC.VIDEO_AVI, HC.VIDEO_FLV, HC.VIDEO_WMV, HC.VIDEO_MOV, HC.VIDEO_MP4, HC.VIDEO_MKV, HC.VIDEO_WEBM, HC.VIDEO_MPEG ):
		( ( width, height ), duration, num_frames ) = GetFFMPEGVideoProperties( path )

	elif mime == HC.APPLICATION_PDF:
		num_words = GetPDFNumWords( path ) # this now give None until a better solution can be found

	elif mime == HC.APPLICATION_PSD:
		( width, height ) = GetPSDResolution( path )

	elif mime in HC.AUDIO:
		ffmpeg_lines = GetFFMPEGInfoLines( path )

		( file_duration_in_s, stream_duration_in_s ) = ParseFFMPEGDuration( ffmpeg_lines )

		duration = int( file_duration_in_s * 1000 )

	if width is not None and width < 0:
		width *= -1

	if height is not None and height < 0:
		width *= -1

	if duration is not None and duration < 0:
		duration *= -1

	if num_frames is not None and num_frames < 0:
		num_frames *= -1

	if num_words is not None and num_words < 0:
		num_words *= -1

	return ( size, mime, width, height, duration, num_frames, num_words )

def GetHashFromPath( path ):
	h = hashlib.sha256()

	with open( path, 'rb' ) as f:
		for block in ReadFileLikeAsBlocks( f ):
			h.update( block )

	return h.digest()

def GetMime( path ):
	size = os.path.getsize( path )

	if size == 0:
		raise SizeException( 'File is of zero length!' )

	with open( path, 'rb' ) as f:
		bit_to_check = f.read( 256 )

	for ( offset, header, mime ) in header_and_mime:
		offset_bit_to_check = bit_to_check[ offset: ]

		if offset_bit_to_check.startswith( header ):
			if mime == HC.UNDETERMINED_WM:
				if HasVideoStream( path ):
					return HC.VIDEO_WMV

				# we'll catch and verify wma later

			elif mime == HC.UNDETERMINED_PNG:
				if HasVideoStream( path ):
					return HC.IMAGE_APNG

				else:
					return HC.IMAGE_PNG

			else:
				return mime

	try:
		mime = GetVideoMime( path )

		if mime != HC.APPLICATION_UNKNOWN:
			return mime

	except MimeException:
		Print( 'FFMPEG couldn\'t figure out the mime for:
 	' + path )

	except Exception as e:
		Print( 'FFMPEG couldn\'t figure out the mime for:
 	' + path )
		PrintException( e, do_wait = False )

	if LooksLikeHTML( bit_to_check ):
		return HC.TEXT_HTML

	return HC.APPLICATION_UNKNOWN


code:
import struct, warnings

from PIL import ImageFile as PILImageFile
from PIL import Image as PILImage

from lib.Hydrus.Paths import GetTempPath, CopyFileLikeToFileLike, CleanUpTempPath

### CONSTANTS ###
import lib.Hydrus.Constants as HC

if hasattr( PILImageFile, 'LOAD_TRUNCATED_IMAGES' ):
	PILImageFile.LOAD_TRUNCATED_IMAGES = True

if not hasattr( PILImage, 'DecompressionBombWarning' ):
	# super old versions don't have this, so let's just make a stub, wew

	class DBW_stub( Exception ):
		pass

	PILImage.DecompressionBombWarning = DBW_stub

warnings.simplefilter( 'ignore', PILImage.DecompressionBombWarning )

OLD_PIL_MAX_IMAGE_PIXELS = PILImage.MAX_IMAGE_PIXELS
PILImage.MAX_IMAGE_PIXELS = None # this turns off decomp check entirely, wew

### FUNCTIONS ###

def ConvertToPngIfBmp( path ):
	with open( path, 'rb' ) as f:
		header = f.read( 2 )

	if header == b'BM':
		( os_file_handle, temp_path ) = GetTempPath()

		try:
			with open( path, 'rb' ) as f_source:
				with open( temp_path, 'wb' ) as f_dest:
					CopyFileLikeToFileLike( f_source, f_dest )

			pil_image = GeneratePILImage( temp_path )

			pil_image.save( path, 'PNG' )

		finally:
			CleanUpTempPath( os_file_handle, temp_path )

def Dequantize( pil_image ):
	if pil_image.mode not in ( 'RGBA', 'RGB' ):
		if pil_image.mode == 'LA' or ( pil_image.mode == 'P' and 'transparency' in pil_image.info ):
			pil_image = pil_image.convert( 'RGBA' )

		else:
			pil_image = pil_image.convert( 'RGB' )

	return pil_image

def GeneratePILImage( path ):
	fp = open( path, 'rb' )

	try:
		pil_image = PILImage.open( fp )

	except Exception:
		# pil doesn't clean up its open file on exception, jej

		fp.close()

		raise

	if pil_image.format == 'JPEG' and hasattr( pil_image, '_getexif' ):
		try:
			exif_dict = pil_image._getexif()

		except Exception:
			exif_dict = None

		if exif_dict is not None:
			EXIF_ORIENTATION = 274

			if EXIF_ORIENTATION in exif_dict:
				orientation = exif_dict[ EXIF_ORIENTATION ]

				if orientation == 1:
					pass # normal

				elif orientation == 2:
					# mirrored horizontal

					pil_image = pil_image.transpose( PILImage.FLIP_LEFT_RIGHT )

				elif orientation == 3:
					# 180

					pil_image = pil_image.transpose( PILImage.ROTATE_180 )

				elif orientation == 4:
					# mirrored vertical

					pil_image = pil_image.transpose( PILImage.FLIP_TOP_BOTTOM )

				elif orientation == 5:
					# seems like these 90 degree rotations are wrong, but fliping them works for my posh example images, so I guess the PIL constants are odd

					# mirrored horizontal, then 90 CCW

					pil_image = pil_image.transpose( PILImage.FLIP_LEFT_RIGHT ).transpose( PILImage.ROTATE_90 )

				elif orientation == 6:
					# 90 CW

					pil_image = pil_image.transpose( PILImage.ROTATE_270 )

				elif orientation == 7:
					# mirrored horizontal, then 90 CCW

					pil_image = pil_image.transpose( PILImage.FLIP_LEFT_RIGHT ).transpose( PILImage.ROTATE_270 )

				elif orientation == 8:
					# 90 CCW

					pil_image = pil_image.transpose( PILImage.ROTATE_90 )

	if pil_image is None:
		raise Exception( 'The file at ' + path + ' could not be rendered!' )

	return pil_image

def GeneratePILImageFromNumpyImage( numpy_image ):
	( h, w, depth ) = numpy_image.shape

	if depth == 3:
		format = 'RGB'

	elif depth == 4:
		format = 'RGBA'

	pil_image = PILImage.frombytes( format, ( w, h ), numpy_image.data.tobytes() )

	return pil_image

def GetGIFFrameDurations( path ):
	pil_image = GeneratePILImage( path )

	frame_durations = []

	i = 0

	while True:
		try:
			pil_image.seek( i )

		except Exception:
			break

		if 'duration' not in pil_image.info:
			duration = 83 # (83ms -- 1000 / 12) Set a 12 fps default when duration is missing or too funky to extract. most stuff looks ok at this.

		else:
			duration = pil_image.info[ 'duration' ]

			# In the gif frame header, 10 is stored as 1ms. This 1 is commonly as utterly wrong as 0.
			if duration in ( 0, 10 ):
				duration = 83

		frame_durations.append( duration )

		i += 1

	return frame_durations

def GetImageProperties( path, mime ):
	( ( width, height ), num_frames ) = GetResolutionAndNumFrames( path, mime )

	if num_frames > 1:
		durations = GetGIFFrameDurations( path )

		duration = sum( durations )

	else:
		duration = None
		num_frames = None

	return ( ( width, height ), duration, num_frames )

def GetPSDResolution( path ):
	with open( path, 'rb' ) as f:
		f.seek( 14 )

		height_bytes = f.read( 4 )
		width_bytes = f.read( 4 )

	height = struct.unpack( '>L', height_bytes )[0]
	width = struct.unpack( '>L', width_bytes )[0]

	return ( width, height )

def GetResolutionAndNumFrames( path, mime ):
	pil_image = GeneratePILImage( path )

	( x, y ) = pil_image.size

	if mime == HC.IMAGE_GIF: # some jpegs came up with 2 frames and 'duration' because of some embedded thumbnail in the metadata

		try:
			pil_image.seek( 1 )
			pil_image.seek( 0 )

			num_frames = 1

			while True:
				try:
					pil_image.seek( pil_image.tell() + 1 )
					num_frames += 1

				except Exception:
					break

		except Exception:
			num_frames = 1

	else:
		num_frames = 1

	return ( ( x, y ), num_frames )

def GetThumbnailResolution( image_resolution, bounding_resolution ):
	( im_width, im_height ) = image_resolution
	( bounding_width, bounding_height ) = bounding_resolution

	if bounding_width >= im_width and bounding_height >= im_height:
		return ( im_width, im_height )

	width_ratio = im_width / bounding_width
	height_ratio = im_height / bounding_height

	thumbnail_width = bounding_width
	thumbnail_height = bounding_height

	if width_ratio > height_ratio:
		thumbnail_height = im_height / width_ratio

	elif height_ratio > width_ratio:
		thumbnail_width = im_width / height_ratio

	thumbnail_width = max( int( thumbnail_width ), 1 )
	thumbnail_height = max( int( thumbnail_height ), 1 )

	return ( thumbnail_width, thumbnail_height )

def IsDecompressionBomb( path ):
	# I boosted this up x2 as a temp test
	PILImage.MAX_IMAGE_PIXELS = OLD_PIL_MAX_IMAGE_PIXELS * 2

	warnings.simplefilter( 'error', PILImage.DecompressionBombWarning )

	try:
		GeneratePILImage( path )

	except ( PILImage.DecompressionBombWarning, PILImage.DecompressionBombError ):
		return True

	finally:
		PILImage.MAX_IMAGE_PIXELS = None

		warnings.simplefilter( 'ignore', PILImage.DecompressionBombWarning )

	return False

def ResizePILImage( pil_image, target_resolution ):
	( target_x, target_y ) = target_resolution
	( im_x, im_y ) = pil_image.size

	return pil_image.resize( ( target_x, target_y ), PILImage.ANTIALIAS )


code:
import numpy.core.multiarray, cv2 # numpy must come before cv2

from lib.Handling import GenerateThumbnailBytesFromStaticImagePathPIL
from lib.Handling.Image import Dequantize, GeneratePILImage, GetThumbnailResolution

from lib.Hydrus.Data import ShowText
from lib.Hydrus.Exceptions import CantRenderWithCVException

from functools import reduce

### CONSTANTS ###
import lib.Hydrus.Globals as HG
import lib.Hydrus.Constants as HC
import lib.Hydrus.Constants.Local as CC

if cv2.__version__.startswith( '2' ):
	CV_IMREAD_FLAGS_SUPPORTS_ALPHA = cv2.CV_LOAD_IMAGE_UNCHANGED
	CV_IMREAD_FLAGS_SUPPORTS_EXIF_REORIENTATION = CV_IMREAD_FLAGS_SUPPORTS_ALPHA

	# there's something wrong with these, but I don't have an easy test env for it atm
	# CV_IMREAD_FLAGS_SUPPORTS_EXIF_REORIENTATION = cv2.CV_LOAD_IMAGE_ANYDEPTH | cv2.CV_LOAD_IMAGE_ANYCOLOR

	CV_JPEG_THUMBNAIL_ENCODE_PARAMS = []
	CV_PNG_THUMBNAIL_ENCODE_PARAMS = []

else:
	CV_IMREAD_FLAGS_SUPPORTS_ALPHA = cv2.IMREAD_UNCHANGED
	CV_IMREAD_FLAGS_SUPPORTS_EXIF_REORIENTATION = cv2.IMREAD_ANYDEPTH | cv2.IMREAD_ANYCOLOR # this preserves colour info but does EXIF reorientation and flipping

	CV_JPEG_THUMBNAIL_ENCODE_PARAMS = [ cv2.IMWRITE_JPEG_QUALITY, 92 ]
	CV_PNG_THUMBNAIL_ENCODE_PARAMS = [ cv2.IMWRITE_PNG_COMPRESSION, 9 ]

cv_interpolation_enum_lookup = {}

cv_interpolation_enum_lookup[ CC.ZOOM_NEAREST ] = cv2.INTER_NEAREST
cv_interpolation_enum_lookup[ CC.ZOOM_LINEAR ] = cv2.INTER_LINEAR
cv_interpolation_enum_lookup[ CC.ZOOM_AREA ] = cv2.INTER_AREA
cv_interpolation_enum_lookup[ CC.ZOOM_CUBIC ] = cv2.INTER_CUBIC
cv_interpolation_enum_lookup[ CC.ZOOM_LANCZOS4 ] = cv2.INTER_LANCZOS4

### FUNCTIONS ###

def GenerateNumpyImage( path, mime ):
	if HG.media_load_report_mode:
		ShowText( 'Loading media: ' + path )

	if mime == HC.IMAGE_GIF or HG.client_controller.new_options.GetBoolean( 'load_images_with_pil' ):
		if HG.media_load_report_mode:
			ShowText( 'Loading with PIL' )

		# a regular cv.imread call, can crash the whole process on random thumbs, hooray, so have this as backup
		# it was just the read that was the problem, so this seems to work fine, even if pil is only about half as fast

		pil_image = GeneratePILImage( path )

		numpy_image = GenerateNumPyImageFromPILImage( pil_image )

	else:
		if HG.media_load_report_mode:
			ShowText( 'Loading with OpenCV' )

		if mime == HC.IMAGE_JPEG:
			flags = CV_IMREAD_FLAGS_SUPPORTS_EXIF_REORIENTATION

		else:
			flags = CV_IMREAD_FLAGS_SUPPORTS_ALPHA

		numpy_image = cv2.imread( path, flags = flags )

		if numpy_image is None: # doesn't support static gifs and some random other stuff

			if HG.media_load_report_mode:
				ShowText( 'OpenCV Failed, loading with PIL' )

			pil_image = GeneratePILImage( path )

			numpy_image = GenerateNumPyImageFromPILImage( pil_image )

		else:
			if numpy_image.dtype == 'uint16':
				numpy_image //= 256

				numpy_image = numpy.array( numpy_image, dtype = 'uint8' )

			shape = numpy_image.shape

			if len( shape ) == 2:
				# monochrome image

				convert = cv2.COLOR_GRAY2RGB

			else:
				( im_y, im_x, depth ) = shape

				if depth == 4:
					convert = cv2.COLOR_BGRA2RGBA

				else:
					convert = cv2.COLOR_BGR2RGB

			numpy_image = cv2.cvtColor( numpy_image, convert )

	return numpy_image

def GenerateNumPyImageFromPILImage( pil_image ):
	pil_image = Dequantize( pil_image )

	( w, h ) = pil_image.size

	s = pil_image.tobytes()

	return numpy.fromstring( s, dtype = 'uint8' ).reshape( ( h, w, len( s ) // ( w * h ) ) )

def GenerateShapePerceptualHashes( path, mime ):
	numpy_image = GenerateNumpyImage( path, mime )

	( y, x, depth ) = numpy_image.shape

	if depth == 4:
		# doing this on 10000x10000 pngs eats ram like mad
		numpy_image = ThumbnailNumpyImage( numpy_image, ( 1024, 1024 ) )

		( y, x, depth ) = numpy_image.shape

		# create weight and transform numpy_image to greyscale

		numpy_alpha = numpy_image[ :, :, 3 ]

		numpy_alpha_float = numpy_alpha / 255.0

		numpy_image_bgr = numpy_image[ :, :, :3 ]

		numpy_image_gray_bare = cv2.cvtColor( numpy_image_bgr, cv2.COLOR_RGB2GRAY )

		# create a white greyscale canvas

		white = numpy.ones( ( y, x ) ) * 255.0

		# paste the grayscale image onto the white canvas using: pixel * alpha + white * ( 1 - alpha )

		numpy_image_gray = numpy.uint8( ( numpy_image_gray_bare * numpy_alpha_float ) + ( white * ( numpy.ones( ( y, x ) ) - numpy_alpha_float ) ) )

	else:
		numpy_image_gray = cv2.cvtColor( numpy_image, cv2.COLOR_RGB2GRAY )

	numpy_image_tiny = cv2.resize( numpy_image_gray, ( 32, 32 ), interpolation = cv2.INTER_AREA )

	# convert to float and calc dct

	numpy_image_tiny_float = numpy.float32( numpy_image_tiny )

	dct = cv2.dct( numpy_image_tiny_float )

	# take top left 8x8 of dct

	dct_88 = dct[:8, :8]

	# get median of dct
	# exclude [0,0], which represents flat colour
	# this [0,0] exclusion is apparently important for mean, but maybe it ain't so important for median--w/e

	# old mean code
	# mask = numpy.ones( ( 8, 8 ) )
	# mask[0,0] = 0
	# average = numpy.average( dct_88, weights = mask )

	median = numpy.median( dct_88.reshape( 64 )[1:] )

	# make a monochromatic, 64-bit hash of whether the entry is above or below the median

	dct_88_boolean = dct_88 > median

	# convert TTTFTFTF to 11101010 by repeatedly shifting answer and adding 0 or 1
	# you can even go ( a << 1 ) + b and leave out the initial param on the reduce call as bools act like ints for this
	# but let's not go crazy for another two nanoseconds

	def collapse_bools_to_binary_uint( a, b ):
		return ( a << 1 ) + int( b )

	list_of_bytes = []

	for i in range( 8 ):
		'''
		# old way of doing it, which compared value to median every time
		byte = 0

		for j in range( 8 ):
			byte <<= 1 # shift byte one left

			value = dct_88[i,j]

			if value > median:
				byte |= 1

		'''

		# this is a 0-255 int
		byte = reduce( collapse_bools_to_binary_uint, dct_88_boolean[i], 0 )

		list_of_bytes.append( byte )

	phash = bytes( list_of_bytes ) # this works!

	# now discard the blank hash, which is 1000000... and not useful

	phashes = set()

	phashes.add( phash )

	phashes.discard( CC.BLANK_PHASH )

	# we good

	return phashes

def GenerateBytesFromCV( numpy_image, mime ):
	( im_y, im_x, depth ) = numpy_image.shape

	if depth == 4:
		convert = cv2.COLOR_RGBA2BGRA

	else:
		convert = cv2.COLOR_RGB2BGR

	numpy_image = cv2.cvtColor( numpy_image, convert )

	if mime == HC.IMAGE_JPEG:
		ext = '.jpg'

		params = CV_JPEG_THUMBNAIL_ENCODE_PARAMS

	else:
		ext = '.png'

		params = CV_PNG_THUMBNAIL_ENCODE_PARAMS

	( result_success, result_byte_array ) = cv2.imencode( ext, numpy_image, params )

	if result_success:
		thumbnail_bytes = result_byte_array.tostring()

		return thumbnail_bytes

	else:
		raise CantRenderWithCVException( 'Thumb failed to encode!' )

def GenerateThumbnailBytesFromStaticImagePathCV( path, bounding_dimensions, mime ):
	if mime == HC.IMAGE_GIF:
		return GenerateThumbnailBytesFromStaticImagePathPIL( path, bounding_dimensions, mime )

	numpy_image = GenerateNumpyImage( path, mime )

	thumbnail_numpy_image = ThumbnailNumpyImage( numpy_image, bounding_dimensions )

	try:
		thumbnail_bytes = GenerateBytesFromCV( thumbnail_numpy_image, mime )

		return thumbnail_bytes

	except CantRenderWithCVException:
		return GenerateThumbnailBytesFromStaticImagePathPIL( path, bounding_dimensions, mime )

def GetNumPyImageResolution( numpy_image ):
	( image_height, image_width, depth ) = numpy_image.shape

	return ( image_width, image_height )

def ResizeNumpyImage( numpy_image, target_resolution ):
	( target_width, target_height ) = target_resolution
	( image_width, image_height, depth ) = numpy_image.shape

	if target_width == image_width and target_height == target_width:
		return numpy_image

	elif target_width > image_height or target_height > image_width:
		interpolation = cv2.INTER_LANCZOS4

	else:
		interpolation = cv2.INTER_AREA

	return cv2.resize( numpy_image, ( target_width, target_height ), interpolation = interpolation )

def ResizeNumpyImageForMediaViewer( mime, numpy_image, target_resolution ):
	( target_width, target_height ) = target_resolution
	new_options = HG.client_controller.new_options

	( scale_up_quality, scale_down_quality ) = new_options.GetMediaZoomQuality( mime )

	( image_width, image_height, depth ) = numpy_image.shape

	if ( target_width, target_height ) == ( image_height, image_width ):
		return numpy_image

	else:
		if target_width > image_height or target_height > image_width:
			interpolation = cv_interpolation_enum_lookup[ scale_up_quality ]

		else:
			interpolation = cv_interpolation_enum_lookup[ scale_down_quality ]

		return cv2.resize( numpy_image, ( target_width, target_height ), interpolation = interpolation )

def ThumbnailNumpyImage( numpy_image, bounding_dimensions ):
	( bounding_width, bounding_height ) = bounding_dimensions
	( image_height, image_width, depth ) = numpy_image.shape

	if bounding_width >= image_height and bounding_height >= image_width:
		return numpy_image

	target_resolution = GetThumbnailResolution( ( image_width, image_height ), ( bounding_width, bounding_height ) )

	return ResizeNumpyImage( numpy_image, target_resolution )


code:
from .Image import *

__all__ = [
	"Local"
]

from . import *