Music production session manager
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

173 lines
7.6 KiB

#! /usr/bi n/env python3
# -*- coding: utf-8 -*-
Copyright 2021, Nils Hilbricht, Germany ( )
This file is part of the Laborejo Software Suite ( ),
This application is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <>.
import logging; logger = logging.getLogger(__name__);"import")
#Standard Library
from datetime import datetime
import pathlib
import os
#Our Modules
from engine.start import PATHS
def fast_scandir(dir):
Get all subdirectories recursively."""
subfolders= [f.path for f in os.scandir(dir) if f.is_dir()]
except PermissionError:
subfolders = []
for dir in list(subfolders):
return subfolders
class Watcher(object):
Initialize with the server controller
The watcher will should only run when the program is in the mode to choose a session.
If a session is loaded it will technically work, but will be a waste of resources.
The watcher will also trigger the nsmController to redundantly query for a session list,
for example when a new empty sessions gets created or duplicated. Once because nsmd sends the
"new" signal, and then our watcher will notice the change itself. However, this happens only so
often and can be accepted, so the program does not need more checks and exceptions.
Inform you via callback when:
a) a session dir was deleted
b) a new directory got created.
c) session directory renamed or moved
d) session.nsm changed timestamp
e) A lockfile appeared or disappeared
We cannot poll nsmServerControl.exportSessionsAsDicts() because that triggers an nsmd response
including log message and will flood their console. Instead this class only offers incremental
Another way is to watch the dir for changes ourselves in in some cases request a new project
list from nsmd.
Lockfile functionality goes beyond what NSM offers. A session-daemon can open only one
project at a time. If you try to open another project with a second GUI (but same NSM-URL)
it goes wrong a bit, at least in the new-session-manager GUI. They will leave a zombie lockfile.
However, it still is possible to open a second nsmd instance. In this case the lockfile prevents
opening a session twice. And we are reflecting the opened state of the project, no matter from
which daemon. Horray for us :)
Clients can only be added or removed while a session is locked. We do not check nor update
number of clients or symlinks. Therefore we advice a GUI to deactivate the display of
these two values while a session is locked (together with size)
def __init__(self, nsmServerControl): = True
self._nsmServerControl = nsmServerControl
assert self._nsmServerControl.sessionRoot
self._directories = fast_scandir(self._nsmServerControl.sessionRoot)"Requestion our own copy of the session list. Don't worry about the apparent redundant call :)")
self._lastExport = self._nsmServerControl.exportSessionsAsDicts() #list of dicts
self._lastTimestamp = {d["nsmSessionName"]:d["lastSavedDate"] for d in self._lastExport} #str:str
#Init all values with None will send the initial state via callback on program start, which is what the user wants to know.
self._lastLockfile = {d["nsmSessionName"]:None for d in self._lastExport} #str:bool
self.timeStampHook = None # a single function that gets informed of changes, most likely the api callback
self.lockFileHook = None # a single function that gets informed of changes, most likely the api callback
self.sessionsChangedHook = None # the api callback function api.callbacks._sessionsChanged. Rarely used.
def resume(self, *args):
"""For api callbacks""" = True
#If we returned from an open session that will surely have changed. Trigger a single poll
self.process()"Watcher resumed")
def suspend(self, *args):
"""For api callbacks""" = False"Watcher suspended")
def _update(self):
current_directories = fast_scandir(self._nsmServerControl.sessionRoot)
if not self._directories == current_directories:
self._directories = current_directories
self._lastExport = self.sessionsChangedHook() #will gather its own data, send it to api callbacks, but also return for us.
self._lastTimestamp = {d["nsmSessionName"]:d["lastSavedDate"] for d in self._lastExport} #str:str
self._lastLockfile = {d["nsmSessionName"]:None for d in self._lastExport} #str:bool
def process(self):
"""Add this to your event loop.
We look for any changes in the directory structure.
If we detect any we simply trigger a new NSM export and a new NSM generated project
list via callback. We do not expect this to happen often.
This will also trigger if we add a new session ourselves. This *is* our way to react to
new Sessions.
if not
if self.sessionsChangedHook:
#Now check the incremental hooks.
#No hooks, no reason to process
if not (self.timeStampHook or self.lockFileHook):"No watcher-hooks to process")
for entry in self._lastExport:
nsmSessionName = entry["nsmSessionName"]
#Timestamp of session.nsm
if self.timeStampHook:
timestamp = datetime.fromtimestamp(entry["sessionFile"].stat().st_mtime).isoformat(sep=" ", timespec='minutes') #same format as server control export
if not timestamp == self._lastTimestamp[nsmSessionName]: #This will only trigger on a minute-based slot, which is all we want and need. This is for relaying information to the user, not for advanced processing.
self._lastTimestamp[nsmSessionName] = timestamp
self.timeStampHook(nsmSessionName, timestamp)
if self.lockFileHook:
lockfileState = entry["lockFile"].is_file()
if not self._lastLockfile[nsmSessionName] == lockfileState:
self._lastLockfile[nsmSessionName] = lockfileState
self.lockFileHook(nsmSessionName, lockfileState)
except PermissionError:
logger.warning(f"File Permission error for {entry}")
self._lastExport.remove(entry) #avoid stumbling upon this again
except FileNotFoundError:
logger.warning(f"File not found error for {entry}")
self._lastExport.remove(entry) #avoid stumbling upon this again