Compare commits

...

12 commits

Author SHA1 Message Date
867812fba7 canonicalize ids here and there 2022-01-17 01:07:32 +01:00
49638cab7c adapt UI "single-event" check to new syntax 2022-01-17 01:07:16 +01:00
803a01357d fix some more usecase 2022-01-17 01:00:15 +01:00
cd94d032e3 make web UI compatible with multiDB 2022-01-17 00:48:44 +01:00
eb0f6c0310 change operations only work on "main" DB 2022-01-17 00:46:24 +01:00
257e3e45cd larigira-dbmanage is multipleDB-ready 2022-01-17 00:21:35 +01:00
d9d0af994c typing and cleanup 2022-01-17 00:21:13 +01:00
dcbb804b94 additional DBs are loaded indeed
refs #19
2022-01-17 00:03:48 +01:00
8de4719701 better handle badly-written DBs 2022-01-17 00:03:08 +01:00
51a7c96ea0 Initial support for multiple dbs
refs #19
2022-01-17 00:00:23 +01:00
c0cc90a2f3 very new files are not removed
this makes download_http more reliable when reusing a file that could otherwise have been removed by UnusedCleaner
2022-01-03 00:53:18 +01:00
b35da0a8d0 download_http doesn't download again
refs #17
2022-01-02 20:16:55 +01:00
8 changed files with 193 additions and 32 deletions

View file

@ -19,6 +19,7 @@ def get_conf(prefix="LARIGIRA_"):
conf["UMASK"] = None conf["UMASK"] = None
conf["CACHING_TIME"] = 10 conf["CACHING_TIME"] = 10
conf["DB_URI"] = os.path.join(conf_dir, "db.json") conf["DB_URI"] = os.path.join(conf_dir, "db.json")
conf["DB_ADDITIONAL_DIR"] = os.path.join(conf_dir, "db.d")
conf["SCRIPTS_PATH"] = os.path.join(conf_dir, "scripts") conf["SCRIPTS_PATH"] = os.path.join(conf_dir, "scripts")
conf["EXTRA_STATIC_PATH"] = os.path.join(conf_dir, "extra") conf["EXTRA_STATIC_PATH"] = os.path.join(conf_dir, "extra")
conf["EXTRA_MENU_LINKS"] = [] conf["EXTRA_MENU_LINKS"] = []

View file

