Try non-normalised / redownload file on failure to load.
This commit is contained in:
parent
432c645759
commit
5103023e85
3 changed files with 121 additions and 61 deletions
|
@ -60,6 +60,7 @@ class MyRadioAPI:
|
||||||
+ str(response.status)
|
+ str(response.status)
|
||||||
)
|
)
|
||||||
self._logException(str(await response.text()))
|
self._logException(str(await response.text()))
|
||||||
|
return None # Given the output was bad, don't forward it.
|
||||||
return await response.read()
|
return await response.read()
|
||||||
|
|
||||||
def call(self, url, method="GET", data=None, timeout=10, json_payload=True):
|
def call(self, url, method="GET", data=None, timeout=10, json_payload=True):
|
||||||
|
@ -102,12 +103,12 @@ class MyRadioAPI:
|
||||||
|
|
||||||
request = None
|
request = None
|
||||||
if method == "GET":
|
if method == "GET":
|
||||||
request = self.async_call(url, method="GET", timeout=timeout)
|
request = await self.async_call(url, method="GET", timeout=timeout)
|
||||||
elif method == "POST":
|
elif method == "POST":
|
||||||
self._log("POST data: {}".format(data))
|
self._log("POST data: {}".format(data))
|
||||||
request = self.async_call(url, data=data, method="POST", timeout=timeout)
|
request = await self.async_call(url, data=data, method="POST", timeout=timeout)
|
||||||
elif method == "PUT":
|
elif method == "PUT":
|
||||||
request = self.async_call(url, method="PUT", timeout=timeout)
|
request = await self.async_call(url, method="PUT", timeout=timeout)
|
||||||
else:
|
else:
|
||||||
self._logException("Invalid API method. Request not sent.")
|
self._logException("Invalid API method. Request not sent.")
|
||||||
return None
|
return None
|
||||||
|
@ -157,7 +158,7 @@ class MyRadioAPI:
|
||||||
self._logException("Failed to get list of show plans.")
|
self._logException("Failed to get list of show plans.")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
payload = json.loads(await request)["payload"]
|
payload = json.loads(request)["payload"]
|
||||||
|
|
||||||
shows = []
|
shows = []
|
||||||
if not payload["current"]:
|
if not payload["current"]:
|
||||||
|
@ -186,7 +187,7 @@ class MyRadioAPI:
|
||||||
self._logException("Failed to get show plan.")
|
self._logException("Failed to get show plan.")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
payload = json.loads(await request)["payload"]
|
payload = json.loads(request)["payload"]
|
||||||
|
|
||||||
plan = {}
|
plan = {}
|
||||||
|
|
||||||
|
@ -203,7 +204,7 @@ class MyRadioAPI:
|
||||||
|
|
||||||
# Audio Library
|
# Audio Library
|
||||||
|
|
||||||
async def get_filename(self, item: PlanItem, did_download: bool = False):
|
async def get_filename(self, item: PlanItem, did_download: bool = False, redownload=False):
|
||||||
format = "mp3" # TODO: Maybe we want this customisable?
|
format = "mp3" # TODO: Maybe we want this customisable?
|
||||||
if item.trackid:
|
if item.trackid:
|
||||||
itemType = "track"
|
itemType = "track"
|
||||||
|
@ -234,28 +235,30 @@ class MyRadioAPI:
|
||||||
filename: str = resolve_external_file_path(
|
filename: str = resolve_external_file_path(
|
||||||
"/music-tmp/{}-{}.{}".format(itemType, id, format)
|
"/music-tmp/{}-{}.{}".format(itemType, id, format)
|
||||||
)
|
)
|
||||||
# Check if we already downloaded the file. If we did, give that.
|
|
||||||
if os.path.isfile(filename):
|
|
||||||
self._log("Already got file: " + filename, DEBUG)
|
|
||||||
return (filename, False) if did_download else filename
|
|
||||||
|
|
||||||
# If something else (another channel, the preloader etc) is downloading the track, wait for it.
|
if not redownload:
|
||||||
if os.path.isfile(filename + dl_suffix):
|
# Check if we already downloaded the file. If we did, give that, unless we're forcing a redownload.
|
||||||
time_waiting_s = 0
|
if os.path.isfile(filename):
|
||||||
self._log(
|
self._log("Already got file: " + filename, DEBUG)
|
||||||
"Waiting for download to complete from another worker. " + filename,
|
return (filename, False) if did_download else filename
|
||||||
DEBUG,
|
|
||||||
)
|
# If something else (another channel, the preloader etc) is downloading the track, wait for it.
|
||||||
while time_waiting_s < 20:
|
if os.path.isfile(filename + dl_suffix):
|
||||||
# TODO: Make something better here.
|
time_waiting_s = 0
|
||||||
# If the connectivity is super poor or we're loading reeaaaalllly long files,
|
self._log(
|
||||||
# this may be annoying, but this is just in case somehow the other api download gives up.
|
"Waiting for download to complete from another worker. " + filename,
|
||||||
if os.path.isfile(filename):
|
DEBUG,
|
||||||
# Now the file is downloaded successfully
|
)
|
||||||
return (filename, False) if did_download else filename
|
while time_waiting_s < 20:
|
||||||
time_waiting_s += 1
|
# TODO: Make something better here.
|
||||||
self._log("Still waiting", DEBUG)
|
# If the connectivity is super poor or we're loading reeaaaalllly long files,
|
||||||
time.sleep(1)
|
# this may be annoying, but this is just in case somehow the other api download gives up.
|
||||||
|
if os.path.isfile(filename):
|
||||||
|
# Now the file is downloaded successfully
|
||||||
|
return (filename, False) if did_download else filename
|
||||||
|
time_waiting_s += 1
|
||||||
|
self._log("Still waiting", DEBUG)
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
# File doesn't exist, download it.
|
# File doesn't exist, download it.
|
||||||
try:
|
try:
|
||||||
|
@ -267,17 +270,18 @@ class MyRadioAPI:
|
||||||
|
|
||||||
request = await self.async_api_call(url, api_version="non")
|
request = await self.async_api_call(url, api_version="non")
|
||||||
|
|
||||||
if not request:
|
if not request or not isinstance(request, (bytes, bytearray)):
|
||||||
return (None, False) if did_download else None
|
return (None, False) if did_download else None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with open(filename + dl_suffix, "wb") as file:
|
with open(filename + dl_suffix, "wb") as file:
|
||||||
file.write(await request)
|
file.write(request)
|
||||||
os.rename(filename + dl_suffix, filename)
|
os.rename(filename + dl_suffix, filename)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self._logException("Failed to write music file: {}".format(e))
|
self._logException("Failed to write music file: {}".format(e))
|
||||||
return (None, False) if did_download else None
|
return (None, False) if did_download else None
|
||||||
|
|
||||||
|
self._log("Successfully re/downloaded file.", DEBUG)
|
||||||
return (filename, True) if did_download else filename
|
return (filename, True) if did_download else filename
|
||||||
|
|
||||||
# Gets the list of managed music playlists.
|
# Gets the list of managed music playlists.
|
||||||
|
@ -285,22 +289,22 @@ class MyRadioAPI:
|
||||||
url = "/playlist/allitonesplaylists"
|
url = "/playlist/allitonesplaylists"
|
||||||
request = await self.async_api_call(url)
|
request = await self.async_api_call(url)
|
||||||
|
|
||||||
if not request:
|
if not request or not isinstance(request, bytes):
|
||||||
self._logException("Failed to retrieve music playlists.")
|
self._logException("Failed to retrieve music playlists.")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
return json.loads(await request)["payload"]
|
return json.loads(request)["payload"]
|
||||||
|
|
||||||
# Gets the list of managed aux playlists (sfx, beds etc.)
|
# Gets the list of managed aux playlists (sfx, beds etc.)
|
||||||
async def get_playlist_aux(self):
|
async def get_playlist_aux(self):
|
||||||
url = "/nipswebPlaylist/allmanagedplaylists"
|
url = "/nipswebPlaylist/allmanagedplaylists"
|
||||||
request = await self.async_api_call(url)
|
request = await self.async_api_call(url)
|
||||||
|
|
||||||
if not request:
|
if not request or not isinstance(request, bytes):
|
||||||
self._logException("Failed to retrieve music playlists.")
|
self._logException("Failed to retrieve music playlists.")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
return json.loads(await request)["payload"]
|
return json.loads(request)["payload"]
|
||||||
|
|
||||||
# Loads the playlist items for a certain managed aux playlist
|
# Loads the playlist items for a certain managed aux playlist
|
||||||
async def get_playlist_aux_items(self, library_id: str):
|
async def get_playlist_aux_items(self, library_id: str):
|
||||||
|
@ -311,13 +315,13 @@ class MyRadioAPI:
|
||||||
url = "/nipswebPlaylist/{}/items".format(library_id)
|
url = "/nipswebPlaylist/{}/items".format(library_id)
|
||||||
request = await self.async_api_call(url)
|
request = await self.async_api_call(url)
|
||||||
|
|
||||||
if not request:
|
if not request or not isinstance(request, bytes):
|
||||||
self._logException(
|
self._logException(
|
||||||
"Failed to retrieve items for aux playlist {}.".format(library_id)
|
"Failed to retrieve items for aux playlist {}.".format(library_id)
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
return json.loads(await request)["payload"]
|
return json.loads(request)["payload"]
|
||||||
|
|
||||||
# Loads the playlist items for a certain managed playlist
|
# Loads the playlist items for a certain managed playlist
|
||||||
|
|
||||||
|
@ -325,13 +329,13 @@ class MyRadioAPI:
|
||||||
url = "/playlist/{}/tracks".format(library_id)
|
url = "/playlist/{}/tracks".format(library_id)
|
||||||
request = await self.async_api_call(url)
|
request = await self.async_api_call(url)
|
||||||
|
|
||||||
if not request:
|
if not request or not isinstance(request, bytes):
|
||||||
self._logException(
|
self._logException(
|
||||||
"Failed to retrieve items for music playlist {}.".format(library_id)
|
"Failed to retrieve items for music playlist {}.".format(library_id)
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
return json.loads(await request)["payload"]
|
return json.loads(request)["payload"]
|
||||||
|
|
||||||
async def get_track_search(
|
async def get_track_search(
|
||||||
self, title: Optional[str], artist: Optional[str], limit: int = 100
|
self, title: Optional[str], artist: Optional[str], limit: int = 100
|
||||||
|
@ -341,11 +345,11 @@ class MyRadioAPI:
|
||||||
)
|
)
|
||||||
request = await self.async_api_call(url)
|
request = await self.async_api_call(url)
|
||||||
|
|
||||||
if not request:
|
if not request or not isinstance(request, bytes):
|
||||||
self._logException("Failed to search for track.")
|
self._logException("Failed to search for track.")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
return json.loads(await request)["payload"]
|
return json.loads(request)["payload"]
|
||||||
|
|
||||||
def post_tracklist_start(self, item: PlanItem):
|
def post_tracklist_start(self, item: PlanItem):
|
||||||
if item.type != "central":
|
if item.type != "central":
|
||||||
|
|
|
@ -44,3 +44,24 @@ def get_normalised_filename_if_available(filename: str):
|
||||||
|
|
||||||
# Else we've not got a normalised verison, just take original.
|
# Else we've not got a normalised verison, just take original.
|
||||||
return filename
|
return filename
|
||||||
|
|
||||||
|
|
||||||
|
# Returns the original file from the normalised one, useful if the normalised one is borked.
|
||||||
|
def get_original_filename_from_normalised(filename: str):
|
||||||
|
if not (isinstance(filename, str) and filename.endswith(".mp3")):
|
||||||
|
raise ValueError("Invalid filename given.")
|
||||||
|
|
||||||
|
# Already not normalised.
|
||||||
|
if not filename.endswith("-normalised.mp3"):
|
||||||
|
if os.path.exists(filename):
|
||||||
|
return filename
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Take the filename, remove "-normalised" from it.
|
||||||
|
original_filename = "{}.mp3".format(filename.rsplit("-", 1)[0])
|
||||||
|
|
||||||
|
# original version exists
|
||||||
|
if os.path.exists(original_filename):
|
||||||
|
return original_filename
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
79
player.py
79
player.py
|
@ -36,7 +36,7 @@ from mutagen.mp3 import MP3
|
||||||
from syncer import sync
|
from syncer import sync
|
||||||
from threading import Timer
|
from threading import Timer
|
||||||
|
|
||||||
from helpers.normalisation import get_normalised_filename_if_available
|
from helpers.normalisation import get_normalised_filename_if_available, get_original_filename_from_normalised
|
||||||
from helpers.myradio_api import MyRadioAPI
|
from helpers.myradio_api import MyRadioAPI
|
||||||
from helpers.state_manager import StateManager
|
from helpers.state_manager import StateManager
|
||||||
from helpers.logging_manager import LoggingManager
|
from helpers.logging_manager import LoggingManager
|
||||||
|
@ -170,9 +170,10 @@ class Player:
|
||||||
# Audio Playout Related Methods
|
# Audio Playout Related Methods
|
||||||
|
|
||||||
def play(self, pos: float = 0):
|
def play(self, pos: float = 0):
|
||||||
if not self.isLoaded:
|
|
||||||
return
|
|
||||||
self.logger.log.info("Playing from pos: " + str(pos))
|
self.logger.log.info("Playing from pos: " + str(pos))
|
||||||
|
if not self.isLoaded:
|
||||||
|
self.logger.log.warning("Player is not loaded.")
|
||||||
|
return False
|
||||||
try:
|
try:
|
||||||
mixer.music.play(0, pos)
|
mixer.music.play(0, pos)
|
||||||
self.state.update("pos_offset", pos)
|
self.state.update("pos_offset", pos)
|
||||||
|
@ -329,7 +330,7 @@ class Player:
|
||||||
# Right. So this may be confusing.
|
# Right. So this may be confusing.
|
||||||
# So... If the user has just moved the loaded item in the channel (by removing above and readding)
|
# So... If the user has just moved the loaded item in the channel (by removing above and readding)
|
||||||
# Then we want to re-associate the loaded_item object reference with the new one.
|
# Then we want to re-associate the loaded_item object reference with the new one.
|
||||||
# The loaded item object before this change is now an ophan, which was
|
# The loaded item object before this change is now an orphan, which was
|
||||||
# kept around while the loaded item was potentially moved to another
|
# kept around while the loaded item was potentially moved to another
|
||||||
# channel.
|
# channel.
|
||||||
if loaded_item.timeslotitemid == new_item_obj.timeslotitemid:
|
if loaded_item.timeslotitemid == new_item_obj.timeslotitemid:
|
||||||
|
@ -406,9 +407,13 @@ class Player:
|
||||||
|
|
||||||
def load(self, weight: int):
|
def load(self, weight: int):
|
||||||
if not self.isPlaying:
|
if not self.isPlaying:
|
||||||
loaded_state = self.state.get()
|
# If we have something loaded already, unload it first.
|
||||||
self.unload()
|
self.unload()
|
||||||
|
|
||||||
|
loaded_state = self.state.get()
|
||||||
|
|
||||||
|
# Sometimes (at least on windows), the pygame player will lose output to the sound output after a while.
|
||||||
|
# It's odd, but essentially, to stop / recover from this, we de-init the pygame mixer and init it again.
|
||||||
self.logger.log.info(
|
self.logger.log.info(
|
||||||
"Resetting output (in case of sound output gone silent somehow) to "
|
"Resetting output (in case of sound output gone silent somehow) to "
|
||||||
+ str(loaded_state["output"])
|
+ str(loaded_state["output"])
|
||||||
|
@ -419,16 +424,19 @@ class Player:
|
||||||
|
|
||||||
loaded_item: Optional[PlanItem] = None
|
loaded_item: Optional[PlanItem] = None
|
||||||
|
|
||||||
|
# Go find the show plan item of the weight we've been asked to load.
|
||||||
for i in range(len(showplan)):
|
for i in range(len(showplan)):
|
||||||
if showplan[i].weight == weight:
|
if showplan[i].weight == weight:
|
||||||
loaded_item = showplan[i]
|
loaded_item = showplan[i]
|
||||||
break
|
break
|
||||||
|
|
||||||
|
# If we didn't find it, exit.
|
||||||
if loaded_item is None:
|
if loaded_item is None:
|
||||||
self.logger.log.error(
|
self.logger.log.error(
|
||||||
"Failed to find weight: {}".format(weight))
|
"Failed to find weight: {}".format(weight))
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
# The file_manager helper may have pre-downloaded the file already, or we've played it before.
|
||||||
reload = False
|
reload = False
|
||||||
if loaded_item.filename == "" or loaded_item.filename is None:
|
if loaded_item.filename == "" or loaded_item.filename is None:
|
||||||
self.logger.log.info(
|
self.logger.log.info(
|
||||||
|
@ -440,10 +448,12 @@ class Player:
|
||||||
)
|
)
|
||||||
reload = True
|
reload = True
|
||||||
|
|
||||||
|
# Ask the API for the file if we need it.
|
||||||
if reload:
|
if reload:
|
||||||
loaded_item.filename = sync(
|
file = sync(self.api.get_filename(item=loaded_item))
|
||||||
self.api.get_filename(item=loaded_item))
|
loaded_item.filename = str(file) if file else None
|
||||||
|
|
||||||
|
# If the API still couldn't get the file, RIP.
|
||||||
if not loaded_item.filename:
|
if not loaded_item.filename:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
@ -452,38 +462,57 @@ class Player:
|
||||||
loaded_item.filename
|
loaded_item.filename
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# We're comitting to load this item.
|
||||||
self.state.update("loaded_item", loaded_item)
|
self.state.update("loaded_item", loaded_item)
|
||||||
|
|
||||||
|
# Given we've just messed around with filenames etc, update the item in the show plan too.
|
||||||
for i in range(len(showplan)):
|
for i in range(len(showplan)):
|
||||||
if showplan[i].weight == weight:
|
if showplan[i].weight == weight:
|
||||||
self.state.update("show_plan", index=i, value=loaded_item)
|
self.state.update("show_plan", index=i, value=loaded_item)
|
||||||
break
|
break
|
||||||
# TODO: Update the show plan filenames???
|
|
||||||
|
|
||||||
load_attempt = 0
|
load_attempt = 0
|
||||||
|
|
||||||
if not isinstance(loaded_item.filename, str):
|
# Let's have 5 attempts at loading the item audio
|
||||||
return False
|
|
||||||
|
|
||||||
while load_attempt < 5:
|
while load_attempt < 5:
|
||||||
load_attempt += 1
|
load_attempt += 1
|
||||||
|
|
||||||
|
original_file = None
|
||||||
|
if load_attempt == 3:
|
||||||
|
# Ok, we tried twice already to load the file.
|
||||||
|
# Let's see if we can recover from this.
|
||||||
|
# Try swapping the normalised version out for the original.
|
||||||
|
original_file = get_original_filename_from_normalised(
|
||||||
|
loaded_item.filename
|
||||||
|
)
|
||||||
|
self.logger.log.warning("3rd attempt. Trying the non-normalised file: {}".format(original_file))
|
||||||
|
|
||||||
|
if load_attempt == 4:
|
||||||
|
# well, we've got so far that the normalised and original files didn't load.
|
||||||
|
# Take a last ditch effort to download the original file again.
|
||||||
|
file = sync(self.api.get_filename(item=loaded_item, redownload=True))
|
||||||
|
if file:
|
||||||
|
original_file = str(file)
|
||||||
|
self.logger.log.warning("4rd attempt. Trying to redownload the file, got: {}".format(original_file))
|
||||||
|
|
||||||
|
if original_file:
|
||||||
|
loaded_item.filename = original_file
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self.logger.log.info(
|
self.logger.log.info(
|
||||||
"Loading file: " + str(loaded_item.filename))
|
"Attempt {} Loading file: {}".format(load_attempt, loaded_item.filename))
|
||||||
mixer.music.load(loaded_item.filename)
|
mixer.music.load(loaded_item.filename)
|
||||||
except Exception:
|
except Exception:
|
||||||
# We couldn't load that file.
|
# We couldn't load that file.
|
||||||
self.logger.log.exception(
|
self.logger.log.exception(
|
||||||
"Couldn't load file: " + str(loaded_item.filename)
|
"Couldn't load file: " + str(loaded_item.filename)
|
||||||
)
|
)
|
||||||
time.sleep(1)
|
|
||||||
continue # Try loading again.
|
continue # Try loading again.
|
||||||
|
|
||||||
if not self.isLoaded:
|
if not self.isLoaded:
|
||||||
self.logger.log.error(
|
self.logger.log.error(
|
||||||
"Pygame loaded file without error, but never actually loaded."
|
"Pygame loaded file without error, but never actually loaded."
|
||||||
)
|
)
|
||||||
time.sleep(1)
|
|
||||||
continue # Try loading again.
|
continue # Try loading again.
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
@ -500,23 +529,25 @@ class Player:
|
||||||
except Exception:
|
except Exception:
|
||||||
self.logger.log.exception(
|
self.logger.log.exception(
|
||||||
"Failed to update the length of item.")
|
"Failed to update the length of item.")
|
||||||
time.sleep(1)
|
|
||||||
continue # Try loading again.
|
continue # Try loading again.
|
||||||
|
|
||||||
# Everything worked, we made it!
|
# Everything worked, we made it!
|
||||||
|
# Write the loaded item again once more, to confirm the filename if we've reattempted.
|
||||||
|
self.state.update("loaded_item", loaded_item)
|
||||||
|
|
||||||
if loaded_item.cue > 0:
|
if loaded_item.cue > 0:
|
||||||
self.seek(loaded_item.cue)
|
self.seek(loaded_item.cue)
|
||||||
else:
|
else:
|
||||||
self.seek(0)
|
self.seek(0)
|
||||||
|
|
||||||
if self.state.get()["play_on_load"]:
|
if loaded_state["play_on_load"]:
|
||||||
self.unpause()
|
self.unpause()
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
self.logger.log.error(
|
# Even though we failed, make sure state is up to date with latest failure.
|
||||||
"Failed to load track after numerous retries.")
|
# We're comitting to load this item.
|
||||||
return False
|
self.state.update("loaded_item", loaded_item)
|
||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
@ -546,8 +577,10 @@ class Player:
|
||||||
self.logger.log.exception("Failed to quit mixer.")
|
self.logger.log.exception("Failed to quit mixer.")
|
||||||
|
|
||||||
def output(self, name: Optional[str] = None):
|
def output(self, name: Optional[str] = None):
|
||||||
wasPlaying = self.state.get()["playing"]
|
wasPlaying = self.isPlaying
|
||||||
oldPos = self.state.get()["pos_true"]
|
|
||||||
|
state = self.state.get()
|
||||||
|
oldPos = state["pos_true"]
|
||||||
|
|
||||||
name = None if (not name or name.lower() == "none") else name
|
name = None if (not name or name.lower() == "none") else name
|
||||||
|
|
||||||
|
@ -564,7 +597,7 @@ class Player:
|
||||||
)
|
)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
loadedItem = self.state.get()["loaded_item"]
|
loadedItem = state["loaded_item"]
|
||||||
if loadedItem:
|
if loadedItem:
|
||||||
self.logger.log.info("Reloading after output change.")
|
self.logger.log.info("Reloading after output change.")
|
||||||
self.load(loadedItem.weight)
|
self.load(loadedItem.weight)
|
||||||
|
@ -799,6 +832,8 @@ class Player:
|
||||||
loaded_item.name, loaded_item.weight
|
loaded_item.name, loaded_item.weight
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
# Just make sure that if we stop and do nothing, we end up at 0.
|
||||||
|
self.state.update("pos",0)
|
||||||
|
|
||||||
# Repeat 1
|
# Repeat 1
|
||||||
# TODO ENUM
|
# TODO ENUM
|
||||||
|
|
Loading…
Reference in a new issue