diff --git a/script.timers/addon.py b/script.timers/addon.py index 955769c7f4..da44196a8b 100644 --- a/script.timers/addon.py +++ b/script.timers/addon.py @@ -1,7 +1,9 @@ import migration import service +from resources.lib.utils import housekeeper if __name__ == "__main__": migration.migrate() + housekeeper.cleanup_outdated_timers() service.run() diff --git a/script.timers/addon.xml b/script.timers/addon.xml index 49f9a7897d..74b21bca5c 100644 --- a/script.timers/addon.xml +++ b/script.timers/addon.xml @@ -1,9 +1,9 @@ - + - + @@ -66,6 +66,13 @@ https://github.com/Heckie75/kodi-addon-timers https://github.com/Heckie75/kodi-addon-timers +v4.0.0 (2024-08-31) +- New Feature: programming timers with full date (not only day within upcoming 7 days, feature request #34) +- Added Preset of implicitly extra time / offset for EPG timers +- Improved stop behavior of overlapping media timers acc. its priority +- Bugfix / Workaround: [Kodi v21] Settings dialog is broken, issue #43 +- Bugfix: [Kodi v21] Addon can't play PVR items anymore, issue #42 + v3.9.3 (2024-08-02) - Bugfix: Fixed that 'running-high-prio-fading-only-timer' prevents starting other media timers within its period @@ -87,12 +94,6 @@ v3.7.0 (2023-06-30) - If you stop explicitly playback while a start-stop-timer is running there won't be another stop action anymore when this timer runs out. - Added workaround that streamed video (probably mpeg-dash) immediately stops after timer has started (only happened if 'seek to correct time if timer starts belatedly' is activated) -v3.6.0 (2023-04-16) -- Smart shuffle mode for slideshows (try randomly to find folder that fits into timeframe) -- Fixed resuming slideshow at right position -- Fixed resuming of enclosing timers especially combination of concurrent media types like video, audio or slideshow -- Improved notifications and competitive behavior (introduced extra low priority) - Complete changelog see https://github.com/Heckie75/kodi-addon-timers diff --git a/script.timers/migration.py b/script.timers/migration.py index 1ff30fbbae..2563fe7fc6 100644 --- a/script.timers/migration.py +++ b/script.timers/migration.py @@ -4,6 +4,7 @@ from resources.lib.timer.storage import Storage from resources.lib.utils.settings_utils import ( activate_on_settings_changed_events, deactivate_on_settings_changed_events) +from resources.lib.utils.system_utils import get_kodi_version def migrate_from_1_to_2(addon: xbmcaddon.Addon) -> int: @@ -220,6 +221,18 @@ def migrate_from_6_to_7(addon: xbmcaddon.Addon) -> int: return 7 +def migrate_from_7_to_8(addon: xbmcaddon.Addon) -> int: + + storage = Storage() + items = storage._load_from_storage() + for item in items: + item["date"] = "" + + storage._save_to_storage(items) + + return 8 + + def migrate() -> None: addon = xbmcaddon.Addon() @@ -246,6 +259,10 @@ def migrate() -> None: if settingsVersion == 6: settingsVersion = migrate_from_6_to_7(addon) + if settingsVersion == 7: + settingsVersion = migrate_from_7_to_8(addon) + addon.setSettingInt("settingsVersion", settingsVersion) + addon.setSettingInt("kodiVersion", int(get_kodi_version() * 100)) activate_on_settings_changed_events() diff --git a/script.timers/resources/language/resource.language.de_de/strings.po b/script.timers/resources/language/resource.language.de_de/strings.po index ea469e4d61..a6e36273bb 100644 --- a/script.timers/resources/language/resource.language.de_de/strings.po +++ b/script.timers/resources/language/resource.language.de_de/strings.po @@ -125,6 +125,14 @@ msgctxt "#32042" msgid "from" msgstr "von" +msgctxt "#32043" +msgid "specific date" +msgstr "spezifisches Datum" + +msgctxt "#32044" +msgid "Date" +msgstr "Datum" + msgctxt "#32050" msgid "This timer interrupts other timers" msgstr "Dieser Timer unterbricht andere Timer" @@ -526,12 +534,12 @@ msgid "Pause from" msgstr "Pausiere von" msgctxt "#32170" -msgid "Offset" -msgstr "Versatz" +msgid "Real-time clock" +msgstr "Zeitgeber" msgctxt "#32171" -msgid "Offset for timers in seconds" -msgstr "Versatz für Timer in Sekunden" +msgid "Offset for real-time clock in seconds" +msgstr "Versatz für Zeitgeber in Sekunden" msgctxt "#32180" msgid "Presets for epg quick timer" @@ -549,6 +557,38 @@ msgctxt "#32183" msgid "always ask" msgstr "jedes mal fragen" +msgctxt "#32184" +msgid "Offset at start" +msgstr "Versatz zum Start" + +msgctxt "#32185" +msgid "Offset at end" +msgstr "Versatz zum Ende" + +msgctxt "#32186" +msgid "Offset for TV programs" +msgstr "Versatz für TV-Sendungen" + +msgctxt "#32187" +msgid "Offset for radio programs" +msgstr "Versatz für Radiosendungen" + +msgctxt "#32190" +msgid "Housekeeping" +msgstr "Haushaltung" + +msgctxt "#32191" +msgid "Cleanup missed timers at startup" +msgstr "Räume verpasste Timer beim Starten auf" + +msgctxt "#32192" +msgid "Cleanup missed timers when Kodi starts. This can happen if Kodi was not running when timer has been scheduled." +msgstr "Räume verpasste Timer auf wenn Kodi gestartet wird. Das passiert, wenn Kodi nicht lief während ein Timer geplant war." + +msgctxt "#32193" +msgid "" +msgstr "" + msgctxt "#32200" msgid "Monday" msgstr "Montag" @@ -851,4 +891,12 @@ msgstr "Bestimme die Strategie für die Priorität neuer Quicktimer." msgctxt "#32387" msgid "Mediatype is normally determined automatically. Set type manually e.g. if resuming doesn't work as expected." -msgstr "Die Medienart wird automatisch ermittelt. Setze die Art manuell, z.B. wenn die Fortsetzung nicht richtig funktioniert." \ No newline at end of file +msgstr "Die Medienart wird automatisch ermittelt. Setze die Art manuell, z.B. wenn die Fortsetzung nicht richtig funktioniert." + +msgctxt "#32388" +msgid "Schedule this timer with full date and not only by day within one week." +msgstr "Programmiere diesen Timer zu einem Datum und nicht nur an einem Tag innerhalb einer Woche." + +msgctxt "#32385" +msgid "You can set an offset in seconds that is preset for EPG timers." +msgstr "Optional kann ein Versatz in Sekunden angegeben werden, der für EPG Timer voreingestellt wird." diff --git a/script.timers/resources/language/resource.language.en_gb/strings.po b/script.timers/resources/language/resource.language.en_gb/strings.po index 930bf4165e..a66641d0a0 100644 --- a/script.timers/resources/language/resource.language.en_gb/strings.po +++ b/script.timers/resources/language/resource.language.en_gb/strings.po @@ -125,6 +125,14 @@ msgctxt "#32042" msgid "from" msgstr "" +msgctxt "#32043" +msgid "specific date" +msgstr "" + +msgctxt "#32044" +msgid "Date" +msgstr "" + msgctxt "#32050" msgid "This timer interrupts other timers" msgstr "" @@ -526,11 +534,11 @@ msgid "Pause from" msgstr "" msgctxt "#32170" -msgid "Offset" +msgid "Real-time clock" msgstr "" msgctxt "#32171" -msgid "Offset for timers in seconds" +msgid "Offset for real-time clock in seconds" msgstr "" msgctxt "#32180" @@ -549,6 +557,38 @@ msgctxt "#32183" msgid "always ask" msgstr "" +msgctxt "#32184" +msgid "Offset at start" +msgstr "" + +msgctxt "#32185" +msgid "Offset at end" +msgstr "" + +msgctxt "#32186" +msgid "Offset for TV programs" +msgstr "" + +msgctxt "#32187" +msgid "Offset for radio programs" +msgstr "" + +msgctxt "#32190" +msgid "Housekeeping" +msgstr "" + +msgctxt "#32191" +msgid "Cleanup missed timers at startup" +msgstr "" + +msgctxt "#32192" +msgid "Cleanup missed timers when Kodi starts. This can happen if Kodi was not running when timer has been scheduled." +msgstr "" + +msgctxt "#32193" +msgid "" +msgstr "" + msgctxt "#32200" msgid "Monday" msgstr "" @@ -851,4 +891,8 @@ msgstr "" msgctxt "#32387" msgid "Mediatype is normally determined automatically. Set type manually e.g. if resuming doesn't work as expected." -msgstr "" \ No newline at end of file +msgstr "" + +msgctxt "#32388" +msgid "Schedule this timer with full date and not only by day within one week." +msgstr "" diff --git a/script.timers/resources/lib/contextmenu/abstract_set_timer.py b/script.timers/resources/lib/contextmenu/abstract_set_timer.py index 50a0d95b74..ea554a75df 100644 --- a/script.timers/resources/lib/contextmenu/abstract_set_timer.py +++ b/script.timers/resources/lib/contextmenu/abstract_set_timer.py @@ -1,17 +1,18 @@ import time -from datetime import datetime +from datetime import datetime, timedelta import xbmc import xbmcaddon import xbmcgui from resources.lib.contextmenu import pvr_utils -from resources.lib.player.mediatype import VIDEO, SCRIPT +from resources.lib.player.mediatype import SCRIPT, VIDEO from resources.lib.timer.concurrency import determine_overlappings from resources.lib.timer.storage import Storage from resources.lib.timer.timer import (END_TYPE_DURATION, END_TYPE_NO, END_TYPE_TIME, FADE_OFF, MEDIA_ACTION_START_STOP, - SYSTEM_ACTION_NONE, Timer) + SYSTEM_ACTION_NONE, TIMER_BY_DATE, + Timer) from resources.lib.utils import datetime_utils, vfs_utils from resources.lib.utils.settings_utils import (CONFIRM_CUSTOM, CONFIRM_ESCAPE, CONFIRM_NO, CONFIRM_YES, @@ -58,6 +59,13 @@ def __init__(self, label: str, path: str, timerid=-1) -> None: else: timer.days = days + if timer.is_timer_by_date(): + date = self.ask_date(timer.label, path, is_epg, timer) + if date == None: + return + else: + timer.set_timer_by_date(date) + starttime = self.ask_starttime(timer.label, path, is_epg, timer) if starttime == None: return @@ -103,8 +111,10 @@ def __init__(self, label: str, path: str, timerid=-1) -> None: timer.vol_max = vol_max timer.init() + now = datetime.today() + timer.to_timer_by_date(base=now) overlappings = determine_overlappings( - timer, self.storage.load_timers_from_storage(), ignore_extra_prio=True) + timer, self.storage.load_timers_from_storage(), ignore_extra_prio=True, to_display=True, base=now) if overlappings: answer = self.handle_overlapping_timers( timer, overlapping_timers=overlappings) @@ -131,7 +141,7 @@ def is_supported(self, label: str, path: str) -> bool: elif vfs_utils.is_pvr(path): return vfs_utils.is_pvr_channel(path) or vfs_utils.is_pvr_recording(path) or xbmc.getCondVisibility("Window.IsVisible(tvguide)|Window.IsVisible(radioguide)") else: - return vfs_utils.is_script(path) or vfs_utils.is_external(path) or not vfs_utils.is_folder(path) or vfs_utils.has_items_in_path(path) + return vfs_utils.is_script(path) or vfs_utils.is_audio_plugin(path) or vfs_utils.is_video_plugin(path) or vfs_utils.is_external(path) or not vfs_utils.is_folder(path) or vfs_utils.has_items_in_path(path) def perform_ahead(self, timer: Timer) -> bool: @@ -153,6 +163,14 @@ def ask_days(self, label: str, path: str, is_epg: bool, timer: Timer) -> 'list[i else: return [datetime.today().weekday()] + def ask_date(self, label: str, path: str, is_epg: bool, timer: Timer) -> str: + + if is_epg: + return timer.date + + else: + return datetime_utils.to_date_str(datetime.today()) + def ask_starttime(self, label: str, path: str, is_epg: bool, timer: Timer) -> str: if is_epg: @@ -218,19 +236,37 @@ def _get_timer_preselection(self, timerid: int, label: str, path: str) -> 'tuple timer.path = pvr_channel_path startDate = datetime_utils.parse_xbmc_shortdate( xbmc.getInfoLabel("ListItem.Date").split(" ")[0]) - timer.days = [startDate.weekday()] - timer.start = xbmc.getInfoLabel("ListItem.StartTime") - duration = xbmc.getInfoLabel("ListItem.Duration") - if len(duration) == 5: - timer.duration = "00:%s" % duration[:2] - elif len(duration) == 9: + timer.set_timer_by_date( + date=datetime_utils.to_date_str(startDate)) + start = datetime_utils.parse_time( + xbmc.getInfoLabel("ListItem.StartTime")) + start_offset = timedelta( + seconds=self.addon.getSettingInt("epg_tv_offset_start" if vfs_utils.is_pvr_tv_channel(pvr_channel_path) else "epg_radio_offset_start")) + start += start_offset + timer.start, timer.start_offset = datetime_utils.format_from_timedelta( + start) + + s_duration = xbmc.getInfoLabel("ListItem.Duration") + if len(s_duration) == 5: + s_duration = "00:%s" % s_duration[:2] + + elif len(s_duration) == 9: return None, False else: - timer.duration = duration[:5] + s_duration = s_duration[:5] + + td_duration = datetime_utils.parse_time( + s_duration) - start_offset + td_duration += timedelta(seconds=self.addon.getSettingInt( + "epg_tv_offset_end" if vfs_utils.is_pvr_tv_channel(pvr_channel_path) else "epg_radio_offset_end")) - td_start = datetime_utils.parse_time(timer.start) + timer.duration, timer.duration_offset = datetime_utils.format_from_timedelta( + td_duration) + + td_start = datetime_utils.parse_time( + timer.start) + timedelta(seconds=timer.start_offset) if not is_epg: if not timer.days or timer.days == [datetime_utils.WEEKLY]: @@ -238,6 +274,8 @@ def _get_timer_preselection(self, timerid: int, label: str, path: str) -> 'tuple timer.days.append(now.dt.weekday() if not td_start.seconds or td_start.seconds > now.td.seconds else (now.dt.weekday() + 1) % 7) + timer.date = datetime_utils.to_date_str(now.dt) + if vfs_utils.is_favourites(path): timer.path = vfs_utils.get_favourites_target(path) else: @@ -245,8 +283,8 @@ def _get_timer_preselection(self, timerid: int, label: str, path: str) -> 'tuple timer.duration = timer.get_duration() - timer.end = datetime_utils.format_from_seconds( - (td_start + datetime_utils.parse_time(timer.duration)).seconds) + timer.end, timer.end_offset = datetime_utils.format_from_timedelta( + td_start + datetime_utils.parse_time(timer.duration) + timedelta(seconds=timer.duration_offset)) if vfs_utils.is_script(timer.path): timer.media_type = SCRIPT diff --git a/script.timers/resources/lib/contextmenu/pvr_utils.py b/script.timers/resources/lib/contextmenu/pvr_utils.py index 8672b34b9f..cab7829766 100644 --- a/script.timers/resources/lib/contextmenu/pvr_utils.py +++ b/script.timers/resources/lib/contextmenu/pvr_utils.py @@ -2,6 +2,7 @@ import xbmc from resources.lib.utils.jsonrpc_utils import json_rpc +from resources.lib.utils.system_utils import get_kodi_version PVR_TV = "tv" PVR_RADIO = "radio" @@ -10,6 +11,9 @@ _WINDOW_RADIO_GUIDE = 10707 _PLAY_PVR_URL_PATTERN = "pvr://channels/%s/%s/%s_%i.pvr" +_PLAY_PVR_URL_PATTERN_V21 = "pvr://channels/%s/%s@%i/%i@%s_%i.pvr" + +_CHANNEL_GROUP_ALL_ID = -1 def get_current_epg_view() -> str: @@ -47,7 +51,10 @@ def get_pvr_channel_path(type: str, channelno: str) -> str: == True and _c["clientid"] == channels[0]["clientid"]][0] if channelGroupAll and pvrClient and channels[0]: - return _PLAY_PVR_URL_PATTERN % (type, parse.quote(channelGroupAll), pvrClient["addonid"], channels[0]["uniqueid"]) + if get_kodi_version() < 21.0: + return _PLAY_PVR_URL_PATTERN % (type, parse.quote(channelGroupAll), pvrClient["addonid"], channels[0]["uniqueid"]) + else: + return _PLAY_PVR_URL_PATTERN_V21 % (type, parse.quote(channelGroupAll), _CHANNEL_GROUP_ALL_ID, pvrClient["instanceid"], pvrClient["addonid"], channels[0]["uniqueid"]) except: pass diff --git a/script.timers/resources/lib/contextmenu/set_quick_epg_timer.py b/script.timers/resources/lib/contextmenu/set_quick_epg_timer.py index 1152df4633..663ff1502b 100644 --- a/script.timers/resources/lib/contextmenu/set_quick_epg_timer.py +++ b/script.timers/resources/lib/contextmenu/set_quick_epg_timer.py @@ -20,6 +20,7 @@ def perform_ahead(self, timer: Timer) -> bool: for i, t in enumerate(timers): if (found == -1 and timer.days == t.days + and timer.date == t.date and timer.start == t.start and timer.path == t.path): diff --git a/script.timers/resources/lib/contextmenu/set_sleep.py b/script.timers/resources/lib/contextmenu/set_sleep.py index d9ca8f024f..317b11386c 100644 --- a/script.timers/resources/lib/contextmenu/set_sleep.py +++ b/script.timers/resources/lib/contextmenu/set_sleep.py @@ -2,7 +2,7 @@ import xbmcgui from resources.lib.contextmenu.abstract_set_timer import AbstractSetTimer from resources.lib.timer.timer import MEDIA_ACTION_STOP_AT_END, Timer -from resources.lib.utils.datetime_utils import DEFAULT_TIME +from resources.lib.utils import datetime_utils class SetSleep(AbstractSetTimer): @@ -31,7 +31,7 @@ def ask_duration(self, label: str, path: str, is_epg: bool, timer: Timer) -> str else: _default_duration = self.addon.getSetting("sleep_default_duration") _current = timer.get_duration() - _current = _default_duration if DEFAULT_TIME else _current + _current = _default_duration if datetime_utils.DEFAULT_TIME else _current duration = xbmcgui.Dialog().numeric( 2, self.addon.getLocalizedString(32106), _current) diff --git a/script.timers/resources/lib/contextmenu/set_snooze.py b/script.timers/resources/lib/contextmenu/set_snooze.py index 7fce815157..bafbd40fd5 100644 --- a/script.timers/resources/lib/contextmenu/set_snooze.py +++ b/script.timers/resources/lib/contextmenu/set_snooze.py @@ -7,7 +7,7 @@ from resources.lib.player.mediatype import AUDIO, VIDEO from resources.lib.timer.timer import (MEDIA_ACTION_STOP_START, SYSTEM_ACTION_NONE, Timer) -from resources.lib.utils.datetime_utils import DEFAULT_TIME +from resources.lib.utils import datetime_utils class SetSnooze(AbstractSetTimer): @@ -38,7 +38,7 @@ def ask_duration(self, label: str, path: str, is_epg: bool, timer: Timer) -> str _default_duration = self.addon.getSetting("snooze_default_duration") _current = timer.get_duration() - _current = _default_duration if DEFAULT_TIME else _current + _current = _default_duration if datetime_utils.DEFAULT_TIME else _current duration = xbmcgui.Dialog().numeric( 2, self.addon.getLocalizedString(32106), _current) diff --git a/script.timers/resources/lib/contextmenu/set_timer.py b/script.timers/resources/lib/contextmenu/set_timer.py index 5a448bb77e..9d0cbc8a32 100644 --- a/script.timers/resources/lib/contextmenu/set_timer.py +++ b/script.timers/resources/lib/contextmenu/set_timer.py @@ -4,7 +4,7 @@ from resources.lib.timer.concurrency import ask_overlapping_timers from resources.lib.timer.timer import (MEDIA_ACTION_START, MEDIA_ACTION_START_STOP, Timer) -from resources.lib.utils.datetime_utils import DEFAULT_TIME +from resources.lib.utils import datetime_utils from resources.lib.utils.settings_utils import (load_timer_into_settings, select_timer) @@ -34,6 +34,7 @@ def ask_days(self, label: str, path: str, is_epg: bool, timer: Timer) -> 'list[i options = [self.addon.getLocalizedString(32200 + i) for i in range(7)] options.append(self.addon.getLocalizedString(32036)) + options.append(self.addon.getLocalizedString(32043)) selection = xbmcgui.Dialog().multiselect( self.addon.getLocalizedString(32104), options, preselect=timer.days) @@ -42,6 +43,15 @@ def ask_days(self, label: str, path: str, is_epg: bool, timer: Timer) -> 'list[i else: return selection + def ask_date(self, label: str, path: str, is_epg: bool, timer: Timer) -> str: + + date = xbmcgui.Dialog().input( + type=xbmcgui.INPUT_DATE, heading=self.addon.getLocalizedString(32043), defaultt=datetime_utils.convert_for_xbmcdialog(timer.date)) + if date == "": + return None + else: + return datetime_utils.to_date_str(datetime_utils.parse_date_from_xbmcdialog(date)) + def ask_starttime(self, label: str, path: str, is_epg: bool, timer: Timer) -> str: start = xbmcgui.Dialog().numeric( @@ -49,6 +59,7 @@ def ask_starttime(self, label: str, path: str, is_epg: bool, timer: Timer) -> st if start == "": return None else: + timer.start_offset = 0 return ("0%s" % start.strip())[-5:] def ask_duration(self, label: str, path: str, is_epg: bool, timer: Timer) -> str: @@ -58,11 +69,12 @@ def ask_duration(self, label: str, path: str, is_epg: bool, timer: Timer) -> str if duration == "": return None else: + timer.end_offset = 0 return ("0%s" % duration.strip())[-5:] def ask_repeat_resume(self, timer: Timer) -> 'tuple[bool, bool]': - return timer.repeat, timer.resume and timer.duration != DEFAULT_TIME + return timer.repeat, timer.resume and timer.duration != datetime_utils.DEFAULT_TIME def ask_fader(self, timer: Timer) -> 'tuple[int, int, int]': @@ -70,7 +82,7 @@ def ask_fader(self, timer: Timer) -> 'tuple[int, int, int]': def ask_action(self, label: str, path: str, is_epg: bool, timer: Timer) -> 'tuple[int, int]': - return timer.system_action, MEDIA_ACTION_START_STOP if timer.duration != DEFAULT_TIME else MEDIA_ACTION_START + return timer.system_action, MEDIA_ACTION_START_STOP if timer.duration != datetime_utils.DEFAULT_TIME else MEDIA_ACTION_START def handle_overlapping_timers(self, timer: Timer, overlapping_timers: 'list[Timer]') -> int: diff --git a/script.timers/resources/lib/player/player.py b/script.timers/resources/lib/player/player.py index 218206ac71..cbf5970c25 100644 --- a/script.timers/resources/lib/player/player.py +++ b/script.timers/resources/lib/player/player.py @@ -35,7 +35,7 @@ def __init__(self) -> None: self._resume_status: 'dict[PlayerStatus]' = dict() self._running_stop_at_end_timer: 'tuple[Timer, bool]' = (None, False) - + self.__is_unit_test__: bool = False def playTimer(self, timer: Timer, dtd: datetime_utils.DateTimeDelta) -> None: @@ -63,8 +63,8 @@ def _get_delay_for_seektime(_timer: Timer, _dtd: datetime_utils.DateTimeDelta) - seektime = None if self._seek_delayed_timer and _timer.is_play_at_start_timer(): if timer.current_period: - seektime = datetime_utils.abs_time_diff( - _dtd.td, timer.current_period.start) + seektime = datetime_utils.datetime_diff( + timer.current_period.start, _dtd.dt) seektime = None if seektime * 1000 <= self._RESPITE else seektime return seektime @@ -91,8 +91,8 @@ def _get_delay_for_seektime(_timer: Timer, _dtd: datetime_utils.DateTimeDelta) - len(files)] if seektime else None if timer.is_stop_at_end_timer(): - amountOfSlides = datetime_utils.abs_time_diff( - timer.current_period.end, dtd.td) // stayTime + 1 + amountOfSlides = datetime_utils.datetime_diff( + dtd.dt, timer.current_period.end) // stayTime + 1 else: amountOfSlides = 0 @@ -128,7 +128,7 @@ def _playAV(self, playlist: PlayList, startpos=0, seektime=None, repeat=player_u if self.__is_unit_test__: self.setRepeat(repeat) - + self.play(playlist.directUrl or playlist, startpos=startpos) self.setRepeat(repeat) self.setShuffled(shuffled) diff --git a/script.timers/resources/lib/timer/concurrency.py b/script.timers/resources/lib/timer/concurrency.py index 3fe2c6032d..251348f863 100644 --- a/script.timers/resources/lib/timer/concurrency.py +++ b/script.timers/resources/lib/timer/concurrency.py @@ -1,3 +1,5 @@ +from datetime import datetime + import xbmcaddon import xbmcgui from resources.lib.player.player_utils import get_types_replaced_by_type @@ -33,38 +35,45 @@ def get_next_higher_prio(timers: 'list[Timer]') -> int: return _max + 1 if _max < HIGH_PRIO_MARK - 1 else _max -def determine_overlappings(timer: Timer, timers: 'list[Timer]', ignore_extra_prio=False) -> 'list[Timer]': +def determine_overlappings(timer: Timer, timers: 'list[Timer]', base: datetime, ignore_extra_prio=False, to_display=False) -> 'list[Timer]': + + def _is_exact_match(period1: Period, period2: Period, base: datetime) -> bool: + + if type(period1.start) == type(period2.start): + return period1.start == period2.start and period1.end == period2.end - def _disturbs(types: 'list[str]', type2: str, media_action1: int, media_action2: int, period1: Period, period2: Period) -> bool: + return _is_exact_match(Period.to_datetime_period(period1, base), Period.to_datetime_period(period2, base), base) + + def _disturbs(types: 'list[str]', type2: str, media_action1: int, media_action2: int, period1: Period, period2: Period, base: datetime) -> bool: if media_action1 == MEDIA_ACTION_START_STOP: - td_play_media1 = period1.start - td_stop_media1 = period1.end + play_media1 = period1.start + stop_media1 = period1.end replace = type2 in types elif media_action1 == MEDIA_ACTION_START: - td_play_media1 = period1.start - td_stop_media1 = None + play_media1 = period1.start + stop_media1 = None replace = type2 in types elif media_action1 == MEDIA_ACTION_START_AT_END: - td_play_media1 = period1.end - td_stop_media1 = None + play_media1 = period1.end + stop_media1 = None replace = type2 in types elif media_action1 == MEDIA_ACTION_STOP_START: - td_play_media1 = period1.end - td_stop_media1 = period1.start + play_media1 = period1.end + stop_media1 = period1.start replace = type2 in types elif media_action1 == MEDIA_ACTION_STOP: - td_play_media1 = None - td_stop_media1 = period1.start + play_media1 = None + stop_media1 = period1.start replace = True elif media_action1 == MEDIA_ACTION_STOP_AT_END: - td_play_media1 = None - td_stop_media1 = period1.end + play_media1 = None + stop_media1 = period1.end replace = True else: @@ -73,18 +82,18 @@ def _disturbs(types: 'list[str]', type2: str, media_action1: int, media_action2: if not replace: return False - if period1.start == period2.start and period1.end == period2.end: + if _is_exact_match(period1, period2, base): return True elif media_action2 == MEDIA_ACTION_START_STOP: - if td_play_media1: - s, e, hit = period2.hit(td_play_media1) + if play_media1: + s, e, hit = period2.hit(play_media1, base) if s and e and hit: return True - if td_stop_media1: - s, e, hit = period2.hit(td_stop_media1) + if stop_media1: + s, e, hit = period2.hit(stop_media1, base) if s and e and hit: return True @@ -92,8 +101,8 @@ def _disturbs(types: 'list[str]', type2: str, media_action1: int, media_action2: elif media_action2 == MEDIA_ACTION_STOP_START: - if td_play_media1: - s, e, hit = period2.hit(td_play_media1) + if play_media1: + s, e, hit = period2.hit(play_media1, base) return s and e and hit return False @@ -113,18 +122,27 @@ def _disturbs(types: 'list[str]', type2: str, media_action1: int, media_action2: for n in timer.periods: - if _disturbs(timer_replace_types, t.media_type, timer.media_action, t.media_action, n, p) or _disturbs(t_replace_types, timer.media_type, t.media_action, timer.media_action, p, n): + if _disturbs(timer_replace_types, t.media_type, timer.media_action, t.media_action, n, p, base) or _disturbs(t_replace_types, timer.media_type, t.media_action, timer.media_action, p, n, base): overlapping_periods.append(p) if overlapping_periods: - days = [ - datetime_utils.WEEKLY] if datetime_utils.WEEKLY in t.days else list() - days.extend([p.start.days for p in overlapping_periods]) - t.days = days - t.periods = overlapping_periods + overlapping_timers.append(t) - overlapping_timers.sort(key=lambda t: (t.days, t.start, + if to_display: + + if t.is_timer_by_date(): + days = [p.start.weekday()] + + else: + days = [ + datetime_utils.WEEKLY] if datetime_utils.WEEKLY in t.days else list() + days.extend([p.start.days for p in overlapping_periods]) + + t.days = days + t.periods = overlapping_periods + + overlapping_timers.sort(key=lambda t: (t.days, t.date, t.start, t.media_action, t.system_action)) return overlapping_timers @@ -133,9 +151,10 @@ def _disturbs(types: 'list[str]', type2: str, media_action1: int, media_action2: def ask_overlapping_timers(timer: Timer, overlapping_timers: 'list[Timer]') -> int: addon = xbmcaddon.Addon() + now = datetime.today() earlier_timers = [ - t for t in overlapping_timers if t.periods[0].start < timer.periods[0].start] + t for t in overlapping_timers if datetime_utils.time_diff(t.periods[0].start, timer.periods[0].start, now) > 0] lines = list() for t in overlapping_timers: diff --git a/script.timers/resources/lib/timer/pause_timers.py b/script.timers/resources/lib/timer/pause_timers.py index c09af7fffb..d8a248b627 100644 --- a/script.timers/resources/lib/timer/pause_timers.py +++ b/script.timers/resources/lib/timer/pause_timers.py @@ -26,7 +26,7 @@ def reset_pause() -> None: _set(from_=None, until=None) -def _set(from_: datetime, until: datetime) -> None: +def _set(from_: datetime, until: 'datetime | None') -> None: if not until: date_from = "2001-01-01" diff --git a/script.timers/resources/lib/timer/period.py b/script.timers/resources/lib/timer/period.py index d333f2849a..1ee0fc21d6 100644 --- a/script.timers/resources/lib/timer/period.py +++ b/script.timers/resources/lib/timer/period.py @@ -1,14 +1,20 @@ -from datetime import timedelta +from datetime import datetime, timedelta + +from resources.lib.utils import datetime_utils class Period: - def __init__(self, start: timedelta, end: timedelta) -> None: + def __init__(self, start: 'timedelta | datetime', end: 'timedelta | datetime') -> None: + + if type(start) != type(end): + raise Exception( + "types of and must be identically!!!") - self.start: timedelta = start - self.end: timedelta = end + self.start: 'timedelta | datetime' = start + self.end: 'timedelta | datetime' = end - def _compare(self, period_start: timedelta, period_end: timedelta) -> 'tuple[timedelta,timedelta,timedelta]': + def _compareByWeekdays(self, period_start: timedelta, period_end: timedelta) -> 'tuple[timedelta,timedelta,timedelta]': self_start = self.start self_end = self.end @@ -31,14 +37,66 @@ def _compare(self, period_start: timedelta, period_end: timedelta) -> 'tuple[tim return self_start - period_start, self_end - period_end, min_end - max_start if max_start <= min_end else None + def _compareByDates(self, period_start: datetime, period_end: datetime) -> 'tuple[timedelta,timedelta,timedelta]': + + max_start = max(self.start, period_start) + min_end = min(self.end, period_end) + + return self.start - period_start, self.end - period_end, min_end - max_start if max_start <= min_end else None + def compare(self, period: 'Period') -> 'tuple[timedelta,timedelta,timedelta]': - return self._compare(period.start, period.end) + if type(self.start) != type(period.start): + raise Exception( + f"can't compare {str(self)} with {str(period)} caused by different types") + + if type(self.start) == timedelta: + return self._compareByWeekdays(period.start, period.end) + else: + return self._compareByDates(period.start, period.end) + + def hit(self, timestamp: 'timedelta | datetime', base: datetime = None) -> 'tuple[timedelta,timedelta,bool]': - def hit(self, timestamp: timedelta) -> 'tuple[timedelta,timedelta, bool]': + if type(self.start) == timedelta and type(timestamp) == timedelta: + s, e, l = self._compareByWeekdays(timestamp, timestamp) + return s, e, l is not None + elif type(self.start) == datetime and type(timestamp) == datetime: + s, e, l = self._compareByDates(timestamp, timestamp) + return s, e, l is not None - s, e, l = self._compare(timestamp, timestamp) - return s, e, l is not None + if type(timestamp) == datetime: + period = Period.to_datetime_period( + period=self, base=base or timestamp) + s, e, l = period._compareByDates(timestamp, timestamp) + return s, e, l is not None + + elif type(self.start) == datetime: + if not base: + raise ("This type of comparision requires a base-datetime") + + timestamp = datetime_utils.apply_for_datetime( + base, timestamp, force_future=True) + s, e, l = self._compareByDates(timestamp, timestamp) + return s, e, l is not None def __str__(self) -> str: - return "Period[start=%s, end=%s]" % (self.start, self.end) + + start = self.start if type( + self.start) == timedelta else self.start.strftime("%Y-%m-%d %H:%M:%S") + end = self.end if type(self.end) == timedelta else self.end.strftime( + "%Y-%m-%d %H:%M:%S") + return f"Period[start={start}, end={end}]" + + @staticmethod + def to_datetime_period(period: 'Period', base: datetime) -> 'Period': + + if type(period.start) == datetime: + return period + + start = datetime_utils.apply_for_datetime(base, period.start) + end = datetime_utils.apply_for_datetime(base, period.end) + if start < end < base: + start += timedelta(days=7) + end += timedelta(days=7) + + return Period(start, end) diff --git a/script.timers/resources/lib/timer/scheduler.py b/script.timers/resources/lib/timer/scheduler.py index 66e0e863ae..b92052112a 100644 --- a/script.timers/resources/lib/timer/scheduler.py +++ b/script.timers/resources/lib/timer/scheduler.py @@ -101,14 +101,14 @@ def _has_changed(former_timer: Timer, timer_from_storage: Timer) -> 'tuple[bool, changed |= (former_timer.vol_max != timer_from_storage.vol_max) elif timer_from_storage.is_fading_timer(): changed = True - restart = True + restart = True return changed, restart def _reset_overlappings(timer: Timer) -> None: overlappings = determine_overlappings( - timer, scheduled_timers) + timer, scheduled_timers, base=datetime.today()) for overlap in overlappings: overlap.state = STATE_WAITING @@ -168,7 +168,8 @@ def _update_from_storage(scheduled_timers: 'list[Timer]') -> 'list[Timer]': self._windows_unlock = addon.getSettingBool("windows_unlock") self._powermanagement_displaysoff = addon.getSettingInt( "powermanagement_displaysoff") - self._disable_displayoff_on_audio = addon.getSettingBool("audio_displaysoff") + self._disable_displayoff_on_audio = addon.getSettingBool( + "audio_displaysoff") self.reset_powermanagement_displaysoff() def start(self) -> None: @@ -218,15 +219,15 @@ def _prevent_powermanagement_displaysoff(self) -> None: fullscreen = is_fullscreen() audio = self._player.isPlayingAudio() - if self._disabled_powermanagement_displaysoff and ((fullscreen and not audio) \ - or (not self._powermanagement_displaysoff and (not self._disable_displayoff_on_audio or not audio)) \ - or (not self._powermanagement_displaysoff and not fullscreen and self._disable_displayoff_on_audio and not audio) \ - or (not self._disable_displayoff_on_audio and fullscreen)): + if self._disabled_powermanagement_displaysoff and ((fullscreen and not audio) + or (not self._powermanagement_displaysoff and (not self._disable_displayoff_on_audio or not audio)) + or (not self._powermanagement_displaysoff and not fullscreen and self._disable_displayoff_on_audio and not audio) + or (not self._disable_displayoff_on_audio and fullscreen)): self.reset_powermanagement_displaysoff() - + elif not self._disabled_powermanagement_displaysoff and \ - ((self._powermanagement_displaysoff and not fullscreen) \ - or (self._disable_displayoff_on_audio and audio)): + ((self._powermanagement_displaysoff and not fullscreen) + or (self._disable_displayoff_on_audio and audio)): self._disabled_powermanagement_displaysoff = True set_powermanagement_displaysoff(0) diff --git a/script.timers/resources/lib/timer/scheduleraction.py b/script.timers/resources/lib/timer/scheduleraction.py index 77abb11086..3f91a01eb5 100644 --- a/script.timers/resources/lib/timer/scheduleraction.py +++ b/script.timers/resources/lib/timer/scheduleraction.py @@ -16,12 +16,11 @@ SYSTEM_ACTION_HIBERNATE, SYSTEM_ACTION_POWEROFF, SYSTEM_ACTION_QUIT_KODI, - SYSTEM_ACTION_RESTART_KODI, SYSTEM_ACTION_REBOOT_SYSTEM, + SYSTEM_ACTION_RESTART_KODI, SYSTEM_ACTION_SHUTDOWN_KODI, - SYSTEM_ACTION_STANDBY, TIMER_WEEKLY, - Timer) -from resources.lib.utils.datetime_utils import DateTimeDelta, abs_time_diff + SYSTEM_ACTION_STANDBY, Timer) +from resources.lib.utils import datetime_utils class SchedulerAction: @@ -54,7 +53,7 @@ def __init__(self, player: Player, storage: Storage) -> None: self.reset() - def calculate(self, timers: 'list[Timer]', now: DateTimeDelta) -> None: + def calculate(self, timers: 'list[Timer]', now: datetime_utils.DateTimeDelta) -> None: def _collectEndingTimer(timer: Timer) -> None: @@ -76,7 +75,7 @@ def _collectEndingTimer(timer: Timer) -> None: elif timer.is_pause_timer(): self.timerToUnpauseAV = timer - def _collectTimers(timers: 'list[Timer]', now: DateTimeDelta) -> None: + def _collectTimers(timers: 'list[Timer]', now: datetime_utils.DateTimeDelta) -> None: for timer in timers: timer.apply(now) @@ -123,16 +122,18 @@ def _reset_stop(): self._forceResumeResetTypes.extend( _types_replaced_by_type if not timerToStop.is_resuming_timer() else list()) - if overlappingTimer.priority < timerToStop.priority and timerToStop.is_playing_media_timer() and overlappingTimer.media_type in _types_replaced_by_type: + if overlappingTimer.priority < timerToStop.priority and timerToStop.is_resuming_timer() and timerToStop.is_playing_media_timer() and overlappingTimer.media_type in _types_replaced_by_type: self._beginningTimers.append(overlappingTimer) - _reset_stop() + if overlappingTimer.priority >= timerToStop.priority: + _reset_stop() enclosingTimers = [t for t in self._runningTimers if (t.current_period.start < timerToStop.current_period.start and t.current_period.end > timerToStop.current_period.end) - and t.is_play_at_start_timer() and t.media_type in _types_replaced_by_type] + and t.is_play_at_start_timer() + and t.media_type in _types_replaced_by_type] - if enclosingTimers and not [t for t in enclosingTimers if timerToStop.priority >= t.priority]: + if enclosingTimers and [t for t in enclosingTimers if timerToStop.priority < t.priority]: _reset_stop() elif timerToStop.is_resuming_timer(): @@ -243,7 +244,7 @@ def getFaderInterval(self) -> float: if not self.fader: return None - delta_end_start = abs_time_diff( + delta_end_start = datetime_utils.datetime_diff( self.fader.current_period.end, self.fader.current_period.start) vol_max = self.fader.return_vol if self.fader.fade == FADE_OUT_FROM_CURRENT else self.fader.vol_max @@ -262,14 +263,14 @@ def _setTimerToPlayAny(self, timer: Timer) -> None: else: self.timerToPlayAV = timer if self.timerToPlayAV is None or self.timerToPlayAV.priority < timer.priority else self.timerToPlayAV - def fade(self, dtd: DateTimeDelta) -> None: + def fade(self, dtd: datetime_utils.DateTimeDelta) -> None: if not self.fader: return - delta_now_start = abs_time_diff( - dtd.td, self.fader.current_period.start) - delta_end_start = abs_time_diff( + delta_now_start = datetime_utils.datetime_diff( + dtd.dt, self.fader.current_period.start) + delta_end_start = datetime_utils.time_diff( self.fader.current_period.end, self.fader.current_period.start) delta_percent = delta_now_start / delta_end_start @@ -284,9 +285,9 @@ def fade(self, dtd: DateTimeDelta) -> None: self._player.setVolume(_volume) - def perform(self, now: DateTimeDelta) -> None: + def perform(self, now: datetime_utils.DateTimeDelta) -> None: - def _performPlayerAction(_now: DateTimeDelta) -> None: + def _performPlayerAction(_now: datetime_utils.DateTimeDelta) -> None: if self.timerToPlayAV: showNotification(self.timerToPlayAV, msg_id=32280) @@ -319,7 +320,7 @@ def _performPlayerAction(_now: DateTimeDelta) -> None: showNotification(self.timerToStopSlideshow, msg_id=32287) self._player.resumeFormerOrStop(self.timerToStopSlideshow) - def _setVolume(dtd: DateTimeDelta) -> None: + def _setVolume(dtd: datetime_utils.DateTimeDelta) -> None: if self.timerWithSystemAction: self._player.setVolume(self._player.getDefaultVolume()) @@ -339,13 +340,15 @@ def _consumeSingleRunTimers() -> None: def _reset(timers: 'list[Timer]') -> None: for timer in timers: - if TIMER_WEEKLY not in timer.days: - if timer.current_period.start.days in timer.days: - timer.days.remove(timer.current_period.start.days) + if not timer.is_weekly_timer(): + if timer.current_period.start.weekday() in timer.days: + timer.days.remove( + timer.current_period.start.weekday()) - if not timer.days: + if timer.is_timer_by_date() or not timer.days: self.storage.delete_timer(timer.id) else: + timer.to_timer_by_date(base=now.dt) self.storage.save_timer(timer=timer) _reset(self._endingTimers) diff --git a/script.timers/resources/lib/timer/storage.py b/script.timers/resources/lib/timer/storage.py index d09aa443df..c23d71ed76 100644 --- a/script.timers/resources/lib/timer/storage.py +++ b/script.timers/resources/lib/timer/storage.py @@ -123,6 +123,7 @@ def _init_timer_from_item(self, item: dict) -> Timer: item["days"].sort() timer.days = item["days"] + timer.date = item["date"] timer.start = item["start"] timer.start_offset = item["start_offset"] timer.end_type = item["end_type"] @@ -150,41 +151,21 @@ def _find_item_index(self, storage: 'list[dict]', id: int) -> int: return -1 - def save_timer(self, timer: Timer) -> None: + def replace_storage(self, timers: 'list[Timer]') -> None: - timer.init() + storage = [timer.to_dict() for timer in timers] + self._save_to_storage(storage) - item = { - "days": timer.days, - "duration": timer.duration, - "duration_offset": timer.duration_offset, - "end": timer.end, - "end_offset": timer.end_offset, - "end_type": timer.end_type, - "fade": timer.fade, - "id": timer.id, - "label": timer.label, - "media_action": timer.media_action, - "media_type": timer.media_type, - "notify": timer.notify, - "path": timer.path, - "priority": timer.priority, - "repeat": timer.repeat, - "resume": timer.resume, - "shuffle": timer.shuffle, - "start": timer.start, - "start_offset": timer.start_offset, - "system_action": timer.system_action, - "vol_min": timer.vol_min, - "vol_max": timer.vol_max - } + def save_timer(self, timer: Timer) -> None: storage = self._load_from_storage() + + timer.init() idx = self._find_item_index(storage, timer.id) if idx == -1: - storage.append(item) + storage.append(timer.to_dict()) else: - storage[idx] = item + storage[idx] = timer.to_dict() self._save_to_storage(storage) @@ -200,7 +181,7 @@ def get_scheduled_timers(self) -> 'list[Timer]': timers = self.load_timers_from_storage() scheduled_timers = [timer for timer in timers if timer.days] - scheduled_timers.sort(key=lambda timer: (timer.days, timer.start, + scheduled_timers.sort(key=lambda timer: (timer.days, timer.date, timer.start, timer.media_action, timer.system_action)) return scheduled_timers diff --git a/script.timers/resources/lib/timer/timer.py b/script.timers/resources/lib/timer/timer.py index 819f82589b..7eaa1ee438 100644 --- a/script.timers/resources/lib/timer/timer.py +++ b/script.timers/resources/lib/timer/timer.py @@ -2,15 +2,11 @@ import xbmcaddon from resources.lib.timer.period import Period -from resources.lib.utils.datetime_utils import (DEFAULT_TIME, DateTimeDelta, - apply_for_now, - format_from_seconds, - parse_time, - periods_to_human_readable, - time_duration_str) +from resources.lib.utils import datetime_utils from resources.lib.utils.vfs_utils import is_script TIMER_WEEKLY = 7 +TIMER_BY_DATE = 8 END_TYPE_NO = 0 END_TYPE_DURATION = 1 @@ -56,12 +52,13 @@ def __init__(self, i: int) -> None: self.id: int = i self.label: str = "" self.days: 'list[int]' = list() - self.start: str = DEFAULT_TIME + self.date: str = "" + self.start: str = datetime_utils.DEFAULT_TIME self.start_offset: int = 0 self.end_type: int = END_TYPE_NO - self.duration: str = DEFAULT_TIME + self.duration: str = datetime_utils.DEFAULT_TIME self.duration_offset: int = 0 - self.end: str = DEFAULT_TIME + self.end: str = datetime_utils.DEFAULT_TIME self.end_offset: int = 0 self.system_action: int = SYSTEM_ACTION_NONE self.media_action: int = MEDIA_ACTION_START @@ -86,103 +83,162 @@ def __init__(self, i: int) -> None: def init(self) -> None: - def _build_end_time(td_start: timedelta, end_type: int, duration_timedelta: timedelta, end: str, end_offset=0, duration_offset=0) -> 'tuple[timedelta, timedelta]': + def _build_end_time(start: 'timedelta | datetime', end_type: int, duration_timedelta: timedelta, end: str, end_offset=0, duration_offset=0) -> 'tuple[timedelta | datetime, timedelta | datetime]': if end_type == END_TYPE_DURATION: - td_end = td_start + duration_timedelta + \ + end_time = start + duration_timedelta + \ timedelta(seconds=duration_offset) elif end_type == END_TYPE_TIME: - td_end = parse_time(end, td_start.days) + \ - timedelta(seconds=end_offset) - if td_end < td_start: - td_end += timedelta(days=1) + end_time = datetime_utils.parse_time( + end) + timedelta(seconds=end_offset) + if type(start) == datetime: + end_time = datetime(year=start.year, month=start.month, day=start.day, + hour=int( + end_time.total_seconds() // 3600), + minute=int( + end_time.total_seconds() % 3600) // 60, + second=int(end_time.total_seconds() % 60)) + else: + end_time = end_time + timedelta(days=start.days) + + if end_time < start: + end_time += timedelta(days=1) else: # END_TYPE_NO - td_end = td_start + timedelta(seconds=1) + end_time = start + timedelta(seconds=1) - return td_end, td_end - td_start + return end_time, end_time - start - td_start = parse_time(self.start) + \ + td_start = datetime_utils.parse_time(self.start) + \ timedelta(seconds=self.start_offset) - self.start = format_from_seconds(td_start.seconds) + self.start = datetime_utils.format_from_seconds(td_start.seconds) self.start_offset = td_start.seconds % 60 td_end, td_duration = _build_end_time( - td_start=td_start, end_type=self.end_type, duration_timedelta=parse_time( + start=td_start, end_type=self.end_type, duration_timedelta=datetime_utils.parse_time( self.duration), end=self.end, end_offset=self.end_offset, duration_offset=self.duration_offset) - self.end = format_from_seconds(td_end.seconds) + self.end = datetime_utils.format_from_seconds(td_end.seconds) self.end_offset = td_end.seconds % 60 - self.duration = format_from_seconds(td_duration.seconds) + self.duration = datetime_utils.format_from_seconds(td_duration.seconds) self.duration_offset = td_duration.seconds % 60 self.duration_timedelta = td_duration - periods = list() - for i_day in self.days: - if i_day == TIMER_WEEKLY: - continue + if self.is_weekly_timer(): + self.date = "" - td_start = parse_time(self.start, i_day) + \ - timedelta(seconds=self.start_offset) - td_end, self.duration_timedelta = _build_end_time(td_start, - self.end_type, - self.duration_timedelta, - self.end) + if self.is_timer_by_date(): - periods.append(Period(td_start, td_end)) + self.days = [TIMER_BY_DATE] + dt_start = datetime_utils.parse_datetime_str( + f"{self.date} {self.start}") + timedelta(seconds=self.start_offset) + dt_end, self.duration_timedelta = _build_end_time(start=dt_start, + end_type=self.end_type, + duration_timedelta=self.duration_timedelta, + end=self.end, + end_offset=self.end_offset, + duration_offset=self.duration_offset) - self.periods = periods + self.periods = [Period(dt_start, dt_end)] - def apply(self, dtd: DateTimeDelta) -> None: + else: + periods = list() + for i_day in self.days: + if i_day == TIMER_WEEKLY: + continue + + td_start = datetime_utils.parse_time(self.start, i_day) + \ + timedelta(seconds=self.start_offset) + td_end, self.duration_timedelta = _build_end_time(start=td_start, + end_type=self.end_type, + duration_timedelta=self.duration_timedelta, + end=self.end, + end_offset=self.end_offset, + duration_offset=self.duration_offset) + + periods.append(Period(td_start, td_end)) + + self.periods = periods + + def _apply_weekday_periods(self, dtd: datetime_utils.DateTimeDelta) -> 'tuple[Period, datetime]': - upcoming_event: timedelta = None + td_upcoming_event: timedelta = None current_period: Period = None for period in self.periods: if period.start > dtd.td: - upcoming_event = period.start if upcoming_event is None or upcoming_event > period.start else upcoming_event + td_upcoming_event = period.start if td_upcoming_event is None or td_upcoming_event > period.start else td_upcoming_event elif dtd.td < period.end: current_period = period - upcoming_event = period.end + td_upcoming_event = period.end break - if current_period is not None and self.state is not STATE_RUNNING: + if not td_upcoming_event and self.periods: + td_upcoming_event = self.periods[0].start + timedelta(days=7) + + upcoming_event = datetime_utils.apply_for_datetime( + dtd.dt, td_upcoming_event) if dtd.dt else None + + return Period.to_datetime_period(current_period, base=dtd.dt) if current_period else None, upcoming_event + + def _apply_date_period(self, dtd: datetime_utils.DateTimeDelta) -> 'tuple[Period, datetime]': + + if not dtd.dt or not self.periods: + return None, None + + date_period: Period = self.periods[0] + current_period: Period = None + upcoming_event: datetime = None + + if date_period.start > dtd.dt: + upcoming_event = date_period.start + + elif dtd.dt < date_period.end: + current_period = date_period + upcoming_event = date_period.end + + return current_period, upcoming_event + + def apply(self, dtd: datetime_utils.DateTimeDelta) -> None: + + if self.is_timer_by_date(): + self.current_period, self.upcoming_event = self._apply_date_period( + dtd) + + else: + self.current_period, self.upcoming_event = self._apply_weekday_periods( + dtd) + + if self.current_period is not None and self.state is not STATE_RUNNING: self.state = STATE_STARTING - elif current_period is None and self.state is not STATE_WAITING: - current_period = Period(dtd.td - self.duration_timedelta, dtd.td) + elif self.current_period is None and self.state is not STATE_WAITING: + self.current_period = Period( + dtd.dt - self.duration_timedelta, dtd.dt) self.state = STATE_ENDING - elif current_period is None: + elif self.current_period is None: self.state = STATE_WAITING else: self.state = STATE_RUNNING - self.current_period = current_period - - if not upcoming_event and self.periods: - upcoming_event = self.periods[0].start + timedelta(days=7) - - self.upcoming_event = apply_for_now( - dtd.dt, upcoming_event) if dtd.dt else None - def get_duration(self) -> str: if self.end_type == END_TYPE_DURATION: return self.duration elif self.end_type == END_TYPE_TIME: - return time_duration_str(self.start, self.end) + return datetime_utils.time_duration_str(self.start, self.end) else: - return DEFAULT_TIME + return datetime_utils.DEFAULT_TIME def _timeStr(self, timeStr: str, offset: int) -> str: @@ -305,7 +361,45 @@ def periods_to_human_readable(self) -> str: self.init() _start = self._timeStr(self.start, self.start_offset) _end = self._timeStr(self.end, self.end_offset) - return periods_to_human_readable(self.days, start=_start, end=_end if self.end_type != END_TYPE_NO else None) + return datetime_utils.periods_to_human_readable(self.days, start=_start, end=_end if self.end_type != END_TYPE_NO else None, date=self.date) + + def set_timer_by_date(self, date: str) -> None: + + self.days = [TIMER_BY_DATE] + self.date = date + + def to_timer_by_date(self, base: 'datetime | None') -> bool: + + if not base: + return False + + elif self.is_weekly_timer(): + self.date = "" + return False + + elif self.is_timer_by_date(): + return True + + _, upcoming_event = self._apply_weekday_periods( + datetime_utils.DateTimeDelta(base)) + self.date = datetime_utils.to_date_str(upcoming_event) + if len(self.days) == 1: + self.set_timer_by_date(self.date) + return True + + return False + + def is_timer_by_date(self) -> bool: + + return TIMER_BY_DATE in self.days + + def is_weekly_timer(self) -> bool: + + return TIMER_WEEKLY in self.days + + def is_off(self) -> bool: + + return not self.days def is_fading_timer(self) -> bool: @@ -347,24 +441,53 @@ def is_system_execution_timer(self) -> bool: return self.system_action != SYSTEM_ACTION_NONE + def to_dict(self) -> 'dict': + + return { + "days": self.days, + "date": self.date, + "duration": self.duration, + "duration_offset": self.duration_offset, + "end": self.end, + "end_offset": self.end_offset, + "end_type": self.end_type, + "fade": self.fade, + "id": self.id, + "label": self.label, + "media_action": self.media_action, + "media_type": self.media_type, + "notify": self.notify, + "path": self.path, + "priority": self.priority, + "repeat": self.repeat, + "resume": self.resume, + "shuffle": self.shuffle, + "start": self.start, + "start_offset": self.start_offset, + "system_action": self.system_action, + "vol_min": self.vol_min, + "vol_max": self.vol_max + } + def __str__(self) -> str: - return "Timer[id=%i, label=%s, state=%s, prio=%i, days=%s, start=%s:%02i, endtype=%s, duration=%s:%02i, end=%s:%02i, systemaction=%s, mediaaction=%s, path=%s, type=%s, repeat=%s, shuffle=%s, resume=%s, fade=%s, min=%i, max=%i, returnvol=%i, notify=%s]" % (self.id, self.label, ["waiting", "starting", "running", "ending"][self.state], self.priority, - [["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun", "weekly"][d] for d in self.days], - self.start, - self.start_offset, - self._endTypeStr(), - self.duration, self.duration_offset, - self.end, self.end_offset, - self._systemActionStr(), - self._mediaActionStr(), - self.path, - self.media_type, - self.repeat, - self.shuffle, - self.resume, - self._fadeStr(), - self.vol_min, - self.vol_max, - self.return_vol or self.vol_max, - self.notify) + return "Timer[id=%i, label=%s, state=%s, prio=%i, days=%s, date=%s, start=%s:%02i, endtype=%s, duration=%s:%02i, end=%s:%02i, systemaction=%s, mediaaction=%s, path=%s, type=%s, repeat=%s, shuffle=%s, resume=%s, fade=%s, min=%i, max=%i, returnvol=%i, notify=%s]" % (self.id, self.label, ["waiting", "starting", "running", "ending"][self.state], self.priority, + [["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun", "weekly", "date"][d] for d in self.days], + self.date, + self.start, + self.start_offset, + self._endTypeStr(), + self.duration, self.duration_offset, + self.end, self.end_offset, + self._systemActionStr(), + self._mediaActionStr(), + self.path, + self.media_type, + self.repeat, + self.shuffle, + self.resume, + self._fadeStr(), + self.vol_min, + self.vol_max, + self.return_vol or self.vol_max, + self.notify) diff --git a/script.timers/resources/lib/utils/datetime_utils.py b/script.timers/resources/lib/utils/datetime_utils.py index d2820ae59a..d417ac9360 100644 --- a/script.timers/resources/lib/utils/datetime_utils.py +++ b/script.timers/resources/lib/utils/datetime_utils.py @@ -1,5 +1,6 @@ -import datetime +import locale import time +from datetime import datetime, timedelta import xbmc import xbmcaddon @@ -7,33 +8,48 @@ DEFAULT_TIME = "00:00" WEEKLY = 7 +TIMER_BY_DATE = 8 class DateTimeDelta(): - def __init__(self, dt: datetime.datetime) -> None: + def __init__(self, dt: datetime) -> None: self.dt = dt - self.td = datetime.timedelta(hours=dt.hour, minutes=dt.minute, - seconds=dt.second, days=dt.weekday()) + self.td = timedelta(hours=dt.hour, minutes=dt.minute, + seconds=dt.second, days=dt.weekday()) @staticmethod def now(offset=0) -> 'DateTimeDelta': - dt_now = datetime.datetime.today() + dt_now = datetime.today() if offset: if offset > 0: - dt_now += datetime.timedelta(seconds=offset) + dt_now += timedelta(seconds=offset) else: - dt_now -= datetime.timedelta(seconds=abs(offset)) + dt_now -= timedelta(seconds=abs(offset)) return DateTimeDelta(dt_now) def _parse_datetime_from_str(s: str, format: str) -> datetime: - return datetime.datetime.fromtimestamp(time.mktime(time.strptime(s, format))) + try: + return datetime.strptime(s, format) + except: + # Workaround for some Kodi versions + return datetime.fromtimestamp(time.mktime(time.strptime(s, format))) + + +def parse_date_str(s: str) -> datetime: + + return _parse_datetime_from_str(s, "%Y-%m-%d") + + +def to_date_str(dt: datetime) -> datetime: + + return dt.strftime("%Y-%m-%d") def parse_datetime_str(s: str) -> datetime: @@ -41,15 +57,32 @@ def parse_datetime_str(s: str) -> datetime: return _parse_datetime_from_str(s, "%Y-%m-%d %H:%M") -def parse_xbmc_shortdate(s: str) -> datetime.datetime: +def parse_xbmc_shortdate(s: str) -> datetime: + + return _parse_datetime_from_str(s, format=xbmc.getRegion("dateshort").replace("%-", "%")) + + +def parse_date_from_xbmcdialog(s: str) -> datetime: + + return _parse_datetime_from_str(s.replace(" ", ""), format="%d/%m/%Y") + - return _parse_datetime_from_str(s, format=xbmc.getRegion("dateshort")) +def convert_for_xbmcdialog(s: str) -> str: + _dt = parse_date_str(s) + return f"{_dt.day:2}/{_dt.month:2}/{_dt.year}" -def periods_to_human_readable(days: 'list[int]', start: str, end="") -> str: + +def periods_to_human_readable(days: 'list[int]', start: str, end="", date="") -> str: addon = xbmcaddon.Addon() + try: + locale.setlocale( + locale.LC_ALL, xbmc.getLanguage(format=xbmc.ISO_639_1)) + except: + pass + def _day_str(d: int, plural=False) -> str: return addon.getLocalizedString(d + (32210 if plural else 32200)) @@ -99,6 +132,10 @@ def _period_str(period: 'list[int]', plural=False) -> str: if days == [i for i in range(8)]: human = addon.getLocalizedString(32035) + elif days == [TIMER_BY_DATE] and date: + date = parse_date_str(date) + human = date.strftime(xbmc.getRegion("datelong")) + else: periods = _sumarize(days=days) periods.reverse() @@ -118,7 +155,7 @@ def _period_str(period: 'list[int]', plural=False) -> str: return human -def parse_time(s_time: str, i_day=0) -> datetime.timedelta: +def parse_time(s_time: str, i_day=0) -> timedelta: if s_time == "": s_time = DEFAULT_TIME @@ -129,29 +166,40 @@ def parse_time(s_time: str, i_day=0) -> datetime.timedelta: else: t_time = time.strptime(s_time, "%H:%M") - return datetime.timedelta( + return timedelta( days=i_day, hours=t_time.tm_hour, minutes=t_time.tm_min) -def abs_time_diff(td1: datetime.timedelta, td2: datetime.timedelta) -> int: +def datetime_diff(t1: datetime, t2: datetime) -> int: + + return int((t2 - t1).total_seconds()) + - return abs(time_diff(td1, td2)) +def time_diff(t1: 'timedelta | datetime', t2: 'timedelta | datetime', base: datetime = None) -> int: + def _datetimedelta_diff(dt1: datetime, td2: timedelta, base: datetime) -> int: -def time_diff(td1: datetime.timedelta, td2: datetime.timedelta) -> int: + dt2 = apply_for_datetime(base or dt1, td2) + return int((dt2 - dt1).total_seconds()) - s1 = td1.days * 86400 + td1.seconds - s2 = td2.days * 86400 + td2.seconds + if type(t1) == type(t2): + return int((t2 - t1).total_seconds()) - return s2 - s1 + elif type(t1) == datetime and type(t2) == timedelta: + return _datetimedelta_diff(t1, t2, base) + + elif type(t1) == timedelta and type(t2) == datetime: + return _datetimedelta_diff(t2, t1, base) + + raise ("Invalid datatype recognized. Only datetime and timedelta are supported") def time_duration_str(start: str, end: str) -> str: _dt_start = parse_time(start) _dt_end = parse_time(end, i_day=1) - _secs = time_diff(_dt_start, _dt_end) % 86400 + _secs = int((_dt_end - _dt_start).total_seconds()) % 86400 return format_from_seconds(_secs) @@ -159,12 +207,20 @@ def format_from_seconds(secs: int) -> str: return "%02i:%02i" % (secs // 3600, (secs % 3600) // 60) -def apply_for_now(dt_now: datetime.datetime, timestamp: datetime.timedelta) -> datetime.datetime: +def format_from_timedelta(td: timedelta) -> 'tuple[str, int]': + seconds = int(td.total_seconds()) + return format_from_seconds(seconds), seconds % 60 - dt_last_monday_same_time = dt_now - \ - datetime.timedelta(days=dt_now.weekday()) - dt_last_monday_midnight = datetime.datetime(year=dt_last_monday_same_time.year, - month=dt_last_monday_same_time.month, - day=dt_last_monday_same_time.day) - return dt_last_monday_midnight + timestamp +def apply_for_datetime(dt_now: datetime, timestamp: timedelta, force_future=False) -> datetime: + + dt_last_monday_same_time = dt_now - \ + timedelta(days=dt_now.weekday()) + dt_last_monday_midnight = datetime(year=dt_last_monday_same_time.year, + month=dt_last_monday_same_time.month, + day=dt_last_monday_same_time.day) + + applied_for_now = dt_last_monday_midnight + timestamp + if force_future and applied_for_now < dt_now: + applied_for_now += timedelta(days=7) + return applied_for_now diff --git a/script.timers/resources/lib/utils/housekeeper.py b/script.timers/resources/lib/utils/housekeeper.py new file mode 100644 index 0000000000..8452377c46 --- /dev/null +++ b/script.timers/resources/lib/utils/housekeeper.py @@ -0,0 +1,77 @@ +from datetime import datetime + +import xbmc +import xbmcaddon +from resources.lib.timer.storage import Storage +from resources.lib.timer.timer import TIMER_BY_DATE, Timer +from resources.lib.utils import datetime_utils + +ACTION_NOTHING = 0 +ACTION_UPDATE = 1 +ACTION_DELETE = 2 + + +def check_timer(timer: Timer, threshold: datetime) -> int: + + if timer.is_weekly_timer() or timer.is_off(): + return ACTION_NOTHING + + timer.init() + timer.apply(dtd=datetime_utils.DateTimeDelta(threshold)) + if timer.date == "": + timer.to_timer_by_date(timer.upcoming_event) + return ACTION_UPDATE + + last_known_date = datetime_utils.parse_date_str(timer.date) + if timer.upcoming_event: + timer.date = datetime_utils.to_date_str(timer.upcoming_event) + + weekdays_to_remove = list() + has_upcoming = False + for p in timer.periods: + s, e, hit = p.hit(threshold, last_known_date) + start = threshold + s + end = threshold + e + if not hit and end < threshold: + weekdays_to_remove.append(start.weekday()) + elif hit and timer.is_timer_by_date(): + return ACTION_NOTHING + elif threshold <= start <= end: + has_upcoming = True + + timer.days = [day for day in timer.days if day not in weekdays_to_remove] + if not timer.days or timer.days == [TIMER_BY_DATE] and not has_upcoming: + return ACTION_DELETE + + elif weekdays_to_remove: + return ACTION_UPDATE + + return ACTION_NOTHING + + +def cleanup_outdated_timers() -> None: + + addon = xbmcaddon.Addon() + if not addon.getSettingBool("clean_outdated"): + return + + storage = Storage() + timers = storage.load_timers_from_storage() + + updated_any = False + timers_to_remove = list() + + now = datetime.today() + for timer in timers: + action = check_timer(timer, now) + if action == ACTION_UPDATE: + updated_any = True + elif action == ACTION_DELETE: + timers_to_remove.append(timer) + + for timer in timers_to_remove: + xbmc.log(f"remove outdated timer: {str(timer)}", xbmc.LOGINFO) + timers.remove(timer) + + if updated_any or timers_to_remove: + storage.replace_storage(timers) diff --git a/script.timers/resources/lib/utils/settings_utils.py b/script.timers/resources/lib/utils/settings_utils.py index 7d53c63991..c164401e99 100644 --- a/script.timers/resources/lib/utils/settings_utils.py +++ b/script.timers/resources/lib/utils/settings_utils.py @@ -1,13 +1,15 @@ import time +from datetime import datetime import xbmcaddon import xbmcgui -from resources.lib.timer.storage import Storage +from resources.lib.utils import housekeeper from resources.lib.player.mediatype import VIDEO -from resources.lib.timer.timer import (DEFAULT_TIME, END_TYPE_NO, FADE_OFF, +from resources.lib.timer.storage import Storage +from resources.lib.timer.timer import (END_TYPE_NO, FADE_OFF, MEDIA_ACTION_NONE, SYSTEM_ACTION_NONE, Timer) -from resources.lib.utils.datetime_utils import WEEKLY +from resources.lib.utils import datetime_utils _ON_SETTING_CHANGE_EVENTS = "onSettingChangeEvents" _SETTING_CHANGE_EVENTS_MAX_SECS = 5 @@ -58,12 +60,13 @@ def prepare_empty_timer_in_setting(timer_id=None) -> None: addon.setSettingString("timer_label", addon.getLocalizedString(32257)) addon.setSettingInt("timer_priority", 0) addon.setSetting("timer_days", "") - addon.setSetting("timer_start", DEFAULT_TIME) + addon.setSetting("timer_date", "") + addon.setSetting("timer_start", datetime_utils.DEFAULT_TIME) addon.setSettingInt("timer_start_offset", 0) addon.setSettingInt("timer_end_type", END_TYPE_NO) - addon.setSetting("timer_duration", DEFAULT_TIME) + addon.setSetting("timer_duration", datetime_utils.DEFAULT_TIME) addon.setSettingInt("timer_duration_offset", 0) - addon.setSetting("timer_end", DEFAULT_TIME) + addon.setSetting("timer_end", datetime_utils.DEFAULT_TIME) addon.setSettingInt("timer_end_offset", 0) addon.setSettingInt("timer_system_action", SYSTEM_ACTION_NONE) addon.setSettingInt("timer_media_action", MEDIA_ACTION_NONE) @@ -95,13 +98,14 @@ def save_timer_from_settings() -> None: return days = addon.getSetting("timer_days") - if days not in ["", str(WEEKLY)]: + if days not in ["", str(datetime_utils.WEEKLY)]: days = [int(d) for d in days.split("|")] else: days = list() timer = Timer(timer_id) timer.days = days + timer.date = addon.getSetting("timer_date") timer.duration = addon.getSetting("timer_duration") timer.duration_offset = addon.getSettingInt("timer_duration_offset") timer.end = addon.getSetting("timer_end") @@ -123,10 +127,13 @@ def save_timer_from_settings() -> None: timer.vol_min = addon.getSettingInt("timer_vol_min") timer.vol_max = addon.getSettingInt("timer_vol_max") + timer.init() + timer.to_timer_by_date(base=datetime.today()) + Storage().save_timer(timer=timer) -def select_timer(multi=False, extra=None) -> 'tuple[list[Timer], list[int]]': +def select_timer(multi=False, extra: 'list[str]' = None, preselect_strategy=None) -> 'tuple[list[Timer], list[int]]': addon = xbmcaddon.Addon() @@ -137,7 +144,7 @@ def select_timer(multi=False, extra=None) -> 'tuple[list[Timer], list[int]]': return None, None - timers.sort(key=lambda t: (t.days, t.start, + timers.sort(key=lambda t: (t.days, t.date, t.start, t.media_action, t.system_action)) options = extra or list() @@ -147,12 +154,18 @@ def select_timer(multi=False, extra=None) -> 'tuple[list[Timer], list[int]]': timer.periods_to_human_readable() ) for timer in timers]) + preselect = list() + if preselect_strategy is not None: + preselect.extend([i + (len(extra) if extra else 0) + for i, timer in enumerate(timers) if preselect_strategy(timer)]) + if multi: selection = xbmcgui.Dialog().multiselect( - addon.getLocalizedString(32103), options) + addon.getLocalizedString(32103), options, preselect=preselect) else: + preselect = preselect[0] if preselect else -1 selection = [xbmcgui.Dialog().select( - addon.getLocalizedString(32103), options)] + addon.getLocalizedString(32103), options, preselect=preselect)] if not selection or -1 in selection: return timers, None @@ -162,7 +175,13 @@ def select_timer(multi=False, extra=None) -> 'tuple[list[Timer], list[int]]': def delete_timer() -> None: - timers, idx = select_timer(multi=True) + now = datetime.today() + + def outdated_timers(t: Timer) -> bool: + + return housekeeper.check_timer(t, now) == housekeeper.ACTION_DELETE + + timers, idx = select_timer(multi=True, preselect_strategy=outdated_timers) if idx is None: return @@ -194,6 +213,7 @@ def load_timer_into_settings(timer: Timer) -> None: addon.setSettingString("timer_label", timer.label) addon.setSettingInt("timer_priority", timer.priority) addon.setSetting("timer_days", "|".join([str(d) for d in timer.days])) + addon.setSetting("timer_date", timer.date) addon.setSetting("timer_start", timer.start) addon.setSettingInt("timer_start_offset", timer.start_offset) addon.setSettingInt("timer_end_type", timer.end_type) diff --git a/script.timers/resources/lib/utils/system_utils.py b/script.timers/resources/lib/utils/system_utils.py index 98005f266f..3c3c59fa0e 100644 --- a/script.timers/resources/lib/utils/system_utils.py +++ b/script.timers/resources/lib/utils/system_utils.py @@ -1,3 +1,5 @@ +import re + import xbmc from resources.lib.utils.jsonrpc_utils import json_rpc @@ -22,3 +24,10 @@ def set_windows_unlock(value: bool) -> bool: ) return value + + +def get_kodi_version() -> float: + + build_version = re.match( + r"^([0-9]+\.[0-9]+).*$", xbmc.getInfoLabel('System.BuildVersion')).groups()[0] + return float(build_version) diff --git a/script.timers/resources/settings.xml b/script.timers/resources/settings.xml index 7ab588c591..e7706e63aa 100644 --- a/script.timers/resources/settings.xml +++ b/script.timers/resources/settings.xml @@ -63,6 +63,25 @@ false + + 0 + + + true + + + 32193 + + false + + + + -1 + 2100 + + + + 0 Timer 15 @@ -129,11 +148,27 @@ - - -1 - + + 0 + + + 32193 + + + true + + false + + + + -1 + 2100 + + + + 0 @@ -147,6 +182,7 @@ + | @@ -158,6 +194,20 @@ -1 + + 0 + 1970-01-01 + + false + + + 32043 + + + -1 + 8 + + 0 00:00 @@ -268,6 +318,25 @@ + + 0 + + + true + + + 32193 + + false + + + + -1 + 2100 + + + + 0 2 @@ -426,6 +495,25 @@ + + 0 + + + true + + + 32193 + + false + + + + -1 + 2100 + + + + 1 0 @@ -482,6 +570,25 @@ + + 0 + + + true + + + 32193 + + false + + + + -1 + 2100 + + + + 2 0 @@ -506,6 +613,25 @@ + + 0 + + + true + + + 32193 + + false + + + + -1 + 2100 + + + + 2 true @@ -641,6 +767,58 @@ + + + 3 + 0 + + -180 + 5 + 180 + + + false + + + + 3 + 0 + + -180 + 5 + 180 + + + false + + + + + + 3 + 0 + + -180 + 5 + 180 + + + false + + + + 3 + 0 + + -180 + 5 + 180 + + + false + + + @@ -694,13 +872,28 @@ 3 - 7 + 8 + + 32256 + + false + + + 3 + 20 32256 false + + + 3 + true + + +