@ -1,24 +1,109 @@
from logging import getLogger
from tinydb import TinyDB from tinydb import TinyDB
from tinydb.storages import JSONStorage
from tinydb.middlewares import Middleware
from pathlib import Path
from typing import Union, Tuple
class ReadOnlyMiddleware(Middleware):
"""
Make sure no write ever occurs
"""
def __init__(self, storage_cls=TinyDB.DEFAULT_STORAGE):
super().__init__(storage_cls)
def write(self, data):
raise ReadOnlyException('You cannot write to a readonly db')
class ReadOnlyException(ValueError):
pass
def only_main(f):
'''assumes first argument is id, and must be "main"'''
def wrapper(self, *args, **kwargs):
_id = args[0]
db, db_id = EventModel.parse_id(_id)
if db != 'main':
raise ReadOnlyException('You called a write operation on a readonly db')
return f(self, db_id, *args[1:], **kwargs)
return wrapper
class EventModel(object): class EventModel(object):
def __init__(self, uri): def __init__(self, uri, additional_db_dir=None):
self.uri = uri self.uri = uri
self.db = None self.additional_db_dir = Path(additional_db_dir) if additional_db_dir else None
self._dbs = {}
self.log = getLogger(self.__class__.__name__)
self.reload() self.reload()
def reload(self): def reload(self):
if self.db is not None: for db in self._dbs.values():
self.db.close() db.close()
self.db = TinyDB(self.uri, indent=2) self._dbs['main'] = TinyDB(self.uri, indent=2)
self._actions = self.db.table("actions") if self.additional_db_dir is not None:
self._alarms = self.db.table("alarms") if self.additional_db_dir.is_dir():
for db_file in self.additional_db_dir.glob('*.db.json'):
name = db_file.name[:-8]
if name == 'main':
self.log.warning("%s db file name is not valid (any other name.db.json would have been ok!", str(db_file.name))
continue
if not name.isalpha():
self.log.warning("%s db file name is not valid: it must be alphabetic only", str(db_file.name))
continue
try:
self._dbs[name] = TinyDB(
str(db_file),
storage=ReadOnlyMiddleware(JSONStorage),
default_table='actions'
)
except ReadOnlyException:
# TinyDB adds the default_table if it is not present at read time.
# This should not happen at all for a ReadOnlyMiddleware db, but at least we can notice it and
# properly signal this to the user.
self.log.error("Could not load db %s: 'actions' table is missing", db_file.name)
continue
def get_action_by_id(self, action_id): self.log.debug('Loaded %d databases: %s', len(self._dbs), ','.join(self._dbs.keys()))
return self._actions.get(eid=action_id)
self._actions = self._dbs['main'].table("actions")
self._alarms = self._dbs['main'].table("alarms")
@staticmethod
def canonicalize(eid_or_aid: Union[str, int]) -> str:
try:
int(eid_or_aid)
except ValueError:
return eid_or_aid
return 'main:%d' % int(eid_or_aid)
@staticmethod
def parse_id(eid_or_aid: Union[str, int]) -> Tuple[str, int]:
try:
int(eid_or_aid)
except ValueError:
pass
else:
return ('main', int(eid_or_aid))
dbname, num = eid_or_aid.split(':')
return (dbname, int(num))
def get_action_by_id(self, action_id: Union[str, int]):
canonical = self.canonicalize(action_id)
db, db_action_id = self.__class__.parse_id(canonical)
out = self._dbs[db].table('actions').get(eid=db_action_id)
out.doc_id = canonical
return out
def get_alarm_by_id(self, alarm_id): def get_alarm_by_id(self, alarm_id):
return self._alarms.get(eid=alarm_id) db, alarm_id = self.__class__.parse_id(alarm_id)
return self._dbs[db].table('alarms').get(eid=alarm_id)
def get_actions_by_alarm(self, alarm): def get_actions_by_alarm(self, alarm):
for action_id in alarm.get("actions", []): for action_id in alarm.get("actions", []):
@ -27,11 +112,21 @@ class EventModel(object):
continue continue
yield action yield action
def get_all_alarms(self): def get_all_alarms(self) -> list:
return self._alarms.all() out = []
for db in self._dbs:
for alarm in self._dbs[db].table('alarms').all():
alarm.doc_id = '%s:%s' % (db, alarm.doc_id)
out.append(alarm)
return out
def get_all_actions(self): def get_all_actions(self) -> list:
return self._actions.all() out = []
for db in self._dbs:
for action in self._dbs[db].table('actions').all():
action.doc_id = '%s:%s' % (db, action.doc_id)
out.append(action)
return out
def get_all_alarms_expanded(self): def get_all_alarms_expanded(self):
for alarm in self.get_all_alarms(): for alarm in self.get_all_alarms():
@ -49,14 +144,18 @@ class EventModel(object):
def add_alarm(self, alarm): def add_alarm(self, alarm):
return self.add_event(alarm, []) return self.add_event(alarm, [])
@only_main
def update_alarm(self, alarmid, new_fields={}): def update_alarm(self, alarmid, new_fields={}):
return self._alarms.update(new_fields, eids=[alarmid]) return self._alarms.update(new_fields, eids=[alarmid])
@only_main
def update_action(self, actionid, new_fields={}): def update_action(self, actionid, new_fields={}):
return self._actions.update(new_fields, eids=[actionid]) return self._actions.update(new_fields, eids=[actionid])
@only_main
def delete_alarm(self, alarmid): def delete_alarm(self, alarmid):
return self._alarms.remove(eids=[alarmid]) return self._alarms.remove(eids=[alarmid])
@only_main
def delete_action(self, actionid): def delete_action(self, actionid):
return self._actions.remove(eids=[actionid]) return self._actions.remove(eids=[actionid])

View file

@ -110,7 +110,7 @@ def addtime():
return render_template("add_time.html", kinds=kinds, info=info) return render_template("add_time.html", kinds=kinds, info=info)
@db.route("/edit/time/<int:alarmid>", methods=["GET", "POST"]) @db.route("/edit/time/<alarmid>", methods=["GET", "POST"])
def edit_time(alarmid): def edit_time(alarmid):
model = get_model() model = get_model()
timespec = model.get_alarm_by_id(alarmid) timespec = model.get_alarm_by_id(alarmid)
@ -124,7 +124,7 @@ def edit_time(alarmid):
model.update_alarm(alarmid, data) model.update_alarm(alarmid, data)
model.reload() model.reload()
return redirect( return redirect(
url_for("db.events_calendar", highlight="%d" % alarmid) url_for("db.events_calendar", highlight=model.canonicalize(alarmid))
) )
return render_template( return render_template(
"add_time_kind.html", "add_time_kind.html",
@ -197,7 +197,7 @@ def addaudio_kind(kind):
) )
@db.route("/edit/audio/<int:actionid>", methods=["GET", "POST"]) @db.route("/edit/audio/<actionid>", methods=["GET", "POST"])
def edit_audio(actionid): def edit_audio(actionid):
model = get_model() model = get_model()
audiospec = model.get_action_by_id(actionid) audiospec = model.get_action_by_id(actionid)
@ -223,7 +223,7 @@ def edit_audio(actionid):
@db.route("/edit/event/<alarmid>") @db.route("/edit/event/<alarmid>")
def edit_event(alarmid): def edit_event(alarmid):
model = current_app.larigira.controller.monitor.model model = current_app.larigira.controller.monitor.model
alarm = model.get_alarm_by_id(int(alarmid)) alarm = model.get_alarm_by_id(alarmid)
if alarm is None: if alarm is None:
abort(404) abort(404)
allactions = model.get_all_actions() allactions = model.get_all_actions()
@ -245,16 +245,16 @@ def change_actions(alarmid):
new_actions = [] new_actions = []
model = current_app.larigira.controller.monitor.model model = current_app.larigira.controller.monitor.model
ret = model.update_alarm( ret = model.update_alarm(
int(alarmid), new_fields={"actions": [int(a) for a in new_actions]} alarmid, new_fields={"actions": [a for a in new_actions]}
) )
return jsonify(dict(updated=alarmid, ret=ret)) return jsonify(dict(updated=alarmid, ret=ret))
@db.route("/api/alarm/<int:alarmid>/delete", methods=["POST"]) @db.route("/api/alarm/<alarmid>/delete", methods=["POST"])
def delete_alarm(alarmid): def delete_alarm(alarmid):
model = current_app.larigira.controller.monitor.model model = current_app.larigira.controller.monitor.model
try: try:
alarm = model.get_alarm_by_id(int(alarmid)) alarm = model.get_alarm_by_id(alarmid)
print(alarm["nick"]) print(alarm["nick"])
model.delete_alarm(alarmid) model.delete_alarm(alarmid)
except KeyError: except KeyError:

View file

@ -42,7 +42,7 @@ class Monitor(ParentedLet):
self.running = {} self.running = {}
self.conf = conf self.conf = conf
self.q = Queue() self.q = Queue()
self.model = EventModel(self.conf["DB_URI"]) self.model = EventModel(self.conf["DB_URI"], self.conf['DB_ADDITIONAL_DIR'])
self.ticker = Timer(int(self.conf["EVENT_TICK_SECS"]) * 1000, self.q) self.ticker = Timer(int(self.conf["EVENT_TICK_SECS"]) * 1000, self.q)
def _alarm_missing_time(self, timespec): def _alarm_missing_time(self, timespec):

View file

@ -8,19 +8,19 @@ from .config import get_conf
def main_list(args): def main_list(args):
m = EventModel(args.file) m = EventModel(args.file, args.additional_dir)
for alarm, action in m.get_all_alarms_expanded(): for alarm, action in m.get_all_alarms_expanded():
json.dump(dict(alarm=alarm, action=action), sys.stdout, indent=4) json.dump(dict(alarm=alarm, action=action), sys.stdout, indent=4)
sys.stdout.write('\n') sys.stdout.write('\n')
def main_getaction(args): def main_getaction(args):
m = EventModel(args.file) m = EventModel(args.file, args.additional_dir)
json.dump(m.get_action_by_id(int(args.actionid)), sys.stdout, indent=4) json.dump(m.get_action_by_id(args.actionid), sys.stdout, indent=4)
def main_add(args): def main_add(args):
m = EventModel(args.file) m = EventModel(args.file, args.additional_dir)
m.add_event( m.add_event(
dict(kind="frequency", interval=args.interval, start=1), dict(kind="frequency", interval=args.interval, start=1),
[dict(kind="mpd", howmany=1)], [dict(kind="mpd", howmany=1)],
@ -29,11 +29,14 @@ def main_add(args):
def main(): def main():
conf = get_conf() conf = get_conf()
p = argparse.ArgumentParser() p = argparse.ArgumentParser(formatter_class=argparse.ArgumentDefaultsHelpFormatter)
p.set_defaults(func=None) p.set_defaults(func=None)
p.add_argument( p.add_argument(
"-f", "--file", help="Filepath for DB", required=False, default=conf["DB_URI"] "-f", "--file", help="Filepath for DB", required=False, default=conf["DB_URI"]
) )
p.add_argument(
"-d", "--additional-dir", help="Filepath for extra DBs", required=False, default=conf["DB_ADDITIONAL_DIR"]
)
sub = p.add_subparsers() sub = p.add_subparsers()
sub_list = sub.add_parser("list") sub_list = sub.add_parser("list")
sub_list.set_defaults(func=main_list) sub_list.set_defaults(func=main_list)

View file

@ -6,6 +6,10 @@ import posixpath
import urllib.request import urllib.request
from tempfile import mkstemp from tempfile import mkstemp
from urllib.parse import urlparse from urllib.parse import urlparse
from pathlib import Path
import hashlib
import requests
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@ -46,6 +50,14 @@ def shortname(path):
return name return name
def http_expected_length(url):
resp = requests.head(url, allow_redirects=True)
resp.raise_for_status()
header_value = resp.headers.get('content-length')
expected_length = int(header_value)
return expected_length
def download_http(url, destdir=None, copy=False, prefix="httpdl"): def download_http(url, destdir=None, copy=False, prefix="httpdl"):
if url.split(":")[0] not in ("http", "https"): if url.split(":")[0] not in ("http", "https"):
log.warning("Not a valid URL: %s", url) log.warning("Not a valid URL: %s", url)
@ -56,15 +68,43 @@ def download_http(url, destdir=None, copy=False, prefix="httpdl"):
return None return None
if not copy: if not copy:
return url return url
if destdir is None:
destdir = os.getenv('TMPDIR', '/tmp/')
fname = posixpath.basename(urlparse(url).path) fname = posixpath.basename(urlparse(url).path)
# sanitize # sanitize
fname = "".join( fname = "".join(
c for c in fname if c.isalnum() or c in list("._-") c for c in fname if c.isalnum() or c in list("_-")
).rstrip() ).rstrip()
url_hash = hashlib.sha1(url.encode('utf8')).hexdigest()
final_path = Path(destdir) / ('%s-%s-%s.%s' % (prefix, fname[:20], url_hash, ext))
# it might be already fully downloaded, let's check
if final_path.exists():
# this "touch" helps avoiding a race condition in which the
# UnusedCleaner could delete this
final_path.touch()
actual_size = final_path.stat().st_size
try:
expected_size = http_expected_length(url)
except Exception as exc:
log.debug("Could not determine expected length for %s: %s", url, exc)
else:
if expected_size == actual_size:
log.debug("File %s already present and complete, download not needed", final_path)
return final_path.as_uri()
else:
log.debug("File %s is already present, but has the wrong length: %d but expected %d", final_path, actual_size, expected_size)
else:
log.debug("File %s does not exist", final_path)
tmp = mkstemp( tmp = mkstemp(
suffix="." + ext, prefix="%s-%s-" % (prefix, fname), dir=destdir suffix="." + ext, prefix="%s-%s-%s-" % (prefix, fname, url_hash), dir=destdir
) )
os.close(tmp[0]) os.close(tmp[0])
log.info("downloading %s -> %s", url, tmp[1]) log.info("downloading %s -> %s -> %s", url, tmp[1], final_path)
fname, headers = urllib.request.urlretrieve(url, tmp[1]) fname, headers = urllib.request.urlretrieve(url, tmp[1])
return "file://%s" % os.path.realpath(tmp[1]) Path(fname).rename(final_path)
return final_path.as_uri()
# "file://%s" % os.path.realpath(final_path)

View file

@ -17,7 +17,7 @@ jQuery(function ($) {
var content = $('<div/>').append( var content = $('<div/>').append(
$('<p/>').append($('<a class="btn btn-default"/>').text('Modifica orario evento').attr('href', 'edit/time/' + alarmid)) $('<p/>').append($('<a class="btn btn-default"/>').text('Modifica orario evento').attr('href', 'edit/time/' + alarmid))
) )
if (Number.isInteger(actions)) { // else, it's a string representing a list if (actions.toString().indexOf(',') === -1) { // single one
content.append($('<p/>').append( content.append($('<p/>').append(
$('<a class="btn btn-default"/>') $('<a class="btn btn-default"/>')
.text('Modifica audio evento') .text('Modifica audio evento')

View file

@ -7,6 +7,8 @@ This component will look for files to be removed. There are some assumptions:
import logging import logging
import os import os
from os.path import normpath from os.path import normpath
from pathlib import Path
import time
import mpd import mpd
@ -30,6 +32,10 @@ except ImportError:
class UnusedCleaner: class UnusedCleaner:
# ONLY_DELETE_OLDER_THAN is expressed in seconds.
# It configures the maximum age a file can have before being removed.
# Set it to "None" if you want to disable this feature.
ONLY_DELETE_OLDER_THAN = 30
def __init__(self, conf): def __init__(self, conf):
self.conf = conf self.conf = conf
self.waiting_removal_files = set() self.waiting_removal_files = set()
@ -69,7 +75,19 @@ class UnusedCleaner:
for song in mpdc.playlistid() for song in mpdc.playlistid()
if song["file"].startswith("/") if song["file"].startswith("/")
} }
now = time.time()
for fpath in self.waiting_removal_files - files_in_playlist: for fpath in self.waiting_removal_files - files_in_playlist:
# audio files are sometimes reused, as in download_http. To avoid
# referencing a file that UnusedCleaner is going to remove, users
# are invited to touch the file, so that UnusedCleaner doesn't
# consider it for removal. While this doesn't conceptually solve
# the race condition, it should now be extremely rare.
if ONLY_DELETE_OLDER_THAN is not None:
mtime = Path(fpath).stat().st_mtime
if now - mtime < ONLY_DELETE_OLDER_THAN:
continue
# we can remove it! # we can remove it!
self.log.debug("removing unused: %s", fpath) self.log.debug("removing unused: %s", fpath)
self.waiting_removal_files.remove(fpath) self.waiting_removal_files.remove(fpath)