From 8f69a767e2e1f71c2166685fb1299dc398d20279 Mon Sep 17 00:00:00 2001 From: Adam Jakab Date: Sun, 8 Mar 2020 19:02:06 +0100 Subject: [PATCH 01/39] config changes --- BEETSDIR/config.yaml | 20 -------------------- 1 file changed, 20 deletions(-) diff --git a/BEETSDIR/config.yaml b/BEETSDIR/config.yaml index b2b0423..2f81249 100644 --- a/BEETSDIR/config.yaml +++ b/BEETSDIR/config.yaml @@ -62,23 +62,3 @@ goingrunning: mood_aggressive+: 50 duration: 60 #target: SONY - 10K: - alias: "Non mollare mai!" - query: - bpm: 155..175 - length: 120..300 - duration: 60 - target: SONY - strides-1k: - # Segments will be implemented in some future version - segments: - - duration: 15 - query: - bpm: 160..180 - length: 120..240 - - duration: 5 - query: - bpm: 100..140 - length: 120..300 - repeat_segments: 5 - From a0a815d04d0b2785ef34088c934e101d61039561 Mon Sep 17 00:00:00 2001 From: Adam Jakab Date: Sun, 8 Mar 2020 19:04:59 +0100 Subject: [PATCH 02/39] bumping up to v1.1.1 --- beetsplug/goingrunning/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/beetsplug/goingrunning/version.py b/beetsplug/goingrunning/version.py index 62dcf13..ab987c5 100644 --- a/beetsplug/goingrunning/version.py +++ b/beetsplug/goingrunning/version.py @@ -5,4 +5,4 @@ # License: See LICENSE.txt # -__version__ = '1.1.0' +__version__ = '1.1.1' From 104c2838359881ba0652bed0d7224464c31b218c Mon Sep 17 00:00:00 2001 From: Adam Jakab Date: Fri, 13 Mar 2020 23:41:20 +0100 Subject: [PATCH 03/39] added file check before copy (stale library item) --- beetsplug/goingrunning/command.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/beetsplug/goingrunning/command.py b/beetsplug/goingrunning/command.py index 90f2320..edc9c14 100644 --- a/beetsplug/goingrunning/command.py +++ b/beetsplug/goingrunning/command.py @@ -216,6 +216,11 @@ def random_string(length=6): cnt = 0 for item in rnd_items: src = os.path.realpath(item.get("path").decode("utf-8")) + if not os.path.isfile(src): + # todo: this is bad enough to interrupt! create option for this + self.log.warning("File does not exist: {}".format(src)) + continue + fn, ext = os.path.splitext(src) gen_filename = "{0}_{1}{2}".format(str(cnt).zfill(6), random_string(), ext) @@ -464,7 +469,7 @@ def _retrieve_library_items(self, training: Subview): for tq in query_items: query.append("{0}:{1}".format(tq, query_items[tq])) - self.log.debug("Song selection query: {}".format(query)) + self._say("Song selection query: {}".format(query)) return self.lib.items(query) From 33211b617485834ef0e75c4b577e80b4d245edf5 Mon Sep 17 00:00:00 2001 From: Adam Jakab Date: Sun, 15 Mar 2020 01:09:17 +0100 Subject: [PATCH 04/39] removed bubble up concept and added special "fallback" training --- BEETSDIR/config.yaml | 20 +++--- beetsplug/goingrunning/command.py | 102 ++++++++++++++---------------- beetsplug/goingrunning/common.py | 32 +++++++--- 3 files changed, 81 insertions(+), 73 deletions(-) diff --git a/BEETSDIR/config.yaml b/BEETSDIR/config.yaml index 2f81249..d8b2e8e 100644 --- a/BEETSDIR/config.yaml +++ b/BEETSDIR/config.yaml @@ -17,20 +17,13 @@ plugins: - goingrunning #format_item: "[format:$format][bpm:$bpm] ::: $path" -format_item: "[bpm:$bpm][length:$length][year:$year] $artist ::: $title" +format_item: "[bpm:$bpm][gender:$gender][year:$year] $artist ::: $title" import: copy: yes autotag: no goingrunning: - query: - bpm: 90..160 - ordering: - year+: 100 - bpm+: 100 - duration: 120 - target: test # todo: this (clean_target) is dangerous here and should be checked ONLY on the target clean_target: no targets: @@ -48,15 +41,20 @@ goingrunning: - STDBDATA.DAT - STDBSTR.DAT trainings: - query: - bpm: 80..140 + fallback: + alias: FALLBACK + query: + bpm: 90..140 + duration: 60 + target: test halfmarathon: - alias: "Born to run----------------------------HALF" + alias: HM query: bpm: 130..150 length: 120..300 year: 1990..2020 ordering: + # todo: get rid of "+" sign in the keys and use -100..100 values for direction year+: 75 bpm+: 50 mood_aggressive+: 50 diff --git a/beetsplug/goingrunning/command.py b/beetsplug/goingrunning/command.py index edc9c14..d1a6821 100644 --- a/beetsplug/goingrunning/command.py +++ b/beetsplug/goingrunning/command.py @@ -19,11 +19,13 @@ from beets.ui import Subcommand, decargs from beets.util.confit import Subview, NotFoundError -# from beets.dbcore.types import Integer, Float -# import pandas as pd - from beetsplug.goingrunning import common as GRC +# The plugin +__PLUGIN_NAME__ = u'goingrunning' +__PLUGIN_SHORT_NAME__ = u'gr' +__PLUGIN_SHORT_DESCRIPTION__ = u'run with the music that matches your training sessions' + class GoingRunningCommand(Subcommand): log = None @@ -60,8 +62,6 @@ def __init__(self, cfg): help=u'Do not delete/copy any songs. Just show what would be done' ) - # @todo: add dry-run option - self.parser.add_option( '-q', '--quiet', action='store_true', dest='quiet', default=False, @@ -71,8 +71,9 @@ def __init__(self, cfg): # Keep this at the end super(GoingRunningCommand, self).__init__( parser=self.parser, - name='goingrunning', - help=u'bring some music with you that matches your training' + name=__PLUGIN_NAME__, + help=__PLUGIN_SHORT_DESCRIPTION__, + aliases=[__PLUGIN_SHORT_NAME__] ) def func(self, lib: BeatsLibrary, options, arguments): @@ -93,8 +94,9 @@ def func(self, lib: BeatsLibrary, options, arguments): if options.list: self.list_trainings() - else: - self.handle_training() + return + + self.handle_training() def handle_training(self): training_name = self.query.pop(0) @@ -130,7 +132,7 @@ def handle_training(self): # 2) select random items n from the ordered list(T=length) - by # chosing n times song from the remaining songs between 1 and m # where m = T/n - duration = GRC.get_config_value_bubble_up(training, "duration") + duration = GRC.get_training_attribute(training, "duration") # sel_items = GRC.get_randomized_items(lib_items, duration) sel_items = self._get_items_for_duration(sorted_lib_items, duration) @@ -167,7 +169,7 @@ def handle_training(self): self._say("Run!") def _clean_target_path(self, training: Subview): - target_name = GRC.get_config_value_bubble_up(training, "target") + target_name = GRC.get_training_attribute(training, "target") if self._get_target_attribute_for_training(training, "clean_target"): dst_path = self._get_destination_path_for_training(training) @@ -205,7 +207,7 @@ def _clean_target_path(self, training: Subview): os.remove(dst_path) def _copy_items_to_target(self, training: Subview, rnd_items): - target_name = GRC.get_config_value_bubble_up(training, "target") + target_name = GRC.get_training_attribute(training, "target") dst_path = self._get_destination_path_for_training(training) self._say("Copying to target[{0}]: {1}".format(target_name, dst_path)) @@ -230,20 +232,17 @@ def random_string(length=6): cnt += 1 def _get_target_for_training(self, training: Subview): - target_name = GRC.get_config_value_bubble_up(training, "target") + target_name = GRC.get_training_attribute(training, "target") self.log.debug("Finding target: {0}".format(target_name)) - target: Subview = self.config["targets"][target_name] - if not target.exists(): - self._say( - "The target name[{0}] is not defined!".format(target_name)) + if not self.config["targets"][target_name].exists(): + self._say("The target name[{0}] is not defined!".format(target_name)) return - return target + return self.config["targets"][target_name] - def _get_target_attribute_for_training(self, training: Subview, - attrib: str = "name"): - target_name = GRC.get_config_value_bubble_up(training, "target") + def _get_target_attribute_for_training(self, training: Subview, attrib: str = "name"): + target_name = GRC.get_training_attribute(training, "target") self.log.debug("Getting attribute[{0}] for target: {1}".format(attrib, target_name)) target = self._get_target_for_training(training) @@ -259,17 +258,15 @@ def _get_target_attribute_for_training(self, training: Subview, except NotFoundError: attrib_val = None else: - attrib_val = GRC.get_config_value_bubble_up(target, attrib) + attrib_val = GRC.get_target_attribute(target, attrib) self.log.debug( - "Found target[{0}] attribute[{1}] path: {2}".format(target_name, - attrib, - attrib_val)) + "Found target[{0}] attribute[{1}] path: {2}".format(target_name, attrib, attrib_val)) return attrib_val def _get_destination_path_for_training(self, training: Subview): - target_name = GRC.get_config_value_bubble_up(training, "target") + target_name = GRC.get_training_attribute(training, "target") root = self._get_target_attribute_for_training(training, "device_root") path = self._get_target_attribute_for_training(training, "device_path") path = path or "" @@ -453,11 +450,9 @@ def _retrieve_library_items(self, training: Subview): query_items = {} # Query defined by the training - # USE: GRC.get_config_value_bubble_up(training, "query") - if training["query"].exists() and len(training["query"].keys()) > 0: - training_query = training["query"].get() - for tq in training_query.keys(): - query_items[tq] = training_query[tq] + training_query = GRC.get_training_attribute(training, "query") + for tq in training_query.keys(): + query_items[tq] = training_query[tq] # Query passed on command line while self.query: @@ -496,36 +491,37 @@ def list_trainings(self): # @todo: order keys :return: void """ - if not self.config["trainings"].exists() or len( - self.config["trainings"].keys()) == 0: + if not self.config["trainings"].exists() or len(self.config["trainings"].keys()) == 0: self._say("You have not created any trainings yet.") return self._say("Available trainings:") - training_names = list(self.config["trainings"].keys()) + trainings = list(self.config["trainings"].keys()) + training_names = [s for s in trainings if s != "fallback"] for training_name in training_names: self.list_training_attributes(training_name) def list_training_attributes(self, training_name: str): - """ - @todo: Explain keys - @todo: "target" is a special case and the value from targets (paths) - should also be shown - :param training_name: - :return: void - """ - target: Subview = self.config["trainings"][training_name] - if target.exists() and isinstance(target.get(), dict): - training_keys = target.keys() - self._say("{0} ::: {1}".format("=" * 40, training_name)) - training_keys = list( - set(GRC.MUST_HAVE_TRAINING_KEYS) | set(training_keys)) - training_keys.sort() - for tkey in training_keys: - tval = GRC.get_config_value_bubble_up(target, tkey) - self._say("{0}: {1}".format(tkey, tval)) - # else: - # self.log.debug("Training[{0}] does not exist.".format(training_name)) + if not self.config["trainings"].exists() or not self.config["trainings"][training_name].exists(): + self.log.debug("Training[{0}] does not exist.".format(training_name)) + return + + training: Subview = self.config["trainings"][training_name] + training_keys = training.keys() + self._say("{0} ::: {1}".format("=" * 40, training_name)) + + training_keys = list(set(GRC.MUST_HAVE_TRAINING_KEYS) | set(training_keys)) + training_keys.sort() + + for tkey in training_keys: + tval = GRC.get_training_attribute(training, tkey) + if isinstance(tval, dict): + value = [] + for k in tval: + value.append("{key}({val})".format(key=k, val=tval[k])) + tval = ", ".join(value) + + self._say("{0}: {1}".format(tkey, tval)) def _say(self, msg): self.log.debug(msg) diff --git a/beetsplug/goingrunning/common.py b/beetsplug/goingrunning/common.py index 43e96aa..8e10bd6 100644 --- a/beetsplug/goingrunning/common.py +++ b/beetsplug/goingrunning/common.py @@ -10,7 +10,8 @@ from beets.util.confit import Subview from beets.random import random_objs -MUST_HAVE_TRAINING_KEYS = ['song_bpm', 'song_len', 'duration', 'target'] +MUST_HAVE_TRAINING_KEYS = ['query', 'duration', 'target'] +MUST_HAVE_TARGET_KEYS = ['device_root', 'device_path'] def get_beets_logger(): @@ -27,18 +28,31 @@ def get_human_readable_time(seconds): return "%d:%02d:%02d" % (h, m, s) +# @deprecated: DO NOT USE THIS NO MORE! def get_config_value_bubble_up(cfg_view: Subview, attrib: str): - """This method will look for the requested attribute in the provided view - all the way up the hierarchy tree until it finds it (or hits the root). + return False + + +def get_training_attribute(training: Subview, attrib: str): + """Returns the attribute value from "goingrunning.trainings" for the specified training or uses the + spacial fallback training configuration. """ value = None + if training[attrib].exists(): + value = training[attrib].get() + elif training.name != "goingrunning.trainings.fallback" and training.parent["fallback"].exists(): + fallback = training.parent["fallback"] + value = get_training_attribute(fallback, attrib) + + return value - if cfg_view[attrib].exists(): - value = cfg_view[attrib].get() - else: - view_name = cfg_view.name - if view_name != "root": - value = get_config_value_bubble_up(cfg_view.parent, attrib) + +def get_target_attribute(target: Subview, attrib: str): + """Returns the attribute value from "goingrunning.targets" for the specified target. + """ + value = None + if target[attrib].exists(): + value = target[attrib].get() return value From 085d8bc633c7f17f5d0caf932030e66cb662ace9 Mon Sep 17 00:00:00 2001 From: Adam Jakab Date: Sun, 15 Mar 2020 01:23:25 +0100 Subject: [PATCH 05/39] ordering: removed +/- signs from key names --- BEETSDIR/config.yaml | 7 +++---- beetsplug/goingrunning/command.py | 12 ++---------- 2 files changed, 5 insertions(+), 14 deletions(-) diff --git a/BEETSDIR/config.yaml b/BEETSDIR/config.yaml index d8b2e8e..efd9465 100644 --- a/BEETSDIR/config.yaml +++ b/BEETSDIR/config.yaml @@ -54,9 +54,8 @@ goingrunning: length: 120..300 year: 1990..2020 ordering: - # todo: get rid of "+" sign in the keys and use -100..100 values for direction - year+: 75 - bpm+: 50 - mood_aggressive+: 50 + year: -100 + #bpm: 50 + #mood_aggressive: 50 duration: 60 #target: SONY diff --git a/beetsplug/goingrunning/command.py b/beetsplug/goingrunning/command.py index d1a6821..26b6fbb 100644 --- a/beetsplug/goingrunning/command.py +++ b/beetsplug/goingrunning/command.py @@ -348,8 +348,7 @@ def _get_min_max_sum_avg_for_items(self, items, field_name): def _score_library_items(self, training: Subview, items): ordering = {} fields = [] - if training["ordering"].exists() and len( - training["ordering"].keys()) > 0: + if training["ordering"].exists() and len(training["ordering"].keys()) > 0: ordering = training["ordering"].get() fields = list(ordering.keys()) @@ -358,17 +357,14 @@ def _score_library_items(self, training: Subview, items): "max": 0.0, "delta": 0.0, "step": 0.0, - "direction": "+", "weight": 100 } # Build Order Info order_info = {} for field in fields: - field_name = field.strip("+-") - field_direction = field.strip(field_name) + field_name = field.strip() order_info[field_name] = default_field_data.copy() - order_info[field_name]["direction"] = field_direction order_info[field_name]["weight"] = ordering[field] # self._say("ORDER INFO #1: {0}".format(order_info)) @@ -400,8 +396,6 @@ def _score_library_items(self, training: Subview, items): field_data["step"] = round(100 / field_data["delta"], 3) # self._say("ORDER INFO: {0}".format(order_info)) - # {'bpm': {'min': 90.0, 'max': 99.0, 'delta': 9.0, 'step': 11.111, - # 'direction': '+', 'weight': 88}, ... # Score the library items for item in items: @@ -431,8 +425,6 @@ def _score_library_items(self, training: Subview, items): weighted_field_score = round( field_data["weight"] * field_score / 100, 3) - if field_data["direction"] == "-": - weighted_field_score *= -1 item["ordering_score"] = round( item["ordering_score"] + weighted_field_score, 3) From 1d26aba02af3666f5aa703b37d7aa755b798521e Mon Sep 17 00:00:00 2001 From: Adam Jakab Date: Mon, 16 Mar 2020 01:20:41 +0100 Subject: [PATCH 06/39] improved library items fetching - queries now support numeric flex attribute searching (like mood_happy) --- .idea/dataSources.xml | 4 +- BEETSDIR/config.yaml | 13 ++++-- beetsplug/goingrunning/command.py | 67 ++++++++++++++++++++----------- beetsplug/goingrunning/common.py | 7 ++++ 4 files changed, 61 insertions(+), 30 deletions(-) diff --git a/.idea/dataSources.xml b/.idea/dataSources.xml index c02ef22..e7dd5c9 100644 --- a/.idea/dataSources.xml +++ b/.idea/dataSources.xml @@ -1,11 +1,11 @@ - + sqlite.xerial true org.sqlite.JDBC - jdbc:sqlite:$PROJECT_DIR$/.coverage + jdbc:sqlite:$USER_HOME$/.config/beets/real_library.db \ No newline at end of file diff --git a/BEETSDIR/config.yaml b/BEETSDIR/config.yaml index efd9465..e367929 100644 --- a/BEETSDIR/config.yaml +++ b/BEETSDIR/config.yaml @@ -17,7 +17,7 @@ plugins: - goingrunning #format_item: "[format:$format][bpm:$bpm] ::: $path" -format_item: "[bpm:$bpm][gender:$gender][year:$year] $artist ::: $title" +format_item: "[MA:$mood_aggressive][bpm:$bpm][gender:$gender][year:$year] $artist ::: $title" import: copy: yes @@ -47,14 +47,19 @@ goingrunning: bpm: 90..140 duration: 60 target: test + test: + query: + artist: "ZZ TOP" + mood_aggressive: 0.4..09 + duration: 30 halfmarathon: alias: HM query: - bpm: 130..150 + bpm: 90..150 length: 120..300 - year: 1990..2020 + mood_aggressive: 0.3..0.7 ordering: - year: -100 + year: 100 #bpm: 50 #mood_aggressive: 50 duration: 60 diff --git a/beetsplug/goingrunning/command.py b/beetsplug/goingrunning/command.py index 26b6fbb..592ef98 100644 --- a/beetsplug/goingrunning/command.py +++ b/beetsplug/goingrunning/command.py @@ -15,7 +15,9 @@ from shutil import copyfile from glob import glob from beets.dbcore.db import Results -from beets.library import Library as BeatsLibrary, Item +from beets.dbcore import query +from beets.dbcore.queryparse import parse_query_part +from beets.library import Library as BeatsLibrary, Item, parse_query_string from beets.ui import Subcommand, decargs from beets.util.confit import Subview, NotFoundError @@ -436,29 +438,46 @@ def _score_library_items(self, training: Subview, items): } def _retrieve_library_items(self, training: Subview): - """Return all items that match the query defined on the training and - additionally on the command line - """ - query_items = {} - - # Query defined by the training - training_query = GRC.get_training_attribute(training, "query") - for tq in training_query.keys(): - query_items[tq] = training_query[tq] - - # Query passed on command line - while self.query: - qel: str = self.query.pop(0) - qk, qv = qel.split(":", maxsplit=1) - query_items[qk] = qv - - query = [] - for tq in query_items: - query.append("{0}:{1}".format(tq, query_items[tq])) - - self._say("Song selection query: {}".format(query)) - - return self.lib.items(query) + full_query = self.query + + # Append the query elements from the configuration + training_config_query = GRC.get_training_attribute(training, "query") + if training_config_query: + for tqk in training_config_query.keys(): + tqv = training_config_query.get(tqk) + quote_val = " " in tqv + fmt = "{k}:'{v}'" if quote_val else "{k}:{v}" + full_query.append(fmt.format(k=tqk, v=tqv)) + + # Separate numeric flex attribute queries from the rest + query_classes = {} + prefixes = {} + flex_numeric_query = [] + other_query = [] + for query_part in full_query: + key = parse_query_part(query_part)[0] + if key in GRC.KNOWN_NUMERIC_FLEX_ATTRIBUTES: + flex_numeric_query.append(query_part) + else: + other_query.append(query_part) + + # Generate NumericQuery classes for numeric flex attribute queries + flex_query_class_items = [] + for query_part in flex_numeric_query: + key, pattern, query_class, negate = parse_query_part(query_part, query_classes, prefixes, + default_class=query.NumericQuery) + # print("(({}))::{}-{}-{}-{}".format(query_part, key, pattern, query_class, negate)) + flex_query_class_items.append(query_class(key.lower(), pattern, False)) + + # Let the other queries be parsed normally + # There is a bug in parse_query_string: It returnd flex numeric attributes as SubstringQuery! + other_query_class_items = parse_query_string(" ".join(other_query), Item)[0] + other_query_class_items = other_query_class_items.subqueries + + combined_query = query.AndQuery(flex_query_class_items + other_query_class_items) + self.log.debug("Song selection query: {}".format(combined_query)) + + return self.lib.items(combined_query) def display_library_items(self, items, fields): fmt = "" diff --git a/beetsplug/goingrunning/common.py b/beetsplug/goingrunning/common.py index 8e10bd6..430221f 100644 --- a/beetsplug/goingrunning/common.py +++ b/beetsplug/goingrunning/common.py @@ -13,6 +13,13 @@ MUST_HAVE_TRAINING_KEYS = ['query', 'duration', 'target'] MUST_HAVE_TARGET_KEYS = ['device_root', 'device_path'] +KNOWN_NUMERIC_FLEX_ATTRIBUTES = ["danceable", "mood_acoustic", "mood_aggressive", "mood_electronic", "mood_happy", + "mood_party", "mood_relaxed", "mood_sad", "tonal", "average_loudness", + "chords_changes_rate", "chords_number_rate", "key_strength"] + +KNOWN_TEXTUAL_FLEX_ATTRIBUTES = ["gender", "genre_rosamerica", "rhythm", "voice_instrumental", "chords_key", + "chords_scale"] + def get_beets_logger(): return logging.getLogger('beets.goingrunning') From 0b747ee2ead57266dde2ffcb98cf192fc3e92037 Mon Sep 17 00:00:00 2001 From: Adam Jakab Date: Mon, 16 Mar 2020 13:23:17 +0100 Subject: [PATCH 07/39] introduced `flavours` section for reusable queries and `use_flavours` key in training sections for multiple flavours --- BEETSDIR/config.yaml | 15 ++++++---- beetsplug/goingrunning/command.py | 50 +++++++++++++++++++++++++------ beetsplug/goingrunning/common.py | 21 ++++++++++++- 3 files changed, 70 insertions(+), 16 deletions(-) diff --git a/BEETSDIR/config.yaml b/BEETSDIR/config.yaml index e367929..5e82a4d 100644 --- a/BEETSDIR/config.yaml +++ b/BEETSDIR/config.yaml @@ -17,7 +17,7 @@ plugins: - goingrunning #format_item: "[format:$format][bpm:$bpm] ::: $path" -format_item: "[MA:$mood_aggressive][bpm:$bpm][gender:$gender][year:$year] $artist ::: $title" +format_item: "[MA:$mood_aggressive][bpm:$bpm][gender:$gender][year:$year][genre:$genre] $artist ::: $title" import: copy: yes @@ -49,8 +49,8 @@ goingrunning: target: test test: query: - artist: "ZZ TOP" - mood_aggressive: 0.4..09 + #artist: "Bob" + use_flavours: [chillout, sunshine] duration: 30 halfmarathon: alias: HM @@ -60,7 +60,10 @@ goingrunning: mood_aggressive: 0.3..0.7 ordering: year: 100 - #bpm: 50 - #mood_aggressive: 50 duration: 60 - #target: SONY + flavours: + sunshine: + ^genre: Reggae + chillout: + bpm: 1..120 + mood_happy: 0.5..0.99 diff --git a/beetsplug/goingrunning/command.py b/beetsplug/goingrunning/command.py index 592ef98..6b77af2 100644 --- a/beetsplug/goingrunning/command.py +++ b/beetsplug/goingrunning/command.py @@ -437,17 +437,49 @@ def _score_library_items(self, training: Subview, items): "wfld_score": weighted_field_score } - def _retrieve_library_items(self, training: Subview): - full_query = self.query + def _gather_query_elements(self, training: Subview): + """Order(strongest to weakest): command -> training -> flavours + """ + command_query = self.query + training_query = [] + flavour_query = [] # Append the query elements from the configuration - training_config_query = GRC.get_training_attribute(training, "query") - if training_config_query: - for tqk in training_config_query.keys(): - tqv = training_config_query.get(tqk) - quote_val = " " in tqv - fmt = "{k}:'{v}'" if quote_val else "{k}:{v}" - full_query.append(fmt.format(k=tqk, v=tqv)) + tconf = GRC.get_training_attribute(training, "query") + if tconf: + for key in tconf.keys(): + training_query.append(GRC.get_beet_query_formatted_string(key, tconf.get(key))) + + # Append the query elements from the flavours defined on the training + flavours = GRC.get_training_attribute(training, "use_flavours") + if flavours: + flavours = [flavours] if type(flavours) == str else flavours + for flavour_name in flavours: + flavour: Subview = self.config["flavours"][flavour_name] + flavour_query += GRC.get_flavour_elements(flavour) + + self.log.debug("Command query elements: {}".format(command_query)) + self.log.debug("Training query elements: {}".format(training_query)) + self.log.debug("Flavour query elements: {}".format(flavour_query)) + + raw_combined_query = command_query + training_query + flavour_query + self.log.debug("Raw combined query elements: {}".format(raw_combined_query)) + + # Remove duplicate keys + combined_query = [] + used_keys = [] + for query_part in raw_combined_query: + key = parse_query_part(query_part)[0] + if key not in used_keys: + used_keys.append(key) + combined_query.append(query_part) + + self.log.debug("Clean combined query elements: {}".format(combined_query)) + + return combined_query + + def _retrieve_library_items(self, training: Subview): + full_query = self._gather_query_elements(training) # Separate numeric flex attribute queries from the rest query_classes = {} diff --git a/beetsplug/goingrunning/common.py b/beetsplug/goingrunning/common.py index 430221f..7d1a1f5 100644 --- a/beetsplug/goingrunning/common.py +++ b/beetsplug/goingrunning/common.py @@ -35,9 +35,28 @@ def get_human_readable_time(seconds): return "%d:%02d:%02d" % (h, m, s) +def get_beet_query_formatted_string(key, val): + quote_val = type(val) == str and " " in val + fmt = "{k}:'{v}'" if quote_val else "{k}:{v}" + return fmt.format(k=key, v=val) + + # @deprecated: DO NOT USE THIS NO MORE! def get_config_value_bubble_up(cfg_view: Subview, attrib: str): - return False + raise DeprecationWarning("Deprecated! Use get_training_attribute!") + + +def get_flavour_elements(flavour: Subview): + elements = [] + + if not flavour.exists(): + return elements + + for key in flavour.keys(): + # todo: in future flavours can have "use_flavours" key to make this recursive + elements.append(get_beet_query_formatted_string(key, flavour[key].get())) + + return elements def get_training_attribute(training: Subview, attrib: str): From b39c6df38ef39157a9896c5cc36ae93e6daa2f56 Mon Sep 17 00:00:00 2001 From: jackisback Date: Mon, 16 Mar 2020 14:06:50 +0100 Subject: [PATCH 08/39] changelog global update --- docs/CHANGELOG.md | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 80f4c2c..1c01735 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,15 +1,25 @@ # CHANGELOG -## 1.1.0 (in development) +## 1.1.1 (in development) ### New features: -- Queries are now compatible with command line queries (and can be overwritten) -- Ordering is now possible on numeric fields -- Cleaning of target device from extra files -- Implemented --dry-run option to show what would be done without execution +- introduced flavour based song selection +- improved library item fetching and filtering - support for numeric flex attributes (such as mood_happy) +- added special "fallback" training +- added file check for stale library items +- advanced ordering based on multi-item scoring system ### Fixes +- removed confusing "bubble up" concept from config/code +## 1.1.0 +### New features: +- Queries are now compatible with command line queries (and can be overwritten) +- Ordering is now possible on numeric fields +- Cleaning of target device from extra files +- Implemented --dry-run option to show what would be done without execution + +### Fixes From cfced6ae22d5dda27778696e073a6a8cfb16a235 Mon Sep 17 00:00:00 2001 From: jackisback Date: Mon, 16 Mar 2020 14:08:23 +0100 Subject: [PATCH 09/39] Fix #8 - cleaning up playlist files --- beetsplug/goingrunning/command.py | 2 +- docs/CHANGELOG.md | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/beetsplug/goingrunning/command.py b/beetsplug/goingrunning/command.py index 6b77af2..7ba3f35 100644 --- a/beetsplug/goingrunning/command.py +++ b/beetsplug/goingrunning/command.py @@ -177,7 +177,7 @@ def _clean_target_path(self, training: Subview): dst_path = self._get_destination_path_for_training(training) self._say("Cleaning target[{0}]: {1}".format(target_name, dst_path)) - song_extensions = ["mp3", "mp4", "flac", "wav"] + song_extensions = ["mp3", "mp4", "flac", "wav", "ogg", "wma", "m3u"] target_file_list = [] for ext in song_extensions: target_file_list += glob( diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 1c01735..73eed85 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -11,6 +11,7 @@ ### Fixes +- now removing .m3u playlist files from device on cleanup - removed confusing "bubble up" concept from config/code From d21ee7c7370de74332155d99088d30ff605d420b Mon Sep 17 00:00:00 2001 From: jackisback Date: Mon, 16 Mar 2020 15:50:46 +0100 Subject: [PATCH 10/39] Updating documentation --- README.md | 52 +++++++++++++++++++++++++++++++++---------------- docs/ROADMAP.md | 37 +++++++++++++++++++---------------- 2 files changed, 55 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index f2de264..4149aa2 100644 --- a/README.md +++ b/README.md @@ -4,24 +4,26 @@ # Going Running (beets plugin) -*A [beets](https://github.com/beetbox/beets) plugin for insane obsessive-compulsive music geeks.* +The *beets-goingrunning* is a [beets](https://github.com/beetbox/beets) plugin for obsessive-compulsive music geek runners. It lets you configure different training activities by filtering songs based on their tag attributes (bpm, length, mood, loudness, etc) and generates a list of songs for that specific training. + +Have you ever tried to beat your PR and have good old Bob singing about ganja in the background? It doesn’t really work. Or don't you know how those recovery session end up with the Crüe kickstarting your heart? You'll be up in your Zone 4 in no time. + +The fact is that it is very difficult and time consuming to compile an appropriate playlist for a specific training session. This plugin tries to help runners with this by allowing them to use their own library. -The *beets-goingrunning* plugin is for obsessive-compulsive music geek runners. It lets you configure different training activities by filtering -songs based on their speed(bpm) and duration (or any other queries) and attempts to generate a list of songs for that training. ## Introduction -To use this plugin at its best and to benefit the most from your library, you will need to make sure that you have -bpm information on all of your songs. Since this plugin uses the bpm information to select songs, the songs with bpm=0 will be ignored (check with `beet ls bpm:0`). If you have many you should update them. There are two ways: +To use this plugin at its best and to benefit the most from your library, you will want to make sure that your songs have the most possible information on rhythm, moods, loudness, etc. + +Without going into much detail the most fundamental information you will want to harvest is `bpm`. Normally, when you run a fast pace training you will keep your pace (the number of times your feet hit the ground in a minute) around 170-180. If you are listening to songs with the same rhythm it helps a lot. If your library has many songs without the bpm information (check with `beet ls bpm:0`) you will not be able to use those songs. So, you should consider updating them. There are many tools you can use: -1) Use the built-in [acousticbrainz plugin](https://beets.readthedocs.io/en/stable/plugins/acousticbrainz.html) to fetch -the bpm information for your songs. It does a lot for well know songs but my library was still 30% uncovered after a full scan. +1) Use the built-in [acousticbrainz plugin](https://beets.readthedocs.io/en/stable/plugins/acousticbrainz.html) to fetch the bpm plus many other information about your songs. This is your starting point. It is as easy as `beet cousticbrainz` and it will do the rest. This tool is based on an on-line database so it will be able to fetch only what has been submitted by someone else. If you have many "uncommon" songs you will need to integrate it with other tools. (My library was still 30% uncovered after a full scan.) -2) Use the [bpmanalyser plugin](https://github.com/adamjakab/BeetsPluginBpmAnalyser). This will scan your songs and calculate -the tempo (bpm) value for them. If you have a big collection it might take a while, but you can potentially end up with -100% coverage. +2) Use the [bpmanalyser plugin](https://github.com/adamjakab/BeetsPluginBpmAnalyser). This will scan your songs and calculate the tempo (bpm) value for them. If you have a big collection it might take a while, but since this tool does not use an on-line database, you can potentially end up with 100% coverage. This plugin will only give you bpm info. -The following explains how to use the *beets-goingrunning* plugin. If something is not clear please use the Issue tracker. Also, if there is a feature not present, please check in the [roadmap](./docs/ROADMAP.md) if it is planned. If not, create a feature request in the Issue tracker. +3) [Essentia extractors](https://essentia.upf.edu/index.html). The Acoustic Brainz (AB) project is based partly on these low and high level extractors. There is currently a highly under-development project [xtractor plugin](https://github.com/adamjakab/BeetsPluginXtractor) which aims to bring your library to 100% coverage. However, for the time being there are no distributable static extractors, so wou will have to compile your own extractors. + +There are many other ways and tools we could list here but I think you got the point... ## Installation @@ -31,12 +33,11 @@ The plugin can be installed via: $ pip install beets-goingrunning ``` -Activate the plugin in your configuration file: +Activate the plugin in your configuration file by adding `goingrunning` to the plugins section: ```yaml plugins: - goingrunning - # [...] ``` Check if plugin is loaded with `beet version`. It should list 'goingrunning' amongst the loaded plugins. @@ -48,19 +49,27 @@ Invoke the plugin as: $ beet goingrunning training [options] [QUERY...] -The following switches are available: +or with the shorthand alias `gr`: + + $ beet gr training [options] [QUERY...] -**--list [-l]**: List all the configured trainings with their attributes. With this switch you do not enter the name of the training, just `beet goingrunning --list` +The following command line options are available: -**--count [-c]**: Count the number of songs available for a specific training. With `beet goingrunning longrun --count` you can see how many of your songs there are in your library that fit your specs. +**--list [-l]**: List all the configured trainings. With `beet goingrunning --list` you will be presented the list of the trainings you have configured in your configuration file. -**--dry-run [-r]**: Only display what would be done without actually making changes to the file system. +**--count [-c]**: Count the number of songs available for a specific training. With `beet goingrunning longrun --count` you can see how many of your songs will fit the specifications for the `longrun` training. + +**--dry-run [-r]**: Only display what would be done without actually making changes to the file system. The plugin will run without clearing the destination and without copying any files. **--quiet [-q]**: Do not display any output from the command. +**--version [-v]**: Display the version number of the plugin. Useful when you need to report some issue and you have to state the version of the plugin you are using. + ## Configuration +suggest external configuration file with include: + Your default configuration is: ```yaml goingrunning: @@ -163,6 +172,15 @@ Do the same as above but today you feel reggae: $ beet goingrunning longrun genre:Reggae +### Issues +If something is not working as expected please use the Issue tracker. +If the documentation is not clear please use the Issue tracker. +If you have a feature request please use the Issue tracker. +In any other situation please use the Issue tracker. + +### Roadmap +Please check the [ROADMAP](./docs/ROADMAP.md) file. If there is a feature you would like to see but which is not planned, create a feature request in the Issue tracker. + ### Final Remarks: diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 395a51e..1c0216e 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,35 +1,38 @@ # TODOS (ROADMAP) -This is a list of things that will be implemented at some point. + +This is an ever-growing list of ideas that will be scheduled for implementation at some point. + ## Short term implementations +These should be easily implemented. -- stats - show statistics about the library - such as number of songs without bpm information -- training info: show total listening time for a specific training +- training info: show full info for a specific training: + - total time + - number of bins + - ... - targets - target definition should include some extra info - just some ideas: ```yaml goingrunning: - # [...] targets: - - - name: SONY-1 - device_path: /media/player_2 - subfolder: MUSIC/AUTO - create_training_folder: yes - clean_destination: yes - create_playlist: yes - delete_extra_files: - - STDBDATA.DAT - # [...] + SONY-1: + create_training_folder: yes + create_playlist: yes ``` -- generate playlist ## Long term implementations +These need some proper planning. +- **possibility to handle multiple sections** inside a training (for interval trainings / strides at different speeds) + - sections can also be repeated - maximize unheard song proposal(optional) by: - incrementing listen count on export - adding it to the query and proposing songs with lower counts - enable song merging and exporting all songs merged into one single file (optional) -- possibility to handle sections inside a training (for interval trainings / strides at different speeds) - - sections can also be repeated - enable audio TTS files to give instructions during training: "Run for 10K at 4:45. RUN!" exporting it as mp3 files and adding it into the song list. + + +## Will not implement +These ideas are kept because they might be valuable for some future development but they will not part of the present plugin + +- stats - show statistics about the library - such as number of songs without bpm information \ No newline at end of file From e349547dcd94ba2d65c70deee74254a2959577ef Mon Sep 17 00:00:00 2001 From: Adam Jakab Date: Mon, 16 Mar 2020 21:22:32 +0100 Subject: [PATCH 11/39] declared FLOAT type for know numeric flex attributes --- BEETSDIR/config.yaml | 2 +- beetsplug/goingrunning/__init__.py | 14 +++++++++++- beetsplug/goingrunning/command.py | 34 ++++-------------------------- setup.py | 2 +- 4 files changed, 19 insertions(+), 33 deletions(-) diff --git a/BEETSDIR/config.yaml b/BEETSDIR/config.yaml index 5e82a4d..a7fc9a8 100644 --- a/BEETSDIR/config.yaml +++ b/BEETSDIR/config.yaml @@ -49,7 +49,7 @@ goingrunning: target: test test: query: - #artist: "Bob" + mood_party: 0.8.. use_flavours: [chillout, sunshine] duration: 30 halfmarathon: diff --git a/beetsplug/goingrunning/__init__.py b/beetsplug/goingrunning/__init__.py index 4100b48..b533b73 100644 --- a/beetsplug/goingrunning/__init__.py +++ b/beetsplug/goingrunning/__init__.py @@ -8,10 +8,11 @@ from beets.plugins import BeetsPlugin -from beets.util import bytestring_path +from beets.dbcore import types from beets.util.confit import ConfigSource, load_yaml from beetsplug.goingrunning.command import GoingRunningCommand +from beetsplug.goingrunning import common as GRC class GoingRunningPlugin(BeetsPlugin): @@ -25,3 +26,14 @@ def __init__(self): def commands(self): return [GoingRunningCommand(self.config)] + + @property + def item_types(self): + """Declare FLOAT types for numeric flex attributes so that query parser will correctly use NumericQuery for them + """ + t = {} + + for attr in GRC.KNOWN_NUMERIC_FLEX_ATTRIBUTES: + t[attr] = types.FLOAT + + return t diff --git a/beetsplug/goingrunning/command.py b/beetsplug/goingrunning/command.py index 7ba3f35..af151af 100644 --- a/beetsplug/goingrunning/command.py +++ b/beetsplug/goingrunning/command.py @@ -15,7 +15,6 @@ from shutil import copyfile from glob import glob from beets.dbcore.db import Results -from beets.dbcore import query from beets.dbcore.queryparse import parse_query_part from beets.library import Library as BeatsLibrary, Item, parse_query_string from beets.ui import Subcommand, decargs @@ -480,36 +479,11 @@ def _gather_query_elements(self, training: Subview): def _retrieve_library_items(self, training: Subview): full_query = self._gather_query_elements(training) + parsed_query = parse_query_string(" ".join(full_query), Item)[0] + self.log.debug("Song selection query: {}".format(parsed_query)) + + return self.lib.items(parsed_query) - # Separate numeric flex attribute queries from the rest - query_classes = {} - prefixes = {} - flex_numeric_query = [] - other_query = [] - for query_part in full_query: - key = parse_query_part(query_part)[0] - if key in GRC.KNOWN_NUMERIC_FLEX_ATTRIBUTES: - flex_numeric_query.append(query_part) - else: - other_query.append(query_part) - - # Generate NumericQuery classes for numeric flex attribute queries - flex_query_class_items = [] - for query_part in flex_numeric_query: - key, pattern, query_class, negate = parse_query_part(query_part, query_classes, prefixes, - default_class=query.NumericQuery) - # print("(({}))::{}-{}-{}-{}".format(query_part, key, pattern, query_class, negate)) - flex_query_class_items.append(query_class(key.lower(), pattern, False)) - - # Let the other queries be parsed normally - # There is a bug in parse_query_string: It returnd flex numeric attributes as SubstringQuery! - other_query_class_items = parse_query_string(" ".join(other_query), Item)[0] - other_query_class_items = other_query_class_items.subqueries - - combined_query = query.AndQuery(flex_query_class_items + other_query_class_items) - self.log.debug("Song selection query: {}".format(combined_query)) - - return self.lib.items(combined_query) def display_library_items(self, items, fields): fmt = "" diff --git a/setup.py b/setup.py index caede55..337a0d9 100644 --- a/setup.py +++ b/setup.py @@ -46,7 +46,7 @@ tests_require=[ 'pytest', 'nose', 'coverage', - 'mock', 'six' + 'mock', 'six', 'yaml' ], classifiers=[ From ce2ca82fced33206c3fd91a9eabcadb89f3067a9 Mon Sep 17 00:00:00 2001 From: Adam Jakab Date: Tue, 17 Mar 2020 08:51:53 +0100 Subject: [PATCH 12/39] added example for multiple sessions in training --- docs/ROADMAP.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 1c0216e..d688657 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -25,6 +25,23 @@ These need some proper planning. - **possibility to handle multiple sections** inside a training (for interval trainings / strides at different speeds) - sections can also be repeated + +example of an interval training with 5 minutes fast and 2.5 minutes recovery repeated 5 times: +```yaml +goingrunning: + trainings: + STRIDES-1K: + use_sections: [fast, recovery] + repeat_sections: 5 + sections: + fast: + use_flavours: [energy, above170] + duration: 300 + recovery: + use_flavours: [chillout, sunshine] + duration: 150 +``` + - maximize unheard song proposal(optional) by: - incrementing listen count on export - adding it to the query and proposing songs with lower counts From 845476e9becf71a0df4bb477fad6e02cb9bd709a Mon Sep 17 00:00:00 2001 From: Adam Jakab Date: Tue, 17 Mar 2020 08:52:14 +0100 Subject: [PATCH 13/39] added version command line option --- beetsplug/goingrunning/command.py | 48 ++++++++++++++++++++----------- 1 file changed, 31 insertions(+), 17 deletions(-) diff --git a/beetsplug/goingrunning/command.py b/beetsplug/goingrunning/command.py index af151af..e9b604b 100644 --- a/beetsplug/goingrunning/command.py +++ b/beetsplug/goingrunning/command.py @@ -16,7 +16,7 @@ from glob import glob from beets.dbcore.db import Results from beets.dbcore.queryparse import parse_query_part -from beets.library import Library as BeatsLibrary, Item, parse_query_string +from beets.library import Library, Item, parse_query_string from beets.ui import Subcommand, decargs from beets.util.confit import Subview, NotFoundError @@ -31,13 +31,13 @@ class GoingRunningCommand(Subcommand): log = None config: Subview = None - lib = None + lib: Library = None query = None - parser = None + parser: OptionParser = None - quiet = False - count_only = False - dry_run = False + cfg_quiet = False + cfg_count = False + cfg_dry_run = False def __init__(self, cfg): self.config = cfg @@ -64,9 +64,15 @@ def __init__(self, cfg): ) self.parser.add_option( - '-q', '--quiet', + '-q', '--cfg_quiet', action='store_true', dest='quiet', default=False, - help=u'keep quiet' + help=u'keep cfg_quiet' + ) + + self.parser.add_option( + '-v', '--version', + action='store_true', dest='version', default=False, + help=u'show plugin version' ) # Keep this at the end @@ -77,28 +83,32 @@ def __init__(self, cfg): aliases=[__PLUGIN_SHORT_NAME__] ) - def func(self, lib: BeatsLibrary, options, arguments): - self.quiet = options.quiet - self.count_only = options.count - self.dry_run = options.dry_run + def func(self, lib: Library, options, arguments): + self.cfg_quiet = options.quiet + self.cfg_count = options.count + self.cfg_dry_run = options.dry_run self.lib = lib self.query = decargs(arguments) # You must either pass a training name or request listing - if len(self.query) < 1 and not options.list: + if len(self.query) < 1 and not (options.list or options.version): self.log.warning( "You can either pass the name of a training or request a " "listing (--list)!") self.parser.print_help() return - if options.list: + if options.version: + self.show_version_information() + return + elif options.list: self.list_trainings() return self.handle_training() + def handle_training(self): training_name = self.query.pop(0) @@ -113,7 +123,7 @@ def handle_training(self): lib_items: Results = self._retrieve_library_items(training) # Show count only - if self.count_only: + if self.cfg_count: self._say("Number of songs available: {}".format(len(lib_items))) return @@ -162,7 +172,7 @@ def handle_training(self): self.display_library_items(sel_items, flds) # todo: move this inside the nex methods to show what would be done - if self.dry_run: + if self.cfg_dry_run: return self._clean_target_path(training) @@ -540,7 +550,11 @@ def list_training_attributes(self, training_name: str): self._say("{0}: {1}".format(tkey, tval)) + def show_version_information(self): + from beetsplug.goingrunning.version import __version__ + self._say("Goingrunning(beets-{}) plugin for Beets: v{}".format(__PLUGIN_NAME__, __version__)) + def _say(self, msg): self.log.debug(msg) - if not self.quiet: + if not self.cfg_quiet: print(msg) From 488f2f3f75f08a9f926a68d5ed25f843e097261e Mon Sep 17 00:00:00 2001 From: Adam Jakab Date: Tue, 17 Mar 2020 10:29:39 +0100 Subject: [PATCH 14/39] changed plugin alias from `gr` to `run` --- beetsplug/goingrunning/command.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/beetsplug/goingrunning/command.py b/beetsplug/goingrunning/command.py index e9b604b..51fc2a6 100644 --- a/beetsplug/goingrunning/command.py +++ b/beetsplug/goingrunning/command.py @@ -24,7 +24,7 @@ # The plugin __PLUGIN_NAME__ = u'goingrunning' -__PLUGIN_SHORT_NAME__ = u'gr' +__PLUGIN_SHORT_NAME__ = u'run' __PLUGIN_SHORT_DESCRIPTION__ = u'run with the music that matches your training sessions' From b3da20566a542187bc4a9c84b6aae2c8e92c1285 Mon Sep 17 00:00:00 2001 From: Adam Jakab Date: Tue, 17 Mar 2020 10:44:41 +0100 Subject: [PATCH 15/39] writing up documentation for 1.1.1 --- BEETSDIR/config.yaml | 12 +- README.md | 160 ++++++++++++---------- beetsplug/goingrunning/config_default.yml | 10 +- 3 files changed, 96 insertions(+), 86 deletions(-) diff --git a/BEETSDIR/config.yaml b/BEETSDIR/config.yaml index a7fc9a8..9b60428 100644 --- a/BEETSDIR/config.yaml +++ b/BEETSDIR/config.yaml @@ -24,18 +24,16 @@ import: autotag: no goingrunning: - # todo: this (clean_target) is dangerous here and should be checked ONLY on the target - clean_target: no targets: test: - device_root: ~/Documents/Projects/Python/BeetsPluginGoingRunning/BEETSDIR/Target1 - device_path: MUSIC/AUTO + device_root: ~/Documents/Projects/Python/BeetsPluginGoingRunning/BEETSDIR/Target1/ + device_path: MUSIC/AUTO/ clean_target: yes delete_from_device: - xyz.txt SONY: - device_root: /Volumes/WALKMAN - device_path: MUSIC/AUTO + device_root: /Volumes/WALKMAN/ + device_path: MUSIC/AUTO/ clean_target: yes delete_from_device: - STDBDATA.DAT @@ -50,7 +48,7 @@ goingrunning: test: query: mood_party: 0.8.. - use_flavours: [chillout, sunshine] + use_flavours: [] duration: 30 halfmarathon: alias: HM diff --git a/README.md b/README.md index 4149aa2..729a654 100644 --- a/README.md +++ b/README.md @@ -49,9 +49,9 @@ Invoke the plugin as: $ beet goingrunning training [options] [QUERY...] -or with the shorthand alias `gr`: +or with the shorthand alias `run`: - $ beet gr training [options] [QUERY...] + $ beet run training [options] [QUERY...] The following command line options are available: @@ -68,93 +68,114 @@ The following command line options are available: ## Configuration -suggest external configuration file with include: +All your configuration will need to be created under the key `goingrunning`. As in my experience the configuration section can grow quite long depending on your needs, I find it quite useful to keep my goingrunning specific configuration in a separate file and from the main configuration file include it like this: -Your default configuration is: ```yaml -goingrunning: - query: - bpm: 90..150 - length: 90..240 - ordering: - year+: 100 - bpm+: 100 - duration: 60 - targets: [] - target: none - clean_target: no +include: + - plg_goingrunning.yaml ``` -There are two concepts you need to know to configure the plugin: targets and trainings: +This is of course optional. + +There are three concepts you need to know to configure the plugin: targets, trainings and flavours. They are explained in detail below. + + +### Targets -- **Targets** are named destinations on your file system to which you will be copying your songs. The `targets` key allows you to define multiple targets whilst the `target` key allows you to specify the name of your default player to which the plugin will always copy your songs (if not otherwise specified in the configuration of a specific training). +Targets are named destinations on your file system to which you will be copying your songs. The `targets` key allows you to define multiple targets so that under a specific training session you will only need to refer to it with the `target` key. + +The configuration of the target names `MPD1` will look like this: ```yaml goingrunning: - # [...] targets: - my_player_1: - device_root: /mnt/player_1 - device_path: - my_other_player: - device_root: /media/player_2 - device_path: Songs - target: my_player_1 - # [...] + MPD1: + device_root: /media/MPD1/ + device_path: MUSIC/AUTO/ + clean_target: yes + delete_from_device: + - LIBRARY.DAT ``` -- **Trainings** are stored named queries into your library. They have two main attributes (`query` and `ordering`) by which the plugin will decide which songs to chose and in what order to put them. The `duration` attribute (expressed in minutes) is used for limiting the number of songs selected. The keys under `query` and `ordering` are the same as you would use them on the command line. A training can optionally declare the `target` and other attributes to override those present at root level (directly under the `goingrunning` key). +The key `device_root` indicates where your operating system mounts the device. The key `device_path` indicates the folder inside the device to which your audio files will be copied. In the above example the final destination is `/media/MPD1/MUSIC/AUTO/`. It is assumed that the folder indicated in the `device_path` key exists. If it doesn't the plugin will exit with a warning. + +The key `clean_target`, when set to yes, instructs the plugin to clean the `device_path` folder before copying the new songs to the device. This will remove all audio songs and playlists found in that folder. + +Some devices might have library files or other data files which need to be deleted in order for the device to reindex the new songs. These files can be added to the `delete_from_device` key. The files listed here are relative to the `device_root` directive. -A common configuration section will look something like this: + +### Trainings + +Trainings are the central concept behind the plugin. When you are "going running" you will already have in mind the type of training you will be doing. This configuration section allows you to preconfigure filters that will allow you to launch a `beet run 10K` command whilst you are tying your shoelaces and be out of the house as quick as possible. In fact, the `trainings` section is there for you to be able to preconfigure these trainings. + +The configuration of a hypothetical 10K training might look like this: ```yaml goingrunning: - # [...] - target: my_player_1 - targets: - my_player_1: - device_root: /mnt/player_1 - device_path: - my_other_player: - device_root: /media/player_2 - device_path: Songs - clean_target: yes - delete_from_device: - - STDBDATA.DAT - - STDBSTR.DAT - trainings: - longrun: - query: - bpm: 90..150 - length: 90..240 - duration: 90 - 10K: - query: - bpm: 150..180 - length: 120..240 - duration: 90 - target: my_other_player - # [...] + trainings: + 10K: + query: + bpm: 160..180 + mood_aggressive: 0.6.. + ^genre: Reggae + ordering: + bpm: 100 + average_loudness: 50 + use_flavours: [] + duration: 60 + target: MPD1 ``` -Once you have configured your targets and created your trainings, connect your device to your pc and launch: +#### query +The keys under the `query` section are exactly the same ones that you use when you are using beets for any other operation. Whatever is described in the [beets query documentation](https://beets.readthedocs.io/en/stable/reference/query.html) applies here with two restriction: you must query specific fields in the form of `field: value` and (for now) regular expressions are not supported. - $ beet goingrunning longrun - -and the songs matching that training will be copied to it. +#### ordering +Your songs are ordered based on a scoring system. What you indicate under the `ordering` section is the fields by which the songs will be ordered and the weight each one of them will have on the final score. The weight can go from -100 to 100. Negative numbers indicate a reverse ordering. (...probably need more explanation?...) + +#### use_flavours +You will find that many of the query specification that you come up with will be repeated across different trainings. To reduce repetition and at the same time to be able to combine many different recipes you can use flavours. Similarly to targets, instead of defining the queries directly on your training you can define queries in a separate section called `flavours` (see below) and then use the `use_flavours` key to indicate which flavours to use. The order in which flavours are indicated is important: the first one has the highest priority meaning that it will overwrite any keys that might be found in subsequent flavours. -The path where the songs will be copied is given by the `device_root` + `device_path`. This means that for `my_player_1` the songs will be copied to the `/mnt/player_1/` folder whilst for the `my_player_2` target they will be copied to the `/media/player_2/Songs/` folder. -The `clean_target` attribute will instruct the plugin to clean these folders before copying the new songs. -The option `delete_from_device` allows you to list additional files that need to be removed. The files listed here are relative to the `device_root` directive. +#### duration +The duration is expressed in minutes and serves the purpose of defining the total length of the training so that the plugin can select the exact number of songs. -For now, within a training the selection of the songs is completely random and no ordering is applied. One of the future plans is to allow you to be more in control of the song selection and song ordering. You can of course use the usual query syntax to fine tune your selection (see examples below) but the ordering will still be casual. +#### target +This key indicates to which target (defined in the `targets` section) your songs will be copied to. -All the configuration options are looked up in the entire configuration tree. This means that whilst the songs for the the `10K` training will be copied to the `my_other_player` target, the `longrun` training (which does not declare this attribute), will use that on the root level: `my_player_1`. This holds for all attributes. -The `clean_target` attribute, when set to `yes` will ensure that all songs are removed from the target before copying the new songs. +#### the `fallback` training +You might also define a special `fallback` training: + +```yaml +goingrunning: + trainings: + fallback: + target: my_other_player +``` + +Any key not defined in a specific training will be looked up from the `fallback` training. So, if in the `10K` example you were to remove the `target` key, it would be looked up from the `fallback` training and your songs would be copied to the `my_other_device` target. + + +### Flavours +The flavours section serves the purpose of defining named queries. If you have 5 different high intensity trainings different in length but sharing queries about bpm, mood and loudness, you can create a single definition, called flavour, here and reuse that flavour in your different trainings with the `use_flavours` key. + +```yaml +goingrunning: + flavours: + overthetop: + bpm: 170.. + mood_aggressive: 0.8.. + average_loudness: 50.. + rocker: + genre: Jazz + sunshine: + genre: Reggae + chillout: + bpm: 1..120 + mood_happy: 0.5..0.99 +``` -### Examples: +### Examples Show all the configured trainings: @@ -172,18 +193,17 @@ Do the same as above but today you feel reggae: $ beet goingrunning longrun genre:Reggae + ### Issues If something is not working as expected please use the Issue tracker. If the documentation is not clear please use the Issue tracker. If you have a feature request please use the Issue tracker. In any other situation please use the Issue tracker. + ### Roadmap Please check the [ROADMAP](./docs/ROADMAP.md) file. If there is a feature you would like to see but which is not planned, create a feature request in the Issue tracker. -### Final Remarks: - -- give feedback -- contribute -- enjoy! +### Final Remarks +Enjoy! diff --git a/beetsplug/goingrunning/config_default.yml b/beetsplug/goingrunning/config_default.yml index 5fbec22..72e6ee8 100644 --- a/beetsplug/goingrunning/config_default.yml +++ b/beetsplug/goingrunning/config_default.yml @@ -1,11 +1,3 @@ -query: - bpm: 90..150 - length: 90..240 -ordering: - year+: 75 - bpm+: 50 - mood_aggressive+: 50 -duration: 60 targets: {} trainings: {} -target: none +flavours: {} From b1631965b6d2c3107da9839e81a3bc8c256818cf Mon Sep 17 00:00:00 2001 From: jackisback Date: Tue, 17 Mar 2020 11:51:24 +0100 Subject: [PATCH 16/39] Updating documentation and roadmap --- README.md | 50 +++++++++++++++++++++++++++---------------------- docs/ROADMAP.md | 1 + 2 files changed, 29 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 729a654..e90e99a 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ # Going Running (beets plugin) -The *beets-goingrunning* is a [beets](https://github.com/beetbox/beets) plugin for obsessive-compulsive music geek runners. It lets you configure different training activities by filtering songs based on their tag attributes (bpm, length, mood, loudness, etc) and generates a list of songs for that specific training. +The *beets-goingrunning* is a [beets](https://github.com/beetbox/beets) plugin for obsessive-compulsive music geek runners. It lets you configure different training activities by filtering songs based on their tag attributes (bpm, length, mood, loudness, etc), generates a list of songs for that specific training and copies those songs to your player device. Have you ever tried to beat your PR and have good old Bob singing about ganja in the background? It doesn’t really work. Or don't you know how those recovery session end up with the Crüe kickstarting your heart? You'll be up in your Zone 4 in no time. @@ -68,16 +68,7 @@ The following command line options are available: ## Configuration -All your configuration will need to be created under the key `goingrunning`. As in my experience the configuration section can grow quite long depending on your needs, I find it quite useful to keep my goingrunning specific configuration in a separate file and from the main configuration file include it like this: - -```yaml -include: - - plg_goingrunning.yaml -``` - -This is of course optional. - -There are three concepts you need to know to configure the plugin: targets, trainings and flavours. They are explained in detail below. +All your configuration will need to be created under the key `goingrunning`. There are three concepts you need to know to configure the plugin: targets, trainings and flavours. They are explained in detail below. ### Targets @@ -156,7 +147,7 @@ Any key not defined in a specific training will be looked up from the `fallback` ### Flavours -The flavours section serves the purpose of defining named queries. If you have 5 different high intensity trainings different in length but sharing queries about bpm, mood and loudness, you can create a single definition, called flavour, here and reuse that flavour in your different trainings with the `use_flavours` key. +The flavours section serves the purpose of defining named queries. If you have 5 different high intensity trainings different in length but sharing queries about bpm, mood and loudness, you can create a single definition here, called flavour, and reuse that flavour in your different trainings with the `use_flavours` key. ```yaml goingrunning: @@ -166,32 +157,47 @@ goingrunning: mood_aggressive: 0.8.. average_loudness: 50.. rocker: - genre: Jazz - sunshine: + genre: Rock + metallic: + genre: Metal + sunshine: genre: Reggae + 60s: + year: 1960..1969 chillout: bpm: 1..120 mood_happy: 0.5..0.99 ``` - +This way, from the above flavours you might add `use_flavours: [overthetop, rock, 60s]` to one training and `use_flavours: [overthetop, metallic]` to another so they will share the same `overthetop` intensity definition whilst having different genre preferences. Similarly, your recovery session might use `use_flavours: [chillout, sunshine]`. + + +#### Using a separate configuration file +In my experience the configuration section can grow quite long depending on your needs, so I find it useful to keep my `goingrunning` specific configuration in a separate file and from the main configuration file include it like this: + +```yaml +include: + - plg_goingrunning.yaml +``` + + ### Examples Show all the configured trainings: $ beet goingrunning --list -Check what the `longrun` training would do: +Check what would be done for the `10K` training: - $ beet goingrunning longrun --dry-run + $ beet goingrunning 10K --dry-run -Now do it! Copy your songs to your target based on the `longrun` training: +Let's go! Copy your songs to your target based on the `10K` training and using the plugin shorthand: - $ beet goingrunning longrun - -Do the same as above but today you feel reggae: + $ beet run longrun + s +Do the same as above but today you feel Ska: - $ beet goingrunning longrun genre:Reggae + $ beet run longrun genre:ska ### Issues diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index d688657..f764fd3 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -6,6 +6,7 @@ This is an ever-growing list of ideas that will be scheduled for implementation ## Short term implementations These should be easily implemented. +- allow passing `flavour` names on command line - training info: show full info for a specific training: - total time - number of bins From 12be3f039678ca15f389b589d5a09f6f30c07ad5 Mon Sep 17 00:00:00 2001 From: Adam Jakab Date: Tue, 17 Mar 2020 12:39:51 +0100 Subject: [PATCH 17/39] added warning for old configuration --- README.md | 10 ++++----- beetsplug/goingrunning/command.py | 34 ++++++++++++++++++++++++++++++- 2 files changed, 38 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index e90e99a..6f4cb59 100644 --- a/README.md +++ b/README.md @@ -172,7 +172,7 @@ goingrunning: This way, from the above flavours you might add `use_flavours: [overthetop, rock, 60s]` to one training and `use_flavours: [overthetop, metallic]` to another so they will share the same `overthetop` intensity definition whilst having different genre preferences. Similarly, your recovery session might use `use_flavours: [chillout, sunshine]`. -#### Using a separate configuration file +### Using a separate configuration file In my experience the configuration section can grow quite long depending on your needs, so I find it useful to keep my `goingrunning` specific configuration in a separate file and from the main configuration file include it like this: ```yaml @@ -181,7 +181,7 @@ include: ``` -### Examples +## Examples Show all the configured trainings: @@ -200,16 +200,16 @@ Do the same as above but today you feel Ska: $ beet run longrun genre:ska -### Issues +## Issues If something is not working as expected please use the Issue tracker. If the documentation is not clear please use the Issue tracker. If you have a feature request please use the Issue tracker. In any other situation please use the Issue tracker. -### Roadmap +## Roadmap Please check the [ROADMAP](./docs/ROADMAP.md) file. If there is a feature you would like to see but which is not planned, create a feature request in the Issue tracker. -### Final Remarks +## Final Remarks Enjoy! diff --git a/beetsplug/goingrunning/command.py b/beetsplug/goingrunning/command.py index 51fc2a6..27d60b2 100644 --- a/beetsplug/goingrunning/command.py +++ b/beetsplug/goingrunning/command.py @@ -91,6 +91,27 @@ def func(self, lib: Library, options, arguments): self.lib = lib self.query = decargs(arguments) + # TEMPORARY: Verify configuration upgrade! + # There is a major backward incompatible upgrade in version 1.1.1 + try: + self.verify_configuration_upgrade() + except RuntimeError as e: + self._say("*" * 80) + self._say("******************** INCOMPATIBLE PLUGIN CONFIGURATION *********************") + self._say("*" * 80) + self._say("* Your configuration has been created for an older version of the plugin.") + self._say("* Since version 1.1.1 the plugin has implemented changes that require your " + "current configuration to be updated.") + self._say("* Please read the updated documentation here and update your configuration.") + self._say( + "* Documentation: https://github.com/adamjakab/BeetsPluginGoingRunning/blob/master/README.md" + "#configuration") + self._say("* I promise it will not happen again ;)") + self._say("* " + str(e)) + self._say("* The plugin will exit now.") + self._say("*" * 80) + return + # You must either pass a training name or request listing if len(self.query) < 1 and not (options.list or options.version): self.log.warning( @@ -513,10 +534,21 @@ def display_library_items(self, items, fields): except IndexError: pass + def verify_configuration_upgrade(self): + """Check if user has old(pre v1.1.1) configuration keys in config + """ + trainings = list(self.config["trainings"].keys()) + training_names = [s for s in trainings if s != "fallback"] + for training_name in training_names: + training: Subview = self.config["trainings"][training_name] + tkeys = training.keys() + for tkey in tkeys: + if tkey in ["song_bpm", "song_len"]: + raise RuntimeError("Offending key in training({}): {}".format(training_name, tkey)) + def list_trainings(self): """ # @todo: order keys - :return: void """ if not self.config["trainings"].exists() or len(self.config["trainings"].keys()) == 0: self._say("You have not created any trainings yet.") From ca7b029398c80b9b082a5bc2b4442eafe44e7d9b Mon Sep 17 00:00:00 2001 From: Adam Jakab Date: Tue, 17 Mar 2020 18:58:22 +0100 Subject: [PATCH 18/39] separated unit and functional tests --- beetsplug/goingrunning/common.py | 27 ----- setup.cfg | 9 +- test/{ => _old}/01_common_module_test.py | 2 +- test/{ => _old}/02_configuration_test.py | 12 +- test/{ => _old}/03_basic_command_test.py | 0 .../000_basic_check_test.py} | 4 +- test/functional/__init__.py | 0 test/helper.py | 101 ++++++++++++----- test/unit/000_common_test.py | 107 ++++++++++++++++++ test/unit/__init__.py | 0 tox.ini | 2 - 11 files changed, 195 insertions(+), 69 deletions(-) rename test/{ => _old}/01_common_module_test.py (97%) rename test/{ => _old}/02_configuration_test.py (97%) rename test/{ => _old}/03_basic_command_test.py (100%) rename test/{00_completion_test.py => functional/000_basic_check_test.py} (87%) create mode 100644 test/functional/__init__.py create mode 100644 test/unit/000_common_test.py create mode 100644 test/unit/__init__.py diff --git a/beetsplug/goingrunning/common.py b/beetsplug/goingrunning/common.py index 7d1a1f5..76dc53c 100644 --- a/beetsplug/goingrunning/common.py +++ b/beetsplug/goingrunning/common.py @@ -6,7 +6,6 @@ # import logging -from beets import config as beets_global_config from beets.util.confit import Subview from beets.random import random_objs @@ -24,28 +23,16 @@ def get_beets_logger(): return logging.getLogger('beets.goingrunning') - -def get_beets_global_config(): - return beets_global_config - - def get_human_readable_time(seconds): m, s = divmod(seconds, 60) h, m = divmod(m, 60) return "%d:%02d:%02d" % (h, m, s) - def get_beet_query_formatted_string(key, val): quote_val = type(val) == str and " " in val fmt = "{k}:'{v}'" if quote_val else "{k}:{v}" return fmt.format(k=key, v=val) - -# @deprecated: DO NOT USE THIS NO MORE! -def get_config_value_bubble_up(cfg_view: Subview, attrib: str): - raise DeprecationWarning("Deprecated! Use get_training_attribute!") - - def get_flavour_elements(flavour: Subview): elements = [] @@ -101,17 +88,3 @@ def get_duration_of_items(items): pass return total_time - - -def get_randomized_items(items, duration_min): - """ This randomization and limiting to duration_min is very basic - @todo: after randomization select songs to be as close as possible to the - duration_min (+-5seconds) - """ - r_limit = 1 - r_time_minutes = duration_min - r_equal_chance = True - rnd_items = random_objs(list(items), False, r_limit, r_time_minutes, - r_equal_chance) - - return rnd_items diff --git a/setup.cfg b/setup.cfg index 213582a..d9b55b4 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,8 +1,9 @@ [nosetests] verbosity=1 with-coverage=1 -cover-erase=1 -cover-package=beetsplug -cover-html=1 +cover-package = beetsplug +cover-erase = 1 +cover-html = 1 cover-html-dir=coverage -logging-clear-handlers=1 +logging-clear-handlers = 1 +process-timeout = 30 diff --git a/test/01_common_module_test.py b/test/_old/01_common_module_test.py similarity index 97% rename from test/01_common_module_test.py rename to test/_old/01_common_module_test.py index 3e0bcba..c9f07a2 100644 --- a/test/01_common_module_test.py +++ b/test/_old/01_common_module_test.py @@ -19,7 +19,7 @@ class CommonModuleTest(TestHelper, Assertions): def test_must_have_training_keys(self): - must_have_keys = ['song_bpm', 'song_len', 'duration', 'target'] + must_have_keys = ['query', 'duration', 'target'] for key in must_have_keys: self.assertIn(key, GRC.MUST_HAVE_TRAINING_KEYS, msg=u'Missing default training key: {0}'.format(key)) diff --git a/test/02_configuration_test.py b/test/_old/02_configuration_test.py similarity index 97% rename from test/02_configuration_test.py rename to test/_old/02_configuration_test.py index cccc68c..eef50c8 100644 --- a/test/02_configuration_test.py +++ b/test/_old/02_configuration_test.py @@ -10,7 +10,7 @@ from test.helper import TestHelper, Assertions, PLUGIN_NAME, capture_stdout from beetsplug.goingrunning.command import GoingRunningCommand from beetsplug.goingrunning import common as GoingRunningCommon - +import unittest class ConfigurationTest(TestHelper, Assertions): @@ -26,8 +26,7 @@ def test_plugin_default_config_keys(self): cfg: Subview = self.config[PLUGIN_NAME] cfg_keys = cfg.keys() cfg_keys.sort() - def_keys = ['duration', 'targets', 'trainings', 'target', 'query', - 'ordering'] + def_keys = ['trainings', 'targets', 'flavours'] def_keys.sort() self.assertEqual(def_keys, cfg_keys) # This has been removed @@ -41,8 +40,7 @@ def test_user_config_main(self): # Check keys cfg_keys = cfg.keys() cfg_keys.sort() - chk_keys = ['duration', 'targets', 'target', 'query', 'ordering', - 'trainings'] + chk_keys = ['duration', 'targets', 'target', 'query', 'ordering', 'trainings'] chk_keys.sort() self.assertEqual(chk_keys, cfg_keys) @@ -164,6 +162,7 @@ def test_method_list_training_attributes(self): # self.assertIn("song_len: [90, 180]", out.getvalue()) self.assertIn("target: drive_3", out.getvalue()) + @unittest.skip("Deprecated! Needs removal") def test_bubble_up(self): """ Check values when each level has its own """ self.reset_beets(config_file=b"config_user.yml") @@ -185,6 +184,7 @@ def test_bubble_up(self): GoingRunningCommon.get_config_value_bubble_up( cfg_l1, attrib)) + @unittest.skip("Deprecated! Needs removal") def test_bubble_up_inexistent_key(self): """ Check values when each level has its own """ self.reset_beets(config_file=b"config_user.yml") @@ -192,6 +192,7 @@ def test_bubble_up_inexistent_key(self): inexistent_key = "you_will_never_find_me" self.assertEqual(None, GoingRunningCommon.get_config_value_bubble_up(cfg, inexistent_key)) + @unittest.skip("Deprecated! Needs removal") def test_bubble_up_no_level_3(self): """ Check that values are taken from level 2 if they are not present on level 3 """ @@ -206,6 +207,7 @@ def test_bubble_up_no_level_3(self): GoingRunningCommon.get_config_value_bubble_up( cfg_l3, attrib)) + @unittest.skip("Deprecated! Needs removal") def test_bubble_up_no_level_3_or_2(self): """ Check that values are taken from level 1 if they are not present on level 3 or 2 """ diff --git a/test/03_basic_command_test.py b/test/_old/03_basic_command_test.py similarity index 100% rename from test/03_basic_command_test.py rename to test/_old/03_basic_command_test.py diff --git a/test/00_completion_test.py b/test/functional/000_basic_check_test.py similarity index 87% rename from test/00_completion_test.py rename to test/functional/000_basic_check_test.py index 0779a98..1008dce 100644 --- a/test/00_completion_test.py +++ b/test/functional/000_basic_check_test.py @@ -8,8 +8,8 @@ from test.helper import TestHelper, Assertions, PLUGIN_NAME, PLUGIN_SHORT_DESCRIPTION, capture_log, capture_stdout -class CompletionTest(TestHelper, Assertions): - """Test invocation of ``beet goingrunning`` with this plugin. +class BasicCheckTest(TestHelper, Assertions): + """Test presence and invocation of the plugin. Only ensures that command does not fail. """ diff --git a/test/functional/__init__.py b/test/functional/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/test/helper.py b/test/helper.py index 0f93c22..943f286 100644 --- a/test/helper.py +++ b/test/helper.py @@ -30,14 +30,14 @@ bytestring_path, displayable_path, ) -from beets.util.confit import Subview, Dumper +from beets.util.confit import Subview, Dumper, LazyConfig, ConfigSource from six import StringIO from beetsplug import goingrunning # Values -PLUGIN_NAME = 'goingrunning' -PLUGIN_SHORT_DESCRIPTION = 'bring some music with you that matches your training' +PLUGIN_NAME = u'goingrunning' +PLUGIN_SHORT_DESCRIPTION = u'run with the music that matches your training sessions' class LogCapture(logging.Handler): @@ -104,6 +104,14 @@ def control_stdin(userinput=None): sys.stdin = org +def get_plugin_configuration(cfg): + """Creates and returns a configuration from a dict to play around with""" + config = LazyConfig("unittest") + cfg = {PLUGIN_NAME: cfg} + config.add(ConfigSource(cfg)) + return config[PLUGIN_NAME] + + def _convert_args(args): """Convert args to strings """ @@ -124,6 +132,7 @@ class TestHelper(TestCase, Assertions): _test_config_dir_ = os.path.join(bytestring_path(os.path.dirname(__file__)), b'config') _test_fixture_dir = os.path.join(bytestring_path(os.path.dirname(__file__)), b'fixtures') _test_target_dir = bytestring_path("/tmp/beets-goingrunning-test-drive") + __item_count = 0 def setUp(self): """Setup before running any tests. @@ -250,31 +259,67 @@ def get_fixture_item_path(self, ext): assert (ext in 'mp3 m4a ogg'.split()) return os.path.join(self._test_fixture_dir, bytestring_path('song.' + ext.lower())) - def add_multiple_items_to_library(self, count=10, song_bpm=None, song_length=None, **kwargs): - if song_bpm is None: - song_bpm = [60, 220] - if song_length is None: - song_length = [15, 300] - for i in range(count): - bpm = randint(song_bpm[0], song_bpm[1]) - length = randint(song_length[0], song_length[1]) - self.add_single_item_to_library(bpm=bpm, length=length, **kwargs) - - def add_single_item_to_library(self, **kwargs): - values = { - 'title': 'track 1', - 'artist': 'artist 1', - 'album': 'album 1', - 'bpm': randint(120, 180), - 'length': randint(90, 720), - 'format': 'mp3', + def create_item(self, **values): + """Return an `Item` instance with sensible default values. + + The item receives its attributes from `**values` paratmeter. The + `title`, `artist`, `album`, `track`, `format` and `path` + attributes have defaults if they are not given as parameters. + The `title` attribute is formated with a running item count to + prevent duplicates. The default for the `path` attribute + respects the `format` value. + + The item is attached to the database from `self.lib`. + """ + item_count = self._get_item_count() + values_ = { + 'title': u't\u00eftle {0}', + 'artist': u'the \u00e4rtist', + 'album': u'the \u00e4lbum', + 'track': item_count, + 'format': 'MP3', } - values.update(kwargs) - item = Item.from_path(self.get_fixture_item_path(values.pop('format'))) - item.update(values) - item.add(self.lib) - item.move(MoveOperation.COPY) - item.write() - return item + values_.update(values) + + values_['title'] = values_['title'].format(item_count) + values_['db'] = self.lib + item = Item(**values_) + + if 'path' not in values: + item['path'] = 'audio.' + item['format'].lower() + # mtime needs to be set last since other assignments reset it. + item.mtime = 12345 + + return item + def _get_item_count(self): + self.__item_count += 1 + return self.__item_count + + # def add_multiple_items_to_library(self, count=10, song_bpm=None, song_length=None, **kwargs): + # if song_bpm is None: + # song_bpm = [60, 220] + # if song_length is None: + # song_length = [15, 300] + # for i in range(count): + # bpm = randint(song_bpm[0], song_bpm[1]) + # length = randint(song_length[0], song_length[1]) + # self.add_single_item_to_library(bpm=bpm, length=length, **kwargs) + # + # def add_single_item_to_library(self, **kwargs): + # values = { + # 'title': 'track 1', + # 'artist': 'artist 1', + # 'album': 'album 1', + # 'bpm': randint(120, 180), + # 'length': randint(90, 720), + # 'format': 'mp3', + # } + # values.update(kwargs) + # item = Item.from_path(self.get_fixture_item_path(values.pop('format'))) + # item.update(values) + # item.add(self.lib) + # item.move(MoveOperation.COPY) + # item.write() + # return item diff --git a/test/unit/000_common_test.py b/test/unit/000_common_test.py new file mode 100644 index 0000000..7e4e84c --- /dev/null +++ b/test/unit/000_common_test.py @@ -0,0 +1,107 @@ +# Copyright: Copyright (c) 2020., Adam Jakab +# +# Author: Adam Jakab +# Created: 3/17/20, 3:28 PM +# License: See LICENSE.txt +# +from beets.library import Item +from beets.util.confit import Subview, ConfigView, LazyConfig, ConfigSource + +from test.helper import TestHelper, Assertions, get_plugin_configuration +from beetsplug.goingrunning import common + +from logging import Logger + + +class CommonTest(TestHelper, Assertions): + """Test presence and invocation of the plugin. + Only ensures that command does not fail. + """ + + def test_module_values(self): + self.assertTrue(hasattr(common, "MUST_HAVE_TRAINING_KEYS")) + self.assertTrue(hasattr(common, "MUST_HAVE_TARGET_KEYS")) + self.assertTrue(hasattr(common, "KNOWN_NUMERIC_FLEX_ATTRIBUTES")) + self.assertTrue(hasattr(common, "KNOWN_TEXTUAL_FLEX_ATTRIBUTES")) + + def test_get_beets_logger(self): + self.assertIsInstance(common.get_beets_logger(), Logger) + + def test_get_beets_global_config(self): + self.assertEqual("0:00:00", common.get_human_readable_time(0), "Bad time format!") + self.assertEqual("0:00:33", common.get_human_readable_time(33), "Bad time format!") + self.assertEqual("0:33:33", common.get_human_readable_time(2013), "Bad time format!") + self.assertEqual("3:33:33", common.get_human_readable_time(12813), "Bad time format!") + + def test_get_beet_query_formatted_string(self): + self.assertEqual("x:y", common.get_beet_query_formatted_string("x", "y")) + self.assertEqual("x:'y z'", common.get_beet_query_formatted_string("x", "y z")) + self.assertEqual("x:1", common.get_beet_query_formatted_string("x", 1)) + + def test_get_flavour_elements(self): + cfg = { + "flavours": { + "speedy": { + "bpm": "180..", + "genre": "Hard Rock", + } + } + } + config = get_plugin_configuration(cfg) + self.assertListEqual(["bpm:180..", "genre:'Hard Rock'"], + common.get_flavour_elements(config["flavours"]["speedy"])) + self.assertListEqual([], common.get_flavour_elements(config["flavours"]["not_there"])) + + def test_get_training_attribute(self): + cfg = { + "trainings": { + "fallback": { + "bpm": "120..", + "target": "MPD1", + }, + "10K": { + "bpm": "180..", + "use_flavours": ["f1", "f2"], + } + } + } + config = get_plugin_configuration(cfg) + training: Subview = config["trainings"]["10K"] + + # Direct + self.assertEqual("180..", common.get_training_attribute(training, "bpm")) + self.assertEqual(["f1", "f2"], common.get_training_attribute(training, "use_flavours")) + + # Fallback + self.assertEqual("MPD1", common.get_training_attribute(training, "target")) + + # Inexistent + self.assertEqual(None, common.get_training_attribute(training, "hoppa")) + + def test_get_target_attribute(self): + cfg = { + "targets": { + "MPD1": { + "device_root": "/media/mpd1/", + "device_path": "auto/", + } + } + } + config = get_plugin_configuration(cfg) + target: Subview = config["targets"]["MPD1"] + + self.assertEqual("/media/mpd1/", common.get_target_attribute(target, "device_root")) + self.assertEqual("auto/", common.get_target_attribute(target, "device_path")) + self.assertEqual(None, common.get_target_attribute(target, "not_there")) + + def test_get_duration_of_items(self): + items = [self.create_item(length=120), self.create_item(length=79)] + self.assertEqual(199, common.get_duration_of_items(items)) + + # ValueError + baditem = self.create_item(length="") + self.assertEqual(0, common.get_duration_of_items([baditem])) + + # TypeError + baditem = self.create_item(length={}) + self.assertEqual(0, common.get_duration_of_items([baditem])) diff --git a/test/unit/__init__.py b/test/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tox.ini b/tox.ini index 773e211..2a132d7 100644 --- a/tox.ini +++ b/tox.ini @@ -7,8 +7,6 @@ basepython = py37: python3.7 py38: python3.8 deps = -; pytest -; pytest-cov nose mock coverage From 77a29e6078c2b3ff793f12e6724cf4cf69e3b892 Mon Sep 17 00:00:00 2001 From: Adam Jakab Date: Tue, 17 Mar 2020 19:07:42 +0100 Subject: [PATCH 19/39] added some very basic functional tests --- beetsplug/goingrunning/command.py | 2 +- test/functional/000_basic_check_test.py | 9 ++++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/beetsplug/goingrunning/command.py b/beetsplug/goingrunning/command.py index 27d60b2..5715905 100644 --- a/beetsplug/goingrunning/command.py +++ b/beetsplug/goingrunning/command.py @@ -43,7 +43,7 @@ def __init__(self, cfg): self.config = cfg self.log = GRC.get_beets_logger() - self.parser = OptionParser(usage='%prog training [options] [QUERY...]') + self.parser = OptionParser(usage='beet goingrunning [training] [options] [QUERY...]') self.parser.add_option( '-l', '--list', diff --git a/test/functional/000_basic_check_test.py b/test/functional/000_basic_check_test.py index 1008dce..0049561 100644 --- a/test/functional/000_basic_check_test.py +++ b/test/functional/000_basic_check_test.py @@ -20,11 +20,14 @@ def test_application(self): self.assertIn(PLUGIN_NAME, out.getvalue()) self.assertIn(PLUGIN_SHORT_DESCRIPTION, out.getvalue()) - def test_application_plugin_list(self): + def test_application_version(self): with capture_stdout() as out: self.runcli("version") self.assertIn("plugins: {0}".format(PLUGIN_NAME), out.getvalue()) - def test_plugin(self): - self.runcli(PLUGIN_NAME) + def test_plugin_no_arguments(self): + with capture_stdout() as out: + self.runcli(PLUGIN_NAME) + + self.assertIn("Usage: beet goingrunning [training] [options] [QUERY...]", out.getvalue()) From 4a6c5ece088dd1ec760bf31fe169493161072a2e Mon Sep 17 00:00:00 2001 From: Adam Jakab Date: Tue, 17 Mar 2020 19:46:41 +0100 Subject: [PATCH 20/39] unit tests completed --- beetsplug/goingrunning/command.py | 39 +++----------------------- beetsplug/goingrunning/common.py | 39 ++++++++++++++++++++++++-- test/unit/000_common_test.py | 46 +++++++++++++++++++++++++++---- test/unit/001_command_test.py | 37 +++++++++++++++++++++++++ 4 files changed, 117 insertions(+), 44 deletions(-) create mode 100644 test/unit/001_command_test.py diff --git a/beetsplug/goingrunning/command.py b/beetsplug/goingrunning/command.py index 5715905..f9c3f61 100644 --- a/beetsplug/goingrunning/command.py +++ b/beetsplug/goingrunning/command.py @@ -327,8 +327,8 @@ def _get_destination_path_for_training(self, training: Subview): def _get_items_for_duration(self, items, duration): selected = [] total_time = 0 - _min, _max, _sum, _avg = self._get_min_max_sum_avg_for_items(items, - "length") + _min, _max, _sum, _avg = GRC.get_min_max_sum_avg_for_items(items, + "length") est_num_songs = round(duration * 60 / _avg) bin_size = len(items) / est_num_songs @@ -346,37 +346,6 @@ def _get_items_for_duration(self, items, duration): return selected - def _get_min_max_sum_avg_for_items(self, items, field_name): - _min = 99999999.9 - _max = 0 - _sum = 0 - _avg = 0 - for item in items: - item: Item - try: - field_value = round(float(item.get(field_name, None)), 3) - except ValueError: - field_value = None - except TypeError: - field_value = None - - # Min - if field_value is not None and field_value < _min: - _min = field_value - - # Max - if field_value is not None and field_value > _max: - _max = field_value - - # Sum - if field_value is not None: - _sum = _sum + field_value - - # Avg - _avg = round(_sum / len(items), 3) - - return _min, _max, _sum, _avg - def _score_library_items(self, training: Subview, items): ordering = {} fields = [] @@ -404,8 +373,8 @@ def _score_library_items(self, training: Subview, items): # Populate Order Info for field_name in order_info.keys(): field_data = order_info[field_name] - _min, _max, _sum, _avg = self._get_min_max_sum_avg_for_items(items, - field_name) + _min, _max, _sum, _avg = GRC.get_min_max_sum_avg_for_items(items, + field_name) field_data["min"] = _min field_data["max"] = _max diff --git a/beetsplug/goingrunning/common.py b/beetsplug/goingrunning/common.py index 76dc53c..2e2b3fd 100644 --- a/beetsplug/goingrunning/common.py +++ b/beetsplug/goingrunning/common.py @@ -6,6 +6,8 @@ # import logging + +from beets.library import Item from beets.util.confit import Subview from beets.random import random_objs @@ -45,7 +47,6 @@ def get_flavour_elements(flavour: Subview): return elements - def get_training_attribute(training: Subview, attrib: str): """Returns the attribute value from "goingrunning.trainings" for the specified training or uses the spacial fallback training configuration. @@ -59,7 +60,6 @@ def get_training_attribute(training: Subview, attrib: str): return value - def get_target_attribute(target: Subview, attrib: str): """Returns the attribute value from "goingrunning.targets" for the specified target. """ @@ -69,7 +69,6 @@ def get_target_attribute(target: Subview, attrib: str): return value - def get_duration_of_items(items): """ Calculate the total duration of the media items using the "length" attribute @@ -88,3 +87,37 @@ def get_duration_of_items(items): pass return total_time + + +def get_min_max_sum_avg_for_items(items, field_name): + _min = 99999999.9 + _max = 0 + _sum = 0 + _avg = 0 + _cnt = 0 + for item in items: + try: + field_value = round(float(item.get(field_name, None)), 3) + _cnt += 1 + except ValueError: + field_value = None + except TypeError: + field_value = None + + # Min + if field_value is not None and field_value < _min: + _min = field_value + + # Max + if field_value is not None and field_value > _max: + _max = field_value + + # Sum + if field_value is not None: + _sum = _sum + field_value + + # Avg + if _cnt > 0: + _avg = round(_sum / _cnt, 3) + + return _min, _max, _sum, _avg diff --git a/test/unit/000_common_test.py b/test/unit/000_common_test.py index 7e4e84c..f986e79 100644 --- a/test/unit/000_common_test.py +++ b/test/unit/000_common_test.py @@ -4,8 +4,6 @@ # Created: 3/17/20, 3:28 PM # License: See LICENSE.txt # -from beets.library import Item -from beets.util.confit import Subview, ConfigView, LazyConfig, ConfigSource from test.helper import TestHelper, Assertions, get_plugin_configuration from beetsplug.goingrunning import common @@ -14,8 +12,7 @@ class CommonTest(TestHelper, Assertions): - """Test presence and invocation of the plugin. - Only ensures that command does not fail. + """Test methods in the beetsplug.goingrunning.common module """ def test_module_values(self): @@ -66,7 +63,7 @@ def test_get_training_attribute(self): } } config = get_plugin_configuration(cfg) - training: Subview = config["trainings"]["10K"] + training = config["trainings"]["10K"] # Direct self.assertEqual("180..", common.get_training_attribute(training, "bpm")) @@ -88,7 +85,7 @@ def test_get_target_attribute(self): } } config = get_plugin_configuration(cfg) - target: Subview = config["targets"]["MPD1"] + target = config["targets"]["MPD1"] self.assertEqual("/media/mpd1/", common.get_target_attribute(target, "device_root")) self.assertEqual("auto/", common.get_target_attribute(target, "device_path")) @@ -105,3 +102,40 @@ def test_get_duration_of_items(self): # TypeError baditem = self.create_item(length={}) self.assertEqual(0, common.get_duration_of_items([baditem])) + + def test_get_min_max_sum_avg_for_items(self): + item1 = self.create_item(bpm=100) + item2 = self.create_item(bpm=150) + item3 = self.create_item(bpm=200) + _min, _max, _sum, _avg = common.get_min_max_sum_avg_for_items([item1, item2, item3], "bpm") + self.assertEqual(100, _min) + self.assertEqual(200, _max) + self.assertEqual(450, _sum) + self.assertEqual(150, _avg) + + item1 = self.create_item(bpm=99.7512345) + item2 = self.create_item(bpm=150.482234) + item3 = self.create_item(bpm=200.254733) + _min, _max, _sum, _avg = common.get_min_max_sum_avg_for_items([item1, item2, item3], "bpm") + self.assertEqual(99.751, _min) + self.assertEqual(200.255, _max) + self.assertEqual(450.488, _sum) + self.assertEqual(150.163, _avg) + + # ValueError + item1 = self.create_item(bpm=100) + item2 = self.create_item(bpm="") + _min, _max, _sum, _avg = common.get_min_max_sum_avg_for_items([item1, item2], "bpm") + self.assertEqual(100, _min) + self.assertEqual(100, _max) + self.assertEqual(100, _sum) + self.assertEqual(100, _avg) + + # TypeError + item1 = self.create_item(bpm=100) + item2 = self.create_item(bpm={}) + _min, _max, _sum, _avg = common.get_min_max_sum_avg_for_items([item1, item2], "bpm") + self.assertEqual(100, _min) + self.assertEqual(100, _max) + self.assertEqual(100, _sum) + self.assertEqual(100, _avg) diff --git a/test/unit/001_command_test.py b/test/unit/001_command_test.py new file mode 100644 index 0000000..3e7a0b0 --- /dev/null +++ b/test/unit/001_command_test.py @@ -0,0 +1,37 @@ +# Copyright: Copyright (c) 2020., Adam Jakab +# +# Author: Adam Jakab +# Created: 3/17/20, 7:09 PM +# License: See LICENSE.txt +# +# +# Author: Adam Jakab +# Created: 3/17/20, 3:28 PM +# License: See LICENSE.txt +# + +from test.helper import TestHelper, Assertions, get_plugin_configuration +from beetsplug.goingrunning import command + +from logging import Logger + + +class CommandTest(TestHelper, Assertions): + """Test methods in the beetsplug.goingrunning.command module + """ + + def test_module_values(self): + self.assertTrue(hasattr(command, "__PLUGIN_NAME__")) + self.assertTrue(hasattr(command, "__PLUGIN_SHORT_NAME__")) + self.assertTrue(hasattr(command, "__PLUGIN_SHORT_DESCRIPTION__")) + + self.assertEqual(u'goingrunning', command.__PLUGIN_NAME__) + self.assertEqual(u'run', command.__PLUGIN_SHORT_NAME__) + self.assertEqual(u'run with the music that matches your training sessions', + command.__PLUGIN_SHORT_DESCRIPTION__) + + def test_class_init_config(self): + cfg = {"something": "good"} + config = get_plugin_configuration(cfg) + inst = command.GoingRunningCommand(config) + self.assertEqual(config, inst.config) From 9fd5044796e14e80bca08308527eeab81b31fff5 Mon Sep 17 00:00:00 2001 From: Adam Jakab Date: Tue, 17 Mar 2020 23:57:23 +0100 Subject: [PATCH 21/39] cleaning up tests --- beetsplug/goingrunning/command.py | 27 +++-- test/_old/01_common_module_test.py | 99 ---------------- test/_old/02_configuration_test.py | 84 +------------- test/config/config_inc_main.yml | 4 - test/config/config_inc_sub.yml | 77 ------------- test/config/config_user.yml | 83 -------------- test/config/config_user_no_level_3.yml | 32 ------ test/config/config_user_no_level_3_or_2.yml | 24 ---- test/config/default.yml | 66 +++++++++++ test/config/obsolete.yml | 8 ++ ..._basic_check_test.py => 000_basic_test.py} | 6 +- test/functional/001_configuration_test.py | 90 +++++++++++++++ test/functional/002_command_test.py | 107 ++++++++++++++++++ test/helper.py | 100 +++++++++------- test/unit/000_common_test.py | 4 +- test/unit/001_command_test.py | 4 +- 16 files changed, 365 insertions(+), 450 deletions(-) delete mode 100644 test/_old/01_common_module_test.py delete mode 100644 test/config/config_inc_main.yml delete mode 100644 test/config/config_inc_sub.yml delete mode 100644 test/config/config_user.yml delete mode 100644 test/config/config_user_no_level_3.yml delete mode 100644 test/config/config_user_no_level_3_or_2.yml create mode 100644 test/config/default.yml create mode 100644 test/config/obsolete.yml rename test/functional/{000_basic_check_test.py => 000_basic_test.py} (79%) create mode 100644 test/functional/001_configuration_test.py create mode 100644 test/functional/002_command_test.py diff --git a/beetsplug/goingrunning/command.py b/beetsplug/goingrunning/command.py index f9c3f61..057ca00 100644 --- a/beetsplug/goingrunning/command.py +++ b/beetsplug/goingrunning/command.py @@ -156,6 +156,10 @@ def handle_training(self): "There are no songs in your library that match this training!") return + # Verify target device path path + if not self._get_destination_path_for_training(training): + return + # 1) order items by scoring system (need ordering in config) self._score_library_items(training, lib_items) sorted_lib_items = sorted(lib_items, @@ -172,9 +176,6 @@ def handle_training(self): # @todo: check if total time is close to duration - (config might be # too restrictive or too few songs) - # Verify target device path path - if not self._get_destination_path_for_training(training): - return # Show some info self._say("Training duration: {0}".format( @@ -327,10 +328,17 @@ def _get_destination_path_for_training(self, training: Subview): def _get_items_for_duration(self, items, duration): selected = [] total_time = 0 - _min, _max, _sum, _avg = GRC.get_min_max_sum_avg_for_items(items, - "length") - est_num_songs = round(duration * 60 / _avg) - bin_size = len(items) / est_num_songs + _min, _max, _sum, _avg = GRC.get_min_max_sum_avg_for_items(items, "length") + + if _avg > 0: + est_num_songs = round(duration * 60 / _avg) + else: + est_num_songs = 0 + + if est_num_songs > 0: + bin_size = len(items) / est_num_songs + else: + bin_size = 0 self._say("Estimated number of songs: {}".format(est_num_songs)) self._say("Bin Size: {}".format(bin_size)) @@ -394,7 +402,10 @@ def _score_library_items(self, training: Subview, items): for field_name in order_info.keys(): field_data = order_info[field_name] field_data["delta"] = field_data["max"] - field_data["min"] - field_data["step"] = round(100 / field_data["delta"], 3) + if field_data["delta"] > 0: + field_data["step"] = round(100 / field_data["delta"], 3) + else: + field_data["step"] = 0 # self._say("ORDER INFO: {0}".format(order_info)) diff --git a/test/_old/01_common_module_test.py b/test/_old/01_common_module_test.py deleted file mode 100644 index c9f07a2..0000000 --- a/test/_old/01_common_module_test.py +++ /dev/null @@ -1,99 +0,0 @@ -# Copyright: Copyright (c) 2020., Adam Jakab -# -# Author: Adam Jakab -# Created: 2/19/20, 12:40 PM -# License: See LICENSE.txt -# - -from logging import Logger -from random import randint - -from beets.library import Item - -from test.helper import TestHelper, Assertions, PLUGIN_NAME, capture_log - -from beets import config as beets_global_config -from beetsplug.goingrunning import common as GRC - - -class CommonModuleTest(TestHelper, Assertions): - - def test_must_have_training_keys(self): - must_have_keys = ['query', 'duration', 'target'] - for key in must_have_keys: - self.assertIn(key, GRC.MUST_HAVE_TRAINING_KEYS, - msg=u'Missing default training key: {0}'.format(key)) - - def test_log_interface(self): - log = GRC.get_beets_logger() - self.assertIsInstance(log, Logger) - - msg = "Anything goes tonight!" - with capture_log() as logs: - log.info(msg) - - self.assertIn('{0}: {1}'.format(PLUGIN_NAME, msg), '\n'.join(logs)) - - def test_get_beets_global_config(self): - beets_cfg = beets_global_config - plg_cfg = GRC.get_beets_global_config() - self.assertEqual(beets_cfg, plg_cfg) - - def test_human_readable_time(self): - self.assertEqual("0:00:00", GRC.get_human_readable_time(0), "Bad time format!") - self.assertEqual("0:00:30", GRC.get_human_readable_time(30), "Bad time format!") - self.assertEqual("0:01:30", GRC.get_human_readable_time(90), "Bad time format!") - self.assertEqual("0:10:00", GRC.get_human_readable_time(600), "Bad time format!") - - def test_duration_of_items(self): - items = None - self.assertEqual(0, GRC.get_duration_of_items(items)) - - items = {} - self.assertEqual(0, GRC.get_duration_of_items(items)) - - items = [] - self.assertEqual(0, GRC.get_duration_of_items(items)) - - items = [] - total = 0 - for i in range(100): - length = randint(1, 300) - total += length - items.append({"length": length}) - self.assertEqual(total, GRC.get_duration_of_items(items)) - - items = [{"length": 1}, {"length": {}}, {"length": "abc"}, {"length": None}] - self.assertEqual(1, GRC.get_duration_of_items(items)) - - def test_item_randomizer(self): - items = None - duration = 5 - with self.assertRaises(TypeError): - GRC.get_randomized_items(items, duration) - - items = [] - duration = 0 - self.assertEqual([], GRC.get_randomized_items(items, duration)) - - items = [] - duration = 5 - self.assertEqual([], GRC.get_randomized_items(items, duration)) - - items = [] - items_duration = 0 - for i in range(100): - length = randint(60, 300) - items_duration += length - item = Item() - item.update({"title": "Song-{}".format(length), "length": length}) - items.append(item) - - max_duration_min = 90 - rnd_items = GRC.get_randomized_items(items, max_duration_min) - self.assertNotEqual(items, rnd_items) - rnd_items_duration = GRC.get_duration_of_items(rnd_items) - self.assertLessEqual(rnd_items_duration, max_duration_min * 60) - - - diff --git a/test/_old/02_configuration_test.py b/test/_old/02_configuration_test.py index eef50c8..cbd37a3 100644 --- a/test/_old/02_configuration_test.py +++ b/test/_old/02_configuration_test.py @@ -1,6 +1,11 @@ # Copyright: Copyright (c) 2020., Adam Jakab # # Author: Adam Jakab +# Created: 3/17/20, 9:10 PM +# License: See LICENSE.txt +# +# +# Author: Adam Jakab # Created: 2/17/20, 10:53 PM # License: See LICENSE.txt # @@ -14,23 +19,6 @@ class ConfigurationTest(TestHelper, Assertions): - def test_has_plugin_default_config(self): - self.assertTrue(self.config.exists()) - plg_cfg = self.config[PLUGIN_NAME] - self.assertTrue(plg_cfg.exists()) - self.assertIsInstance(plg_cfg, Subview) - - def test_plugin_default_config_keys(self): - """ Generic check to see if plugin related default configuration is - present in config """ - cfg: Subview = self.config[PLUGIN_NAME] - cfg_keys = cfg.keys() - cfg_keys.sort() - def_keys = ['trainings', 'targets', 'flavours'] - def_keys.sort() - self.assertEqual(def_keys, cfg_keys) - # This has been removed - self.assertNotIn("clean_target", cfg_keys) def test_user_config_main(self): """ Root level values check """ @@ -161,65 +149,3 @@ def test_method_list_training_attributes(self): # self.assertIn("song_bpm: [170, 180]", out.getvalue()) # self.assertIn("song_len: [90, 180]", out.getvalue()) self.assertIn("target: drive_3", out.getvalue()) - - @unittest.skip("Deprecated! Needs removal") - def test_bubble_up(self): - """ Check values when each level has its own """ - self.reset_beets(config_file=b"config_user.yml") - - cfg_l1: Subview = self.config[PLUGIN_NAME] - cfg_l2: Subview = cfg_l1["trainings"] - cfg_l3: Subview = cfg_l2["training-1"] - self._dump_config(self.config) - - # Each level has its own value - for attrib in ['duration', 'target', 'query', 'ordering']: - self.assertEqual(cfg_l3[attrib].get(), - GoingRunningCommon.get_config_value_bubble_up( - cfg_l3, attrib)) - self.assertEqual(cfg_l2[attrib].get(), - GoingRunningCommon.get_config_value_bubble_up( - cfg_l2, attrib)) - self.assertEqual(cfg_l1[attrib].get(), - GoingRunningCommon.get_config_value_bubble_up( - cfg_l1, attrib)) - - @unittest.skip("Deprecated! Needs removal") - def test_bubble_up_inexistent_key(self): - """ Check values when each level has its own """ - self.reset_beets(config_file=b"config_user.yml") - cfg: Subview = self.config[PLUGIN_NAME]["trainings"]["training-1"] - inexistent_key = "you_will_never_find_me" - self.assertEqual(None, GoingRunningCommon.get_config_value_bubble_up(cfg, inexistent_key)) - - @unittest.skip("Deprecated! Needs removal") - def test_bubble_up_no_level_3(self): - """ Check that values are taken from level 2 if they are not present - on level 3 """ - self.reset_beets(config_file=b"config_user_no_level_3.yml") - - cfg_l1: Subview = self.config[PLUGIN_NAME] - cfg_l2: Subview = cfg_l1["trainings"] - cfg_l3: Subview = cfg_l2["training-1"] - - for attrib in ['duration', 'target', 'query', 'ordering']: - self.assertEqual(cfg_l2[attrib].get(), - GoingRunningCommon.get_config_value_bubble_up( - cfg_l3, attrib)) - - @unittest.skip("Deprecated! Needs removal") - def test_bubble_up_no_level_3_or_2(self): - """ Check that values are taken from level 1 if they are not present - on level 3 or 2 """ - self.reset_beets(config_file=b"config_user_no_level_3_or_2.yml") - - cfg_l1: Subview = self.config[PLUGIN_NAME] - cfg_l2: Subview = cfg_l1["trainings"] - cfg_l3: Subview = cfg_l2["training-1"] - - for attrib in ['duration', 'target', 'query', 'ordering']: - self.assertEqual(cfg_l1[attrib].get(), - GoingRunningCommon.get_config_value_bubble_up( - cfg_l3, attrib)) - - self._dump_config(self.config) diff --git a/test/config/config_inc_main.yml b/test/config/config_inc_main.yml deleted file mode 100644 index 2d1bc4b..0000000 --- a/test/config/config_inc_main.yml +++ /dev/null @@ -1,4 +0,0 @@ -# This configuration file is used to configuration file inclusion - MAIN - -include: - - config_inc_sub.yml diff --git a/test/config/config_inc_sub.yml b/test/config/config_inc_sub.yml deleted file mode 100644 index 15bd831..0000000 --- a/test/config/config_inc_sub.yml +++ /dev/null @@ -1,77 +0,0 @@ -# This configuration file is used to configuration file inclusion - SUB - -format_item: "[bpm:$bpm][length:$length][genre:$genre]: $artist - $album - $title ::: $path" - - -goingrunning: - query: - bpm: 0..999 - length: 0..999 - ordering: - year+: 100 - bpm+: 100 - duration: 120 - target: drive_1 - targets: - drive_1: - device_root: /tmp/beets-goingrunning-test-drive - device_path: - clean_target: yes - drive_2: - device_root: /mnt/UsbDrive - device_path: - drive_3: - device_root: ~/Music/ - device_path: - drive_not_connected: - device_root: /media/this/probably/does/not/exist - device_path: - trainings: - query: - bpm: 50..200 - length: 30..600 - ordering: - year+: 100 - bpm+: 100 - duration: 60 - target: drive_2 - training-1: - alias: "Born to run" - query: - bpm: 150..180 - length: 120..240 - duration: 55 - target: drive_3 - training-2: - alias: "Born to run" - query: - bpm: 170..180 - length: 90..180 - duration: 25 - target: drive_3 - undefined-target: - duration: 300 - target: i_am_not_defined - bad-target-2: - duration: 300 - target: drive_not_connected - marathon: - alias: "Born to run" - query: - bpm: 145..160 - length: 120..600 - duration: 300 - target: drive_1 - one-hour-run: - alias: "Born to run" - query: - bpm: 150..180 - length: 120..240 - duration: 60 - target: drive_1 - quick-run: - query: - bpm: 150..180 - length: 120..240 - duration: 10 - target: drive_1 diff --git a/test/config/config_user.yml b/test/config/config_user.yml deleted file mode 100644 index a11aaa6..0000000 --- a/test/config/config_user.yml +++ /dev/null @@ -1,83 +0,0 @@ -# This configuration file is used to test a hypothetical user configuration scenario - -format_item: "[bpm:$bpm][length:$length][genre:$genre]: $artist - $album - $title ::: $path" - -goingrunning: - query: - bpm: 0..999 - length: 0..999 - ordering: - year+: 100 - bpm+: 100 - duration: 120 - target: drive_1 - targets: - drive_1: - device_root: /tmp/beets-goingrunning-test-drive - drive_2: - device_root: /mnt/UsbDrive - drive_3: - device_root: ~/Music/ - drive_not_connected: - device_root: /media/this/probably/does/not/exist - trainings: - query: - bpm: 50..200 - length: 30..600 - ordering: - year+: 100 - bpm+: 100 - duration: 60 - target: drive_2 - training-1: - alias: "Born to run" - query: - bpm: 150..180 - length: 120..240 - ordering: - year+: 75 - bpm+: 50 - duration: 55 - target: drive_3 - training-2: - alias: "Born to run" - query: - bpm: 170..180 - length: 90..180 - ordering: - year+: 50 - bpm+: 25 - duration: 25 - target: drive_3 - bad-target-1: - query: - bpm: 145..160 - length: 120..600 - duration: 300 - target: inexistent_target - bad-target-2: - query: - bpm: 145..160 - length: 120..600 - duration: 300 - target: drive_not_connected - marathon: - alias: "Born to run" - query: - bpm: 145..160 - length: 120..600 - duration: 300 - target: drive_1 - one-hour-run: - alias: "Born to run" - query: - bpm: 150..180 - length: 120..240 - duration: 60 - target: drive_1 - quick-run: - query: - bpm: 150..180 - length: 120..240 - duration: 10 - target: drive_1 diff --git a/test/config/config_user_no_level_3.yml b/test/config/config_user_no_level_3.yml deleted file mode 100644 index 1c6db38..0000000 --- a/test/config/config_user_no_level_3.yml +++ /dev/null @@ -1,32 +0,0 @@ -# This configuration file is used to test that the configuration values propagate from root down. -# That is to say, if a certain leaf does not have a configuration value it should inherit it from its parent. -# In this configuration file values on "training-1" are missing (only alias is given as a placeholder) so -# values from "trainings" should be applied. - -goingrunning: - query: - bpm: 0..999 - length: 0..999 - ordering: - year+: 100 - bpm+: 100 - duration: 120 - target: drive_1 - targets: - drive_1: - device_root: ~/Music/ - drive_2: - device_root: /mnt/UsbDrive - drive_3: - device_root: /media/Storage - trainings: - query: - bpm: 150..200 - length: 30..600 - ordering: - year+: 100 - bpm+: 100 - duration: 60 - target: drive_2 - training-1: - alias: "Born to run" diff --git a/test/config/config_user_no_level_3_or_2.yml b/test/config/config_user_no_level_3_or_2.yml deleted file mode 100644 index 869a623..0000000 --- a/test/config/config_user_no_level_3_or_2.yml +++ /dev/null @@ -1,24 +0,0 @@ -# This configuration file is used to test that the configuration values propagate from root down. -# That is to say, if a certain leaf does not have a configuration value it should inherit it from its parent. -# In this configuration file values on "training-1" are missing (only alias is given as a placeholder) and also -# values from "trainings" level are missing. So "root" level values should be applied. - -goingrunning: - query: - bpm: 0..999 - length: 0..999 - ordering: - year+: 100 - bpm+: 100] - duration: 120 - target: drive_1 - targets: - drive_1: - device_root: ~/Music/ - drive_2: - device_root: /mnt/UsbDrive - drive_3: - device_root: /media/Storage - trainings: - training-1: - alias: "Born to run" diff --git a/test/config/default.yml b/test/config/default.yml new file mode 100644 index 0000000..8b5a7ac --- /dev/null +++ b/test/config/default.yml @@ -0,0 +1,66 @@ +# This default configuration file is used to test a hypothetical user configuration scenario + +format_item: "[bpm:$bpm][length:$length][genre:$genre]: $artist - $album - $title ::: $path" + +goingrunning: + targets: + MPD_1: + device_root: /tmp/beets-goingrunning-test-drive/ + device_path: Music/ + clean_target: yes + delete_from_device: + - xyz.txt + MPD_2: + device_root: /mnt/UsbDrive/ + device_path: Auto/Music/ + clean_target: no + MPD_3: + device_root: /media/this/probably/does/not/exist/ + device_path: Music/ + trainings: + training-1: + alias: "Match any songs" + query: + bpm: 0..999 + ordering: + bpm: 100 + duration: 60 + target: MPD_1 + training-2: + alias: "Born to run" + query: + bpm: 170..180 + length: 90..180 + ordering: + year+: 50 + bpm+: 25 + duration: 25 + target: MPD_1 + marathon: + alias: "Born to run" + query: + bpm: 145..160 + length: 120..600 + duration: 300 + target: MPD_1 + one-hour-run: + alias: "Born to run" + query: + bpm: 150..180 + length: 120..240 + duration: 60 + target: MPD_1 + quick-run: + query: + bpm: 150..180 + length: 120..240 + duration: 10 + target: MPD_1 + bad-target-1: + target: inexistent_target + bad-target-2: + target: MPD_3 + flavours: + sunshine: + genre: Reggae + diff --git a/test/config/obsolete.yml b/test/config/obsolete.yml new file mode 100644 index 0000000..593174c --- /dev/null +++ b/test/config/obsolete.yml @@ -0,0 +1,8 @@ +# This contains keys which indicate that it was created for a pre-v1.1.1 version +# The plugin will issue a warning and exit when finding those keys + +goingrunning: + trainings: + training-1: + song_bpm: [100, 150] + song_len: [60, 120] diff --git a/test/functional/000_basic_check_test.py b/test/functional/000_basic_test.py similarity index 79% rename from test/functional/000_basic_check_test.py rename to test/functional/000_basic_test.py index 0049561..55d0d19 100644 --- a/test/functional/000_basic_check_test.py +++ b/test/functional/000_basic_test.py @@ -5,10 +5,11 @@ # License: See LICENSE.txt # -from test.helper import TestHelper, Assertions, PLUGIN_NAME, PLUGIN_SHORT_DESCRIPTION, capture_log, capture_stdout +from test.helper import FunctionalTestHelper, Assertions, PLUGIN_NAME, PLUGIN_SHORT_DESCRIPTION, capture_log, \ + capture_stdout -class BasicCheckTest(TestHelper, Assertions): +class BasicTest(FunctionalTestHelper, Assertions): """Test presence and invocation of the plugin. Only ensures that command does not fail. """ @@ -27,6 +28,7 @@ def test_application_version(self): self.assertIn("plugins: {0}".format(PLUGIN_NAME), out.getvalue()) def test_plugin_no_arguments(self): + self.reset_beets(config_file=b"empty.yml") with capture_stdout() as out: self.runcli(PLUGIN_NAME) diff --git a/test/functional/001_configuration_test.py b/test/functional/001_configuration_test.py new file mode 100644 index 0000000..01c9201 --- /dev/null +++ b/test/functional/001_configuration_test.py @@ -0,0 +1,90 @@ +# Copyright: Copyright (c) 2020., Adam Jakab +# +# Author: Adam Jakab +# Created: 2/19/20, 12:35 PM +# License: See LICENSE.txt +# + +from beets.util.confit import Subview + +from test.helper import FunctionalTestHelper, Assertions, PLUGIN_NAME, PLUGIN_SHORT_DESCRIPTION, capture_log, \ + capture_stdout + + +class ConfigurationTest(FunctionalTestHelper, Assertions): + """Configuration related tests + """ + + def test_plugin_no_config(self): + self.reset_beets(config_file=b"empty.yml") + self.assertTrue(self.config.exists()) + self.assertTrue(self.config[PLUGIN_NAME].exists()) + self.assertIsInstance(self.config[PLUGIN_NAME], Subview) + self.assertTrue(self.config[PLUGIN_NAME]["targets"].exists()) + self.assertTrue(self.config[PLUGIN_NAME]["trainings"].exists()) + self.assertTrue(self.config[PLUGIN_NAME]["flavours"].exists()) + # print(self.config[PLUGIN_NAME].flatten()) + + def test_obsolete_config(self): + self.reset_beets(config_file=b"obsolete.yml") + with capture_stdout() as out: + self.runcli(PLUGIN_NAME) + + self.assertIn("INCOMPATIBLE PLUGIN CONFIGURATION", out.getvalue()) + self.assertIn("Offending key in training(training-1): song_bpm", out.getvalue()) + + def test_default_config_sanity(self): + self.assertTrue(self.config[PLUGIN_NAME].exists()) + cfg = self.config[PLUGIN_NAME] + + # Check keys + cfg_keys = cfg.keys() + cfg_keys.sort() + chk_keys = ['targets', 'trainings', 'flavours'] + chk_keys.sort() + self.assertEqual(chk_keys, cfg_keys) + + def test_default_config_targets(self): + """ Check Targets""" + cfg: Subview = self.config[PLUGIN_NAME] + targets = cfg["targets"] + self.assertTrue(targets.exists()) + + self.assertIsInstance(targets, Subview) + self.assertEquals(["MPD_1", "MPD_2", "MPD_3"], list(targets.get().keys())) + + # MPD 1 + target = targets["MPD_1"] + self.assertIsInstance(target, Subview) + self.assertTrue(target.exists()) + self.assertEqual("/tmp/beets-goingrunning-test-drive/", target["device_root"].get()) + self.assertEqual("Music/", target["device_path"].get()) + self.assertTrue(target["clean_target"].get()) + self.assertEqual(["xyz.txt"], target["delete_from_device"].get()) + + # MPD 2 + target = targets["MPD_2"] + self.assertIsInstance(target, Subview) + self.assertTrue(target.exists()) + self.assertEqual("/mnt/UsbDrive/", target["device_root"].get()) + self.assertEqual("Auto/Music/", target["device_path"].get()) + self.assertFalse(target["clean_target"].get()) + + # MPD 3 + target = targets["MPD_3"] + self.assertIsInstance(target, Subview) + self.assertTrue(target.exists()) + self.assertEqual("/media/this/probably/does/not/exist/", target["device_root"].get()) + self.assertEqual("Music/", target["device_path"].get()) + + def test_default_config_trainings(self): + """ Check Targets""" + cfg: Subview = self.config[PLUGIN_NAME] + trainings = cfg["trainings"] + self.assertTrue(trainings.exists()) + + def test_default_config_flavours(self): + """ Check Targets""" + cfg: Subview = self.config[PLUGIN_NAME] + flavours = cfg["flavours"] + self.assertTrue(flavours.exists()) diff --git a/test/functional/002_command_test.py b/test/functional/002_command_test.py new file mode 100644 index 0000000..58f68a0 --- /dev/null +++ b/test/functional/002_command_test.py @@ -0,0 +1,107 @@ +# Copyright: Copyright (c) 2020., Adam Jakab +# +# Author: Adam Jakab +# Created: 3/17/20, 10:44 PM +# License: See LICENSE.txt +# + +from beets.util.confit import Subview + +from test.helper import FunctionalTestHelper, Assertions, PLUGIN_NAME, PLUGIN_SHORT_DESCRIPTION, capture_log, \ + capture_stdout + + +class CommandTest(FunctionalTestHelper, Assertions): + """Command related tests + """ + + def test_plugin_version(self): + with capture_stdout() as out: + self.runcli(PLUGIN_NAME, "--version") + + from beetsplug.goingrunning.version import __version__ + self.assertIn("Goingrunning(beets-{})".format(PLUGIN_NAME), out.getvalue()) + self.assertIn("v{}".format(__version__), out.getvalue()) + + def test_training_listing_empty(self): + self.reset_beets(config_file=b"empty.yml") + with capture_stdout() as out: + self.runcli(PLUGIN_NAME, "--list") + self.assertIn("You have not created any trainings yet.", out.getvalue()) + + with capture_stdout() as out: + self.runcli(PLUGIN_NAME, "-l") + self.assertIn("You have not created any trainings yet.", out.getvalue()) + + def test_training_listing_default(self): + with capture_stdout() as out: + self.runcli(PLUGIN_NAME, "--list") + + output = out.getvalue() + self.assertIn("::: training-1", output) + self.assertIn("::: training-2", output) + self.assertIn("::: marathon", output) + + def test_training_handling_inexistent(self): + training_name = "sitting_on_the_sofa" + with capture_stdout() as out: + self.runcli(PLUGIN_NAME, training_name) + + self.assertIn("There is no training[{0}] registered with this name!".format(training_name), out.getvalue()) + + def test_training_song_count(self): + training_name = "marathon" + with capture_stdout() as out: + self.runcli(PLUGIN_NAME, training_name, "--count") + self.assertIn("Number of songs available: {}".format(0), out.getvalue()) + + with capture_stdout() as out: + self.runcli(PLUGIN_NAME, training_name, "-c") + self.assertIn("Number of songs available: {}".format(0), out.getvalue()) + + def test_training_no_songs(self): + training_name = "marathon" + with capture_stdout() as out: + self.runcli(PLUGIN_NAME, training_name) + + self.assertIn("Handling training: {0}".format(training_name), out.getvalue()) + self.assertIn("There are no songs in your library that match this training!", out.getvalue()) + + def test_training_undefined_target(self): + self.add_single_item_to_library() + + training_name = "bad-target-1" + with capture_stdout() as out: + self.runcli(PLUGIN_NAME, training_name) + + target_name = "inexistent_target" + self.assertIn("The target name[{0}] is not defined!".format(target_name), out.getvalue()) + + def test_training_bad_target(self): + self.add_single_item_to_library() + + training_name = "bad-target-2" + with capture_stdout() as out: + self.runcli(PLUGIN_NAME, training_name) + + target_name = "MPD_3" + target_path = "/media/this/probably/does/not/exist/" + self.assertIn("The target[{0}] path does not exist: {1}".format(target_name, target_path), out.getvalue()) + + def test_training_with_songs_multiple_config(self): + self.add_multiple_items_to_library(count=10, bpm=120) + training_name = "training-1" + + # Set existing path for target + target_name = self.config[PLUGIN_NAME]["trainings"][training_name]["target"].get() + target = self.config[PLUGIN_NAME]["targets"][target_name] + device_root = self.create_temp_dir() + target["device_root"].set(device_root) + target["device_path"].set("") + + with capture_stdout() as out: + self.runcli(PLUGIN_NAME, training_name) + + self.assertIn("Handling training: {0}".format(training_name), out.getvalue()) + self.assertIn("Number of songs selected:", out.getvalue()) + self.assertIn("Run!", out.getvalue()) diff --git a/test/helper.py b/test/helper.py index 943f286..47c682a 100644 --- a/test/helper.py +++ b/test/helper.py @@ -128,16 +128,48 @@ def assertIsFile(self: TestCase, path): msg=u'Path is not a file: {0}'.format(displayable_path(path))) -class TestHelper(TestCase, Assertions): +class UnitTestHelper(TestCase, Assertions): + __item_count = 0 + + def create_item(self, **values): + """Return an `Item` instance with sensible default values. + + The item receives its attributes from `**values` paratmeter. The + `title`, `artist`, `album`, `track`, `format` and `path` + attributes have defaults if they are not given as parameters. + The `title` attribute is formatted with a running item count to + prevent duplicates. + """ + item_count = self._get_item_count() + values_ = { + 'title': u't\u00eftle {0}', + 'artist': u'the \u00e4rtist', + 'album': u'the \u00e4lbum', + 'track': item_count, + 'format': 'MP3', + } + values_.update(values) + + values_['title'] = values_['title'].format(item_count) + item = Item(**values_) + + return item + + def _get_item_count(self): + self.__item_count += 1 + return self.__item_count + + +class FunctionalTestHelper(TestCase, Assertions): _test_config_dir_ = os.path.join(bytestring_path(os.path.dirname(__file__)), b'config') _test_fixture_dir = os.path.join(bytestring_path(os.path.dirname(__file__)), b'fixtures') _test_target_dir = bytestring_path("/tmp/beets-goingrunning-test-drive") __item_count = 0 def setUp(self): - """Setup before running any tests. + """Setup before running any tests with an empty configuration file. """ - self.reset_beets(config_file=b"empty.yml") + self.reset_beets(config_file=b"default.yml") def tearDown(self): self.teardown_beets() @@ -149,7 +181,7 @@ def reset_beets(self, config_file: bytes, beet_files: list = None): def _setup_beets(self, config_file: bytes, beet_files: list = None): self.addCleanup(self.teardown_beets) - self.beetsdir = bytestring_path(self.mkdtemp()) + self.beetsdir = bytestring_path(self.create_temp_dir()) os.environ['BEETSDIR'] = self.beetsdir.decode() self.config = beets.config @@ -197,7 +229,7 @@ def _copy_files_to_beetsdir(self, file_list: list): file_name = file_name.decode() dst = os.path.join(self.beetsdir.decode(), file_name) - print("Copy to beetsdir: {}".format(file_name)) + print("Copy({}) to beetsdir: {}".format(src, file_name)) shutil.copyfile(src, dst) @@ -224,10 +256,15 @@ def teardown_beets(self): # beets.config.read(user=False, defaults=True) - def mkdtemp(self): - path = tempfile.mkdtemp() - self._tempdirs.append(path) - return path + def create_temp_dir(self): + temp_dir = tempfile.mkdtemp() + self._tempdirs.append(temp_dir) + return temp_dir + + # def remove_temp_dir(self): + # """Delete the temporary directory created by `create_temp_dir`. + # """ + # shutil.rmtree(self.temp_dir) @staticmethod def unload_plugins(): @@ -259,6 +296,12 @@ def get_fixture_item_path(self, ext): assert (ext in 'mp3 m4a ogg'.split()) return os.path.join(self._test_fixture_dir, bytestring_path('song.' + ext.lower())) + def _get_item_count(self): + """Internal counter for create_item + """ + self.__item_count += 1 + return self.__item_count + def create_item(self, **values): """Return an `Item` instance with sensible default values. @@ -293,33 +336,14 @@ def create_item(self, **values): return item - def _get_item_count(self): - self.__item_count += 1 - return self.__item_count + def add_single_item_to_library(self, **values): + item = self.create_item(**values) + # item = Item.from_path(self.get_fixture_item_path(values.pop('format'))) + item.add(self.lib) + # item.move(MoveOperation.COPY) + # item.write() + return item - # def add_multiple_items_to_library(self, count=10, song_bpm=None, song_length=None, **kwargs): - # if song_bpm is None: - # song_bpm = [60, 220] - # if song_length is None: - # song_length = [15, 300] - # for i in range(count): - # bpm = randint(song_bpm[0], song_bpm[1]) - # length = randint(song_length[0], song_length[1]) - # self.add_single_item_to_library(bpm=bpm, length=length, **kwargs) - # - # def add_single_item_to_library(self, **kwargs): - # values = { - # 'title': 'track 1', - # 'artist': 'artist 1', - # 'album': 'album 1', - # 'bpm': randint(120, 180), - # 'length': randint(90, 720), - # 'format': 'mp3', - # } - # values.update(kwargs) - # item = Item.from_path(self.get_fixture_item_path(values.pop('format'))) - # item.update(values) - # item.add(self.lib) - # item.move(MoveOperation.COPY) - # item.write() - # return item + def add_multiple_items_to_library(self, count=10, **values): + for i in range(count): + self.add_single_item_to_library(**values) diff --git a/test/unit/000_common_test.py b/test/unit/000_common_test.py index f986e79..23e8b3d 100644 --- a/test/unit/000_common_test.py +++ b/test/unit/000_common_test.py @@ -5,13 +5,13 @@ # License: See LICENSE.txt # -from test.helper import TestHelper, Assertions, get_plugin_configuration +from test.helper import UnitTestHelper, Assertions, get_plugin_configuration from beetsplug.goingrunning import common from logging import Logger -class CommonTest(TestHelper, Assertions): +class CommonTest(UnitTestHelper, Assertions): """Test methods in the beetsplug.goingrunning.common module """ diff --git a/test/unit/001_command_test.py b/test/unit/001_command_test.py index 3e7a0b0..fec4329 100644 --- a/test/unit/001_command_test.py +++ b/test/unit/001_command_test.py @@ -10,13 +10,13 @@ # License: See LICENSE.txt # -from test.helper import TestHelper, Assertions, get_plugin_configuration +from test.helper import UnitTestHelper, Assertions, get_plugin_configuration from beetsplug.goingrunning import command from logging import Logger -class CommandTest(TestHelper, Assertions): +class CommandTest(UnitTestHelper, Assertions): """Test methods in the beetsplug.goingrunning.command module """ From 9680253651bb9e282a14b3aacea0bc89bd05ab58 Mon Sep 17 00:00:00 2001 From: Adam Jakab Date: Wed, 18 Mar 2020 00:03:48 +0100 Subject: [PATCH 22/39] master is now respecting integers!!! --- test/_old/02_configuration_test.py | 151 -------------------------- test/_old/03_basic_command_test.py | 168 ----------------------------- test/unit/000_common_test.py | 8 +- 3 files changed, 4 insertions(+), 323 deletions(-) delete mode 100644 test/_old/02_configuration_test.py delete mode 100644 test/_old/03_basic_command_test.py diff --git a/test/_old/02_configuration_test.py b/test/_old/02_configuration_test.py deleted file mode 100644 index cbd37a3..0000000 --- a/test/_old/02_configuration_test.py +++ /dev/null @@ -1,151 +0,0 @@ -# Copyright: Copyright (c) 2020., Adam Jakab -# -# Author: Adam Jakab -# Created: 3/17/20, 9:10 PM -# License: See LICENSE.txt -# -# -# Author: Adam Jakab -# Created: 2/17/20, 10:53 PM -# License: See LICENSE.txt -# - -from beets.util.confit import Subview - -from test.helper import TestHelper, Assertions, PLUGIN_NAME, capture_stdout -from beetsplug.goingrunning.command import GoingRunningCommand -from beetsplug.goingrunning import common as GoingRunningCommon -import unittest - -class ConfigurationTest(TestHelper, Assertions): - - - def test_user_config_main(self): - """ Root level values check """ - self.reset_beets(config_file=b"config_user.yml") - cfg: Subview = self.config[PLUGIN_NAME] - - # Check keys - cfg_keys = cfg.keys() - cfg_keys.sort() - chk_keys = ['duration', 'targets', 'target', 'query', 'ordering', 'trainings'] - chk_keys.sort() - self.assertEqual(chk_keys, cfg_keys) - - # Check values - self.assertTrue(cfg["query"].exists()) - self.assertIsInstance(cfg["query"].get(), dict) - self.assertEqual("0..999", cfg["query"]["bpm"].get()) - self.assertEqual("0..999", cfg["query"]["length"].get()) - - self.assertTrue(cfg["ordering"].exists()) - self.assertIsInstance(cfg["ordering"].get(), dict) - self.assertEqual(100, cfg["ordering"]["year+"].get()) - self.assertEqual(100, cfg["ordering"]["bpm+"].get()) - - self.assertEqual(120, cfg["duration"].get()) - self.assertEqual("drive_1", cfg["target"].get()) - - def test_user_config_targets(self): - """ Check Targets""" - self.reset_beets(config_file=b"config_user.yml") - cfg: Subview = self.config[PLUGIN_NAME] - targets = cfg["targets"] - - self.assertIsInstance(targets, Subview) - self.assertEquals(["drive_1", "drive_2", "drive_3", "drive_not_connected"], list(targets.get().keys())) - - # Check single target - target = targets["drive_1"] - self.assertIsInstance(target, Subview) - self.assertTrue(target.exists()) - self.assertEqual("/tmp/beets-goingrunning-test-drive", target["device_root"].get()) - - # Check single target - target = targets["drive_2"] - self.assertIsInstance(target, Subview) - self.assertTrue(target.exists()) - self.assertEqual("/mnt/UsbDrive", target["device_root"].get()) - - # Check single target - target = targets["drive_3"] - self.assertIsInstance(target, Subview) - self.assertTrue(target.exists()) - self.assertEqual("~/Music/", target["device_root"].get()) - - # Check single target - target = targets["drive_not_connected"] - self.assertIsInstance(target, Subview) - self.assertTrue(target.exists()) - self.assertEqual("/media/this/probably/does/not/exist", target["device_root"].get()) - - - - def test_user_config_trainings(self): - """ Root level values check """ - self.reset_beets(config_file=b"config_user.yml") - cfg: Subview = self.config[PLUGIN_NAME] - - # Check values at Trainings level - trainings = cfg["trainings"] - self.assertTrue(trainings.exists()) - self.assertIsInstance(trainings["query"].get(), dict) - self.assertEqual("50..200", trainings["query"]["bpm"].get()) - self.assertEqual("30..600", trainings["query"]["length"].get()) - self.assertIsInstance(trainings["ordering"].get(), dict) - self.assertEqual(100, trainings["ordering"]["year+"].get()) - self.assertEqual(100, trainings["ordering"]["bpm+"].get()) - self.assertEqual(60, trainings["duration"].get()) - self.assertEqual("drive_2", trainings["target"].get()) - - # Check Training-1 - t1 = trainings["training-1"] - self.assertTrue(t1.exists()) - self.assertIsInstance(t1["query"].get(), dict) - self.assertEqual("150..180", t1["query"]["bpm"].get()) - self.assertEqual("120..240", t1["query"]["length"].get()) - self.assertIsInstance(t1["ordering"].get(), dict) - self.assertEqual(75, t1["ordering"]["year+"].get()) - self.assertEqual(50, t1["ordering"]["bpm+"].get()) - self.assertEqual(55, t1["duration"].get()) - self.assertEqual("drive_3", t1["target"].get()) - self.assertEqual("Born to run", t1["alias"].get()) - - # Check Training-2 - t2 = trainings["training-2"] - self.assertTrue(t2.exists()) - self.assertIsInstance(t2["query"].get(), dict) - self.assertEqual("170..180", t2["query"]["bpm"].get()) - self.assertEqual("90..180", t2["query"]["length"].get()) - self.assertIsInstance(t2["ordering"].get(), dict) - self.assertEqual(50, t2["ordering"]["year+"].get()) - self.assertEqual(25, t2["ordering"]["bpm+"].get()) - self.assertEqual(25, t2["duration"].get()) - self.assertEqual("drive_3", t2["target"].get()) - self.assertEqual("Born to run", t2["alias"].get()) - - def test_method_list_training_attributes(self): - """ Generic check to see if plugin related configuration is present - coming from user configuration file """ - self.reset_beets(config_file=b"config_user.yml") - plg = GoingRunningCommand(self.config[PLUGIN_NAME]) - - name = "training-1" - with capture_stdout() as out: - plg.list_training_attributes(name) - self.assertIn(name, out.getvalue()) - self.assertIn("alias: Born to run", out.getvalue()) - self.assertIn("duration: 55", out.getvalue()) - # self.assertIn("song_bpm: [150, 180]", out.getvalue()) - # self.assertIn("song_len: [120, 240]", out.getvalue()) - self.assertIn("target: drive_3", out.getvalue()) - - name = "training-2" - with capture_stdout() as out: - plg.list_training_attributes(name) - self.assertIn(name, out.getvalue()) - self.assertIn("alias: Born to run", out.getvalue()) - self.assertIn("duration: 25", out.getvalue()) - # self.assertIn("song_bpm: [170, 180]", out.getvalue()) - # self.assertIn("song_len: [90, 180]", out.getvalue()) - self.assertIn("target: drive_3", out.getvalue()) diff --git a/test/_old/03_basic_command_test.py b/test/_old/03_basic_command_test.py deleted file mode 100644 index cc85444..0000000 --- a/test/_old/03_basic_command_test.py +++ /dev/null @@ -1,168 +0,0 @@ -# Copyright: Copyright (c) 2020., Adam Jakab -# -# Author: Adam Jakab -# Created: 2/19/20, 5:44 PM -# License: See LICENSE.txt -# -import os -import platform - -from test.helper import TestHelper, Assertions, PLUGIN_NAME, capture_stdout - - -class BasicCommandTest(TestHelper, Assertions): - - def setUp(self): - """All test here will be running with a main config file using include on a secondary configuration file - """ - beet_files = [ - os.path.join(self._test_config_dir_, b'config_inc_sub.yml') - ] - self.reset_beets(config_file=b"config_inc_main.yml", beet_files=beet_files) - - def test_training_listing_long_format_empty(self): - self.reset_beets(config_file=b"empty.yml") - with capture_stdout() as out: - self.runcli(PLUGIN_NAME, "--list") - - self.assertIn("You have not created any trainings yet.", out.getvalue()) - - def test_training_listing_short_format_empty(self): - self.reset_beets(config_file=b"empty.yml") - with capture_stdout() as out: - self.runcli(PLUGIN_NAME, "-l") - - self.assertIn("You have not created any trainings yet.", out.getvalue()) - - def test_training_listing(self): - with capture_stdout() as out: - self.runcli(PLUGIN_NAME, "--list") - - output = out.getvalue() - self.assertIn("::: training-1", output) - self.assertIn("::: training-2", output) - self.assertIn("::: marathon", output) - - def test_training_handling_inexistent(self): - training_name = "sitting_on_the_sofa" - with capture_stdout() as out: - self.runcli(PLUGIN_NAME, training_name) - - self.assertIn("There is no training[{0}] registered with this name!".format(training_name), out.getvalue()) - - def test_training_training_song_count(self): - training_name = "marathon" - with capture_stdout() as out: - self.runcli(PLUGIN_NAME, training_name, "--count") - - count = 0 - self.assertIn("Number of songs available: {}".format(count), - out.getvalue()) - - def test_training_no_songs(self): - training_name = "marathon" - with capture_stdout() as out: - self.runcli(PLUGIN_NAME, training_name) - - self.assertIn("Handling training: {0}".format(training_name), out.getvalue()) - self.assertIn("There are no songs in your library that match this training!", out.getvalue()) - - def test_training_undefined_target(self): - self.add_multiple_items_to_library(count=1, song_bpm=[145, 145], song_length=[120, 120]) - - training_name = "undefined-target" - with capture_stdout() as out: - self.runcli(PLUGIN_NAME, training_name) - - target_name = "i_am_not_defined" - self.assertIn("The target name[{0}] is not defined!".format(target_name), out.getvalue()) - - def test_training_bad_target_2(self): - self.add_multiple_items_to_library(count=1, song_bpm=[145, 145], song_length=[120, 120]) - - training_name = "bad-target-2" - with capture_stdout() as out: - self.runcli(PLUGIN_NAME, training_name) - - target_name = "drive_not_connected" - target_path = "/media/this/probably/does/not/exist" - self.assertIn("The target[{0}] path does not exist: {1}".format(target_name, target_path), out.getvalue()) - - def test_training_with_songs_multiple_config(self): - self.add_multiple_items_to_library(count=30, song_bpm=[150, 180], song_length=[120, 240]) - - training_name = "one-hour-run" - with capture_stdout() as out: - self.runcli(PLUGIN_NAME, training_name) - - self.assertIn("Handling training: {0}".format(training_name), out.getvalue()) - self.assertIn("Number of songs selected:", out.getvalue()) - self.assertIn("Run!", out.getvalue()) - - def test_training_clear_path(self): - self.add_multiple_items_to_library(count=10, song_bpm=[150, 180], - song_length=[120, 150]) - - # First we run the plugin it to have some songs in the target dir to - # clear for the next step - training_name = "quick-run" - self.runcli(PLUGIN_NAME, training_name) - - with capture_stdout() as out: - self.runcli(PLUGIN_NAME, training_name) - - self.assertIn("Handling training: {0}".format(training_name), - out.getvalue()) - - # This is bad! Very bad! - if platform.system() == "Darwin": - tmp_path = "/private/tmp" - else: - tmp_path = "/tmp" - self.assertIn("Cleaning target[{0}]: {1}" - .format("drive_1", - "{0}/beets-goingrunning-test-drive".format( - tmp_path)), out.getvalue()) - - # def test_training_reserved_filter_clearing(self): - # self.add_multiple_items_to_library(count=10, song_bpm=[150, 180], - # song_length=[120, 150]) - # - # training_name = "quick-run" - # - # # Bpm(bpm) - # reserved_filter_1 = 'bpm:100..200' - # with capture_log() as logs: - # self.runcli(PLUGIN_NAME, training_name, reserved_filter_1) - # self.assertIn('goingrunning: removing reserved filter: {0}'.format( - # reserved_filter_1), '\n'.join(logs)) - # self.assertIn("goingrunning: final song selection query: [ - # 'bpm:150..180', 'length:120..240']", '\n'.join(logs)) - # - # # Length(length) - # reserved_filter_2 = 'length:30..60' - # with capture_log() as logs: - # self.runcli(PLUGIN_NAME, training_name, reserved_filter_2) - # self.assertIn('goingrunning: removing reserved filter: {0}'.format( - # reserved_filter_2), '\n'.join(logs)) - # self.assertIn("goingrunning: final song selection query: [ - # 'bpm:150..180', 'length:120..240']", '\n'.join(logs)) - # - # # Combined - # with capture_log() as logs: - # self.runcli(PLUGIN_NAME, training_name, reserved_filter_1, - # reserved_filter_2) - # self.assertIn('goingrunning: removing reserved filter: {0}'.format( - # reserved_filter_1), '\n'.join(logs)) - # self.assertIn('goingrunning: removing reserved filter: {0}'.format( - # reserved_filter_2), '\n'.join(logs)) - # self.assertIn("goingrunning: final song selection query: [ - # 'bpm:150..180', 'length:120..240']", '\n'.join(logs)) - # - # # Allowed filter - # allowed_filter = 'genre:Rock' - # with capture_log() as logs: - # self.runcli(PLUGIN_NAME, training_name, allowed_filter) - # self.assertIn("goingrunning: final song selection query: ['{0}', - # 'bpm:150..180', 'length:120..240']" - # .format(allowed_filter), '\n'.join(logs)) diff --git a/test/unit/000_common_test.py b/test/unit/000_common_test.py index 23e8b3d..0452a03 100644 --- a/test/unit/000_common_test.py +++ b/test/unit/000_common_test.py @@ -113,10 +113,10 @@ def test_get_min_max_sum_avg_for_items(self): self.assertEqual(450, _sum) self.assertEqual(150, _avg) - item1 = self.create_item(bpm=99.7512345) - item2 = self.create_item(bpm=150.482234) - item3 = self.create_item(bpm=200.254733) - _min, _max, _sum, _avg = common.get_min_max_sum_avg_for_items([item1, item2, item3], "bpm") + item1 = self.create_item(mood_happy=99.7512345) + item2 = self.create_item(mood_happy=150.482234) + item3 = self.create_item(mood_happy=200.254733) + _min, _max, _sum, _avg = common.get_min_max_sum_avg_for_items([item1, item2, item3], "mood_happy") self.assertEqual(99.751, _min) self.assertEqual(200.255, _max) self.assertEqual(450.488, _sum) From 19014a80a9849f369b29d35273c4ed989ccb40b8 Mon Sep 17 00:00:00 2001 From: Adam Jakab Date: Wed, 18 Mar 2020 00:09:18 +0100 Subject: [PATCH 23/39] master is now respecting integers!!! 2 --- test/unit/000_common_test.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/test/unit/000_common_test.py b/test/unit/000_common_test.py index 0452a03..0623cd5 100644 --- a/test/unit/000_common_test.py +++ b/test/unit/000_common_test.py @@ -104,10 +104,10 @@ def test_get_duration_of_items(self): self.assertEqual(0, common.get_duration_of_items([baditem])) def test_get_min_max_sum_avg_for_items(self): - item1 = self.create_item(bpm=100) - item2 = self.create_item(bpm=150) - item3 = self.create_item(bpm=200) - _min, _max, _sum, _avg = common.get_min_max_sum_avg_for_items([item1, item2, item3], "bpm") + item1 = self.create_item(mood_happy=100) + item2 = self.create_item(mood_happy=150) + item3 = self.create_item(mood_happy=200) + _min, _max, _sum, _avg = common.get_min_max_sum_avg_for_items([item1, item2, item3], "mood_happy") self.assertEqual(100, _min) self.assertEqual(200, _max) self.assertEqual(450, _sum) @@ -123,18 +123,18 @@ def test_get_min_max_sum_avg_for_items(self): self.assertEqual(150.163, _avg) # ValueError - item1 = self.create_item(bpm=100) - item2 = self.create_item(bpm="") - _min, _max, _sum, _avg = common.get_min_max_sum_avg_for_items([item1, item2], "bpm") + item1 = self.create_item(mood_happy=100) + item2 = self.create_item(mood_happy="") + _min, _max, _sum, _avg = common.get_min_max_sum_avg_for_items([item1, item2], "mood_happy") self.assertEqual(100, _min) self.assertEqual(100, _max) self.assertEqual(100, _sum) self.assertEqual(100, _avg) # TypeError - item1 = self.create_item(bpm=100) - item2 = self.create_item(bpm={}) - _min, _max, _sum, _avg = common.get_min_max_sum_avg_for_items([item1, item2], "bpm") + item1 = self.create_item(mood_happy=100) + item2 = self.create_item(mood_happy={}) + _min, _max, _sum, _avg = common.get_min_max_sum_avg_for_items([item1, item2], "mood_happy") self.assertEqual(100, _min) self.assertEqual(100, _max) self.assertEqual(100, _sum) From 280d0ef27127bd5ea772aacf0c03d27442d0cccc Mon Sep 17 00:00:00 2001 From: Adam Jakab Date: Wed, 18 Mar 2020 00:52:26 +0100 Subject: [PATCH 24/39] tests are sufficient --- beetsplug/goingrunning/command.py | 1 - test/config/default.yml | 2 +- test/functional/002_command_test.py | 10 ++-------- test/helper.py | 24 ++++++++++++++++++------ 4 files changed, 21 insertions(+), 16 deletions(-) diff --git a/beetsplug/goingrunning/command.py b/beetsplug/goingrunning/command.py index 057ca00..1683db0 100644 --- a/beetsplug/goingrunning/command.py +++ b/beetsplug/goingrunning/command.py @@ -395,7 +395,6 @@ def _score_library_items(self, training: Subview, items): # order_info[field]["max"] == default_field_data["max"] # ] # for field in bad_oi: del order_info[field] - # self._say("ORDER INFO #3: {0}".format(order_info)) # Calculate other values in Order Info diff --git a/test/config/default.yml b/test/config/default.yml index 8b5a7ac..48a4137 100644 --- a/test/config/default.yml +++ b/test/config/default.yml @@ -24,7 +24,7 @@ goingrunning: bpm: 0..999 ordering: bpm: 100 - duration: 60 + duration: 10 target: MPD_1 training-2: alias: "Born to run" diff --git a/test/functional/002_command_test.py b/test/functional/002_command_test.py index 58f68a0..ddb5f0b 100644 --- a/test/functional/002_command_test.py +++ b/test/functional/002_command_test.py @@ -89,15 +89,9 @@ def test_training_bad_target(self): self.assertIn("The target[{0}] path does not exist: {1}".format(target_name, target_path), out.getvalue()) def test_training_with_songs_multiple_config(self): - self.add_multiple_items_to_library(count=10, bpm=120) + self.add_multiple_items_to_library(count=10, bpm=[120, 180], length=[120, 240]) training_name = "training-1" - - # Set existing path for target - target_name = self.config[PLUGIN_NAME]["trainings"][training_name]["target"].get() - target = self.config[PLUGIN_NAME]["targets"][target_name] - device_root = self.create_temp_dir() - target["device_root"].set(device_root) - target["device_path"].set("") + self.ensure_training_target_path(training_name) with capture_stdout() as out: self.runcli(PLUGIN_NAME, training_name) diff --git a/test/helper.py b/test/helper.py index 47c682a..91707b4 100644 --- a/test/helper.py +++ b/test/helper.py @@ -261,10 +261,15 @@ def create_temp_dir(self): self._tempdirs.append(temp_dir) return temp_dir - # def remove_temp_dir(self): - # """Delete the temporary directory created by `create_temp_dir`. - # """ - # shutil.rmtree(self.temp_dir) + def ensure_training_target_path(self, training_name): + # Set existing path for target + target_name = self.config[PLUGIN_NAME]["trainings"][training_name]["target"].get() + target = self.config[PLUGIN_NAME]["targets"][target_name] + device_root = self.create_temp_dir() + device_path = target["device_path"].get() + target["device_root"].set(device_root) + full_path = os.path.join(device_root, device_path) + os.makedirs(full_path) @staticmethod def unload_plugins(): @@ -323,8 +328,10 @@ def create_item(self, **values): 'format': 'MP3', } values_.update(values) - values_['title'] = values_['title'].format(item_count) + + print("Creating Item: {}".format(values_)) + values_['db'] = self.lib item = Item(**values_) @@ -346,4 +353,9 @@ def add_single_item_to_library(self, **values): def add_multiple_items_to_library(self, count=10, **values): for i in range(count): - self.add_single_item_to_library(**values) + new_values = values.copy() + for key in values: + if type(values[key]) == list and len(values[key]) == 2: + random_val = randint(values[key][0], values[key][1]) + new_values[key] = random_val + self.add_single_item_to_library(**new_values) From 82221c82cf232931bb903998cc12ff7229ca837d Mon Sep 17 00:00:00 2001 From: Adam Jakab Date: Wed, 18 Mar 2020 11:04:51 +0100 Subject: [PATCH 25/39] wrapping up tests --- beetsplug/goingrunning/command.py | 151 ++++-------------- beetsplug/goingrunning/common.py | 92 +++++++++++ test/config/default.yml | 24 +-- test/functional/002_command_test.py | 233 ++++++++++++++++++++++++++-- test/helper.py | 58 ++++++- test/unit/000_common_test.py | 49 +++++- 6 files changed, 455 insertions(+), 152 deletions(-) diff --git a/beetsplug/goingrunning/command.py b/beetsplug/goingrunning/command.py index 1683db0..0d6276d 100644 --- a/beetsplug/goingrunning/command.py +++ b/beetsplug/goingrunning/command.py @@ -161,9 +161,8 @@ def handle_training(self): return # 1) order items by scoring system (need ordering in config) - self._score_library_items(training, lib_items) - sorted_lib_items = sorted(lib_items, - key=operator.attrgetter('ordering_score')) + GRC.score_library_items(training, lib_items) + sorted_lib_items = sorted(lib_items, key=operator.attrgetter('ordering_score')) # 2) select random items n from the ordered list(T=length) - by # chosing n times song from the remaining songs between 1 and m @@ -176,22 +175,15 @@ def handle_training(self): # @todo: check if total time is close to duration - (config might be # too restrictive or too few songs) - # Show some info - self._say("Training duration: {0}".format( - GRC.get_human_readable_time(duration * 60))) - self._say("Selected song duration: {}".format( - GRC.get_human_readable_time(total_time))) - self._say("Number of songs available: {}".format(len(lib_items))) - self._say("Number of songs selected: {}".format(len(sel_items))) + self._say("Available songs: {}".format(len(lib_items))) + self._say("Selected songs: {}".format(len(sel_items))) + self._say("Planned training duration: {0}".format(GRC.get_human_readable_time(duration * 60))) + self._say("Total song duration: {}".format(GRC.get_human_readable_time(total_time))) # Show the selected songs and exit - # flds = ("ordering_score", "bpm", "year", "length", "ordering_info", - # "artist", "title") - flds = ("bpm", "year", "length", "artist", "title") - # self.display_library_items(sorted_lib_items, flds) - # self._say("="*80) - self.display_library_items(sel_items, flds) + flds = ["bpm", "year", "length", "artist", "title"] + self.display_library_items(sel_items, flds, prefix="Selected: ") # todo: move this inside the nex methods to show what would be done if self.cfg_dry_run: @@ -340,8 +332,8 @@ def _get_items_for_duration(self, items, duration): else: bin_size = 0 - self._say("Estimated number of songs: {}".format(est_num_songs)) - self._say("Bin Size: {}".format(bin_size)) + self.log.debug("Estimated number of songs: {}".format(est_num_songs)) + self.log.debug("Bin Size: {}".format(bin_size)) for i in range(0, est_num_songs): bin_start = round(i * bin_size) @@ -354,98 +346,6 @@ def _get_items_for_duration(self, items, duration): return selected - def _score_library_items(self, training: Subview, items): - ordering = {} - fields = [] - if training["ordering"].exists() and len(training["ordering"].keys()) > 0: - ordering = training["ordering"].get() - fields = list(ordering.keys()) - - default_field_data = { - "min": 99999999.9, - "max": 0.0, - "delta": 0.0, - "step": 0.0, - "weight": 100 - } - - # Build Order Info - order_info = {} - for field in fields: - field_name = field.strip() - order_info[field_name] = default_field_data.copy() - order_info[field_name]["weight"] = ordering[field] - - # self._say("ORDER INFO #1: {0}".format(order_info)) - - # Populate Order Info - for field_name in order_info.keys(): - field_data = order_info[field_name] - _min, _max, _sum, _avg = GRC.get_min_max_sum_avg_for_items(items, - field_name) - field_data["min"] = _min - field_data["max"] = _max - - # self._say("ORDER INFO #2: {0}".format(order_info)) - - # todo: this will not work anymore - find a better way - # Remove bad items from Order Info - # bad_oi = [field for field in order_info if - # order_info[field]["min"] == default_field_data["min"] and - # order_info[field]["max"] == default_field_data["max"] - # ] - # for field in bad_oi: del order_info[field] - # self._say("ORDER INFO #3: {0}".format(order_info)) - - # Calculate other values in Order Info - for field_name in order_info.keys(): - field_data = order_info[field_name] - field_data["delta"] = field_data["max"] - field_data["min"] - if field_data["delta"] > 0: - field_data["step"] = round(100 / field_data["delta"], 3) - else: - field_data["step"] = 0 - - # self._say("ORDER INFO: {0}".format(order_info)) - - # Score the library items - for item in items: - item: Item - item["ordering_score"] = 0 - item["ordering_info"] = {} - for field_name in order_info.keys(): - field_data = order_info[field_name] - try: - field_value = round(float(item.get(field_name, None)), 3) - except ValueError: - field_value = None - except TypeError: - field_value = None - - if field_value is None: - # Make up average value - field_value = round(field_data["delta"] / 2, 3) - - distance_from_min = round(field_value - field_data["min"], 3) - - # This is linear (we could some day use different models) - # field_score should always be between 0 and 100 - field_score = round(distance_from_min * field_data["step"], 3) - field_score = field_score if field_score > 0 else 0 - field_score = field_score if field_score < 100 else 100 - - weighted_field_score = round( - field_data["weight"] * field_score / 100, 3) - - item["ordering_score"] = round( - item["ordering_score"] + weighted_field_score, 3) - - item["ordering_info"][field_name] = { - "dist": distance_from_min, - "fld_score": field_score, - "wfld_score": weighted_field_score - } - def _gather_query_elements(self, training: Subview): """Order(strongest to weakest): command -> training -> flavours """ @@ -494,9 +394,8 @@ def _retrieve_library_items(self, training: Subview): return self.lib.items(parsed_query) - - def display_library_items(self, items, fields): - fmt = "" + def display_library_items(self, items, fields, prefix=""): + fmt = prefix for field in fields: fmt += "[{0}:{{{0}}}]".format(field) @@ -513,18 +412,6 @@ def display_library_items(self, items, fields): except IndexError: pass - def verify_configuration_upgrade(self): - """Check if user has old(pre v1.1.1) configuration keys in config - """ - trainings = list(self.config["trainings"].keys()) - training_names = [s for s in trainings if s != "fallback"] - for training_name in training_names: - training: Subview = self.config["trainings"][training_name] - tkeys = training.keys() - for tkey in tkeys: - if tkey in ["song_bpm", "song_len"]: - raise RuntimeError("Offending key in training({}): {}".format(training_name, tkey)) - def list_trainings(self): """ # @todo: order keys @@ -565,7 +452,21 @@ def show_version_information(self): from beetsplug.goingrunning.version import __version__ self._say("Goingrunning(beets-{}) plugin for Beets: v{}".format(__PLUGIN_NAME__, __version__)) + def verify_configuration_upgrade(self): + """Check if user has old(pre v1.1.1) configuration keys in config + """ + trainings = list(self.config["trainings"].keys()) + training_names = [s for s in trainings if s != "fallback"] + for training_name in training_names: + training: Subview = self.config["trainings"][training_name] + tkeys = training.keys() + for tkey in tkeys: + if tkey in ["song_bpm", "song_len"]: + raise RuntimeError("Offending key in training({}): {}".format(training_name, tkey)) + def _say(self, msg): + """Log and print to stdout + """ self.log.debug(msg) if not self.cfg_quiet: print(msg) diff --git a/beetsplug/goingrunning/common.py b/beetsplug/goingrunning/common.py index 2e2b3fd..8b0e543 100644 --- a/beetsplug/goingrunning/common.py +++ b/beetsplug/goingrunning/common.py @@ -121,3 +121,95 @@ def get_min_max_sum_avg_for_items(items, field_name): _avg = round(_sum / _cnt, 3) return _min, _max, _sum, _avg + + +def score_library_items(training: Subview, items): + ordering = {} + fields = [] + if training["ordering"].exists() and len(training["ordering"].keys()) > 0: + ordering = training["ordering"].get() + fields = list(ordering.keys()) + + default_field_data = { + "min": 99999999.9, + "max": 0.0, + "delta": 0.0, + "step": 0.0, + "weight": 100 + } + + # Build Order Info + order_info = {} + for field in fields: + field_name = field.strip() + order_info[field_name] = default_field_data.copy() + order_info[field_name]["weight"] = ordering[field] + + # self._say("ORDER INFO #1: {0}".format(order_info)) + + # Populate Order Info + for field_name in order_info.keys(): + field_data = order_info[field_name] + _min, _max, _sum, _avg = get_min_max_sum_avg_for_items(items, field_name) + field_data["min"] = _min + field_data["max"] = _max + + # self._say("ORDER INFO #2: {0}".format(order_info)) + + # todo: this will not work anymore - find a better way + # Remove bad items from Order Info + # bad_oi = [field for field in order_info if + # order_info[field]["min"] == default_field_data["min"] and + # order_info[field]["max"] == default_field_data["max"] + # ] + # for field in bad_oi: del order_info[field] + # self._say("ORDER INFO #3: {0}".format(order_info)) + + # Calculate other values in Order Info + for field_name in order_info.keys(): + field_data = order_info[field_name] + field_data["delta"] = field_data["max"] - field_data["min"] + if field_data["delta"] > 0: + field_data["step"] = round(100 / field_data["delta"], 3) + else: + field_data["step"] = 0 + + # self._say("ORDER INFO: {0}".format(order_info)) + + # Score the library items + for item in items: + item: Item + item["ordering_score"] = 0 + item["ordering_info"] = {} + for field_name in order_info.keys(): + field_data = order_info[field_name] + try: + field_value = round(float(item.get(field_name, None)), 3) + except ValueError: + field_value = None + except TypeError: + field_value = None + + if field_value is None: + # Make up average value + field_value = round(field_data["delta"] / 2, 3) + + distance_from_min = round(field_value - field_data["min"], 3) + + # This is linear (we could some day use different models) + # field_score should always be between 0 and 100 + field_score = round(distance_from_min * field_data["step"], 3) + field_score = field_score if field_score > 0 else 0 + field_score = field_score if field_score < 100 else 100 + + weighted_field_score = round( + field_data["weight"] * field_score / 100, 3) + + item["ordering_score"] = round( + item["ordering_score"] + weighted_field_score, 3) + + item["ordering_info"][field_name] = { + "distance_from_min": distance_from_min, + "field_score": field_score, + "weighted_field_score": weighted_field_score + } diff --git a/test/config/default.yml b/test/config/default.yml index 48a4137..d1ac30e 100644 --- a/test/config/default.yml +++ b/test/config/default.yml @@ -27,21 +27,18 @@ goingrunning: duration: 10 target: MPD_1 training-2: - alias: "Born to run" - query: - bpm: 170..180 - length: 90..180 + alias: "Select songs by flavour only" + use_flavours: [runlikehell, 60s] ordering: - year+: 50 - bpm+: 25 - duration: 25 + bpm: 100 + duration: 10 target: MPD_1 - marathon: - alias: "Born to run" + training-3: + alias: "Select songs by both query and flavour" query: bpm: 145..160 - length: 120..600 - duration: 300 + use_flavours: [sunshine] + duration: 10 target: MPD_1 one-hour-run: alias: "Born to run" @@ -61,6 +58,11 @@ goingrunning: bad-target-2: target: MPD_3 flavours: + runlikehell: + bpm: 170.. + mood_aggressive: 0.7.. + 60s: + year: 1960..1969 sunshine: genre: Reggae diff --git a/test/functional/002_command_test.py b/test/functional/002_command_test.py index ddb5f0b..91b8e9a 100644 --- a/test/functional/002_command_test.py +++ b/test/functional/002_command_test.py @@ -8,7 +8,7 @@ from beets.util.confit import Subview from test.helper import FunctionalTestHelper, Assertions, PLUGIN_NAME, PLUGIN_SHORT_DESCRIPTION, capture_log, \ - capture_stdout + capture_stdout, get_single_line_from_output, get_value_separated_from_output, convert_time_to_seconds class CommandTest(FunctionalTestHelper, Assertions): @@ -40,7 +40,7 @@ def test_training_listing_default(self): output = out.getvalue() self.assertIn("::: training-1", output) self.assertIn("::: training-2", output) - self.assertIn("::: marathon", output) + self.assertIn("::: training-3", output) def test_training_handling_inexistent(self): training_name = "sitting_on_the_sofa" @@ -50,7 +50,7 @@ def test_training_handling_inexistent(self): self.assertIn("There is no training[{0}] registered with this name!".format(training_name), out.getvalue()) def test_training_song_count(self): - training_name = "marathon" + training_name = "training-1" with capture_stdout() as out: self.runcli(PLUGIN_NAME, training_name, "--count") self.assertIn("Number of songs available: {}".format(0), out.getvalue()) @@ -60,7 +60,7 @@ def test_training_song_count(self): self.assertIn("Number of songs available: {}".format(0), out.getvalue()) def test_training_no_songs(self): - training_name = "marathon" + training_name = "training-1" with capture_stdout() as out: self.runcli(PLUGIN_NAME, training_name) @@ -88,14 +88,229 @@ def test_training_bad_target(self): target_path = "/media/this/probably/does/not/exist/" self.assertIn("The target[{0}] path does not exist: {1}".format(target_name, target_path), out.getvalue()) - def test_training_with_songs_multiple_config(self): - self.add_multiple_items_to_library(count=10, bpm=[120, 180], length=[120, 240]) + def test_handling_training_1(self): + """Simple query based song selection + Check that command run until the end + """ training_name = "training-1" + + self.add_multiple_items_to_library(count=10, bpm=[120, 180], length=[120, 240]) + self.ensure_training_target_path(training_name) with capture_stdout() as out: self.runcli(PLUGIN_NAME, training_name) - self.assertIn("Handling training: {0}".format(training_name), out.getvalue()) - self.assertIn("Number of songs selected:", out.getvalue()) - self.assertIn("Run!", out.getvalue()) + """ Output for "training-1": + + Handling training: training-1 + Available songs: 10 + Selected songs: 2 + Planned training duration: 0:10:00 + Total song duration: 0:05:55 + Selected: [bpm:137][year:0][length:175.0][artist:the ärtist][title:tïtle 7] + Selected: [bpm:139][year:0][length:180.0][artist:the ärtist][title:tïtle 6] + Cleaning target[MPD_1]: /private/var/folders/yv/9ntm56m10ql9wf_1zkbw74hr0000gp/T/tmpa2soyuro/Music + Deleting additional files: ['xyz.txt'] + Copying to target[MPD_1]: /private/var/folders/yv/9ntm56m10ql9wf_1zkbw74hr0000gp/T/tmpa2soyuro/Music + Run! + + """ + + output = out.getvalue() + + prefix = "Handling training:" + self.assertIn(prefix, output) + value = get_value_separated_from_output(output, prefix) + self.assertEqual(training_name, value) + + prefix = "Available songs:" + self.assertIn(prefix, output) + value = int(get_value_separated_from_output(output, prefix)) + self.assertEqual(10, value) + + prefix = "Selected songs:" + self.assertIn(prefix, output) + value = int(get_value_separated_from_output(output, prefix)) + self.assertGreater(value, 0) + self.assertLessEqual(value, 10) + + prefix = "Planned training duration:" + self.assertIn(prefix, output) + value = get_value_separated_from_output(output, prefix) + seconds = convert_time_to_seconds(value) + self.assertEqual("0:10:00", value) + self.assertEqual(600, seconds) + + # Do not test for efficiency here + prefix = "Total song duration:" + self.assertIn(prefix, output) + value = get_value_separated_from_output(output, prefix) + seconds = convert_time_to_seconds(value) + self.assertGreater(seconds, 0) + + prefix = "Run!" + line = get_single_line_from_output(output, prefix) + self.assertEqual(prefix, line) + + def test_handling_training_2(self): + """Simple flavour based song selection + Check that command run until the end + """ + training_name = "training-2" + + # Add matching items + self.add_multiple_items_to_library(count=10, + bpm=[170, 200], + mood_aggressive=[0.7, 1], + year=[1960, 1969], + length=[120, 240] + ) + # Add not matching items + self.add_multiple_items_to_library(count=10, + bpm=[120, 150], + mood_aggressive=[0.2, 0.4], + year=[1930, 1950], + length=[120, 240] + ) + + self.ensure_training_target_path(training_name) + + with capture_stdout() as out: + self.runcli(PLUGIN_NAME, training_name) + + """ Output for "training-2": + + Handling training: training-2 + Available songs: 10 + Selected songs: 3 + Planned training duration: 0:10:00 + Total song duration: 0:09:03 + Selected: [bpm:175][year:1967][length:174.0][artist:the ärtist][title:tïtle 6] + Selected: [bpm:181][year:1962][length:206.0][artist:the ärtist][title:tïtle 4] + Selected: [bpm:197][year:1968][length:163.0][artist:the ärtist][title:tïtle 1] + Cleaning target[MPD_1]: /private/var/folders/yv/9ntm56m10ql9wf_1zkbw74hr0000gp/T/tmpgoh3gyo5/Music + Deleting additional files: ['xyz.txt'] + Copying to target[MPD_1]: /private/var/folders/yv/9ntm56m10ql9wf_1zkbw74hr0000gp/T/tmpgoh3gyo5/Music + Run! + + """ + + output = out.getvalue() + + prefix = "Handling training:" + self.assertIn(prefix, output) + value = get_value_separated_from_output(output, prefix) + self.assertEqual(training_name, value) + + prefix = "Available songs:" + self.assertIn(prefix, output) + value = int(get_value_separated_from_output(output, prefix)) + self.assertEqual(10, value) + + prefix = "Selected songs:" + self.assertIn(prefix, output) + value = int(get_value_separated_from_output(output, prefix)) + self.assertGreater(value, 0) + self.assertLessEqual(value, 10) + + prefix = "Planned training duration:" + self.assertIn(prefix, output) + value = get_value_separated_from_output(output, prefix) + seconds = convert_time_to_seconds(value) + self.assertEqual("0:10:00", value) + self.assertEqual(600, seconds) + + # Do not test for efficiency here + prefix = "Total song duration:" + self.assertIn(prefix, output) + value = get_value_separated_from_output(output, prefix) + seconds = convert_time_to_seconds(value) + self.assertGreater(seconds, 0) + + prefix = "Run!" + line = get_single_line_from_output(output, prefix) + self.assertEqual(prefix, line) + + def test_handling_training_3(self): + """Simple query + flavour based song selection + Check that command run until the end + """ + training_name = "training-3" + + # Add matching items for query + flavour + self.add_multiple_items_to_library(count=10, + bpm=[145, 160], + genre="Reggae", + length=[120, 240] + ) + # Add partially matching items + self.add_multiple_items_to_library(count=10, + bpm=[145, 160], + genre="Rock", + length=[120, 240] + ) + + self.add_multiple_items_to_library(count=10, + bpm=[100, 140], + genre="Reggae", + length=[120, 240] + ) + + self.ensure_training_target_path(training_name) + + with capture_stdout() as out: + self.runcli(PLUGIN_NAME, training_name) + + """ Output for "training-2": + + Handling training: training-3 + Available songs: 10 + Selected songs: 3 + Planned training duration: 0:10:00 + Total song duration: 0:07:26 + Selected: [bpm:158][year:0][length:141.0][artist:the ärtist][title:tïtle 3] + Selected: [bpm:154][year:0][length:158.0][artist:the ärtist][title:tïtle 5] + Selected: [bpm:152][year:0][length:147.0][artist:the ärtist][title:tïtle 10] + Cleaning target[MPD_1]: /private/var/folders/yv/9ntm56m10ql9wf_1zkbw74hr0000gp/T/tmpgnxlpeih/Music + Deleting additional files: ['xyz.txt'] + Copying to target[MPD_1]: /private/var/folders/yv/9ntm56m10ql9wf_1zkbw74hr0000gp/T/tmpgnxlpeih/Music + Run! + + """ + + output = out.getvalue() + + prefix = "Handling training:" + self.assertIn(prefix, output) + value = get_value_separated_from_output(output, prefix) + self.assertEqual(training_name, value) + + prefix = "Available songs:" + self.assertIn(prefix, output) + value = int(get_value_separated_from_output(output, prefix)) + self.assertEqual(10, value) + + prefix = "Selected songs:" + self.assertIn(prefix, output) + value = int(get_value_separated_from_output(output, prefix)) + self.assertGreater(value, 0) + self.assertLessEqual(value, 10) + + prefix = "Planned training duration:" + self.assertIn(prefix, output) + value = get_value_separated_from_output(output, prefix) + seconds = convert_time_to_seconds(value) + self.assertEqual("0:10:00", value) + self.assertEqual(600, seconds) + + # Do not test for efficiency here + prefix = "Total song duration:" + self.assertIn(prefix, output) + value = get_value_separated_from_output(output, prefix) + seconds = convert_time_to_seconds(value) + self.assertGreater(seconds, 0) + + prefix = "Run!" + line = get_single_line_from_output(output, prefix) + self.assertEqual(prefix, line) diff --git a/test/helper.py b/test/helper.py index 91707b4..0a063e0 100644 --- a/test/helper.py +++ b/test/helper.py @@ -12,7 +12,7 @@ import sys import tempfile from contextlib import contextmanager -from random import randint +from random import randint, uniform from unittest import TestCase import beets @@ -112,6 +112,36 @@ def get_plugin_configuration(cfg): return config[PLUGIN_NAME] +def get_single_line_from_output(text: str, prefix: str): + selected_line = "" + lines = text.split("\n") + for line in lines: + if prefix in line: + selected_line = line + break + + return selected_line + + +def convert_time_to_seconds(time: str): + return sum(x * int(t) for x, t in zip([3600, 60, 1], time.split(":"))) + + +def get_value_separated_from_output(fulltext: str, prefix: str): + """Separate the value that has been printed to the stdout in the format of: + prefix: value + """ + value = None + line = get_single_line_from_output(fulltext, prefix) + # print("SL:{}".format(line)) + + if prefix in line: + value = line.replace(prefix, "") + value = value.strip() + + return value + + def _convert_args(args): """Convert args to strings """ @@ -155,6 +185,24 @@ def create_item(self, **values): return item + def create_multiple_items(self, count=10, **values): + items = [] + for i in range(count): + new_values = values.copy() + for key in values: + if type(values[key]) == list and len(values[key]) == 2: + if type(values[key][0]) == float or type(values[key][1]) == float: + random_val = uniform(values[key][0], values[key][1]) + elif type(values[key][0]) == int and type(values[key][1]) == int: + random_val = randint(values[key][0], values[key][1]) + else: + raise ValueError("Elements for key({}) are neither float nor int!") + + new_values[key] = random_val + items.append(self.create_item(**new_values)) + + return items + def _get_item_count(self): self.__item_count += 1 return self.__item_count @@ -356,6 +404,12 @@ def add_multiple_items_to_library(self, count=10, **values): new_values = values.copy() for key in values: if type(values[key]) == list and len(values[key]) == 2: - random_val = randint(values[key][0], values[key][1]) + if type(values[key][0]) == float or type(values[key][1]) == float: + random_val = uniform(values[key][0], values[key][1]) + elif type(values[key][0]) == int and type(values[key][1]) == int: + random_val = randint(values[key][0], values[key][1]) + else: + raise ValueError("Elements for key({}) are neither float nor int!") + new_values[key] = random_val self.add_single_item_to_library(**new_values) diff --git a/test/unit/000_common_test.py b/test/unit/000_common_test.py index 0623cd5..5982031 100644 --- a/test/unit/000_common_test.py +++ b/test/unit/000_common_test.py @@ -53,11 +53,16 @@ def test_get_training_attribute(self): cfg = { "trainings": { "fallback": { - "bpm": "120..", + "query": { + "bpm": "120..", + }, "target": "MPD1", }, "10K": { - "bpm": "180..", + "query": { + "bpm": "180..", + "length": "60..240", + }, "use_flavours": ["f1", "f2"], } } @@ -66,11 +71,12 @@ def test_get_training_attribute(self): training = config["trainings"]["10K"] # Direct - self.assertEqual("180..", common.get_training_attribute(training, "bpm")) - self.assertEqual(["f1", "f2"], common.get_training_attribute(training, "use_flavours")) + self.assertEqual(cfg["trainings"]["10K"]["query"], common.get_training_attribute(training, "query")) + self.assertEqual(cfg["trainings"]["10K"]["use_flavours"], + common.get_training_attribute(training, "use_flavours")) # Fallback - self.assertEqual("MPD1", common.get_training_attribute(training, "target")) + self.assertEqual(cfg["trainings"]["fallback"]["target"], common.get_training_attribute(training, "target")) # Inexistent self.assertEqual(None, common.get_training_attribute(training, "hoppa")) @@ -139,3 +145,36 @@ def test_get_min_max_sum_avg_for_items(self): self.assertEqual(100, _max) self.assertEqual(100, _sum) self.assertEqual(100, _avg) + + def test_score_library_items(self): + cfg = { + "trainings": { + "10K": { + "ordering": { + "bpm": 100, + } + } + } + } + config = get_plugin_configuration(cfg) + training = config["trainings"]["10K"] + + items = [ + self.create_item(id=1, bpm=140), + self.create_item(id=2, bpm=120), + self.create_item(id=3, bpm=100), + ] + common.score_library_items(training, items) + + _id = 0 + for item in items: + # print(item.evaluate_template("$id - $bpm - $ordering_score")) + _id += 1 + self.assertEqual(_id, item.get("id")) + self.assertTrue(hasattr(item, "ordering_score")) + self.assertIsInstance(item.get("ordering_score"), float) + self.assertTrue(hasattr(item, "ordering_info")) + self.assertIsInstance(item.get("ordering_info"), dict) + + scores = [item.get("ordering_score") for item in items] + self.assertEqual([100, 50, 0], scores) From f11a33ba421c940d2115f2b5ed3e5768bb6b6a9c Mon Sep 17 00:00:00 2001 From: Adam Jakab Date: Wed, 18 Mar 2020 11:14:32 +0100 Subject: [PATCH 26/39] added plugin short name check --- test/functional/000_basic_test.py | 14 ++++++++++++-- test/helper.py | 2 +- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/test/functional/000_basic_test.py b/test/functional/000_basic_test.py index 55d0d19..e6c1669 100644 --- a/test/functional/000_basic_test.py +++ b/test/functional/000_basic_test.py @@ -5,8 +5,11 @@ # License: See LICENSE.txt # -from test.helper import FunctionalTestHelper, Assertions, PLUGIN_NAME, PLUGIN_SHORT_DESCRIPTION, capture_log, \ - capture_stdout +from test.helper import ( + FunctionalTestHelper, Assertions, + PLUGIN_NAME, PLUGIN_SHORT_NAME, PLUGIN_SHORT_DESCRIPTION, + capture_log, capture_stdout +) class BasicTest(FunctionalTestHelper, Assertions): @@ -33,3 +36,10 @@ def test_plugin_no_arguments(self): self.runcli(PLUGIN_NAME) self.assertIn("Usage: beet goingrunning [training] [options] [QUERY...]", out.getvalue()) + + def test_plugin_shortname_no_arguments(self): + self.reset_beets(config_file=b"empty.yml") + with capture_stdout() as out: + self.runcli(PLUGIN_SHORT_NAME) + + self.assertIn("Usage: beet goingrunning [training] [options] [QUERY...]", out.getvalue()) diff --git a/test/helper.py b/test/helper.py index 0a063e0..1eb75e6 100644 --- a/test/helper.py +++ b/test/helper.py @@ -37,11 +37,11 @@ # Values PLUGIN_NAME = u'goingrunning' +PLUGIN_SHORT_NAME = u'run' PLUGIN_SHORT_DESCRIPTION = u'run with the music that matches your training sessions' class LogCapture(logging.Handler): - def __init__(self): super(LogCapture, self).__init__() self.messages = [] From 3de47643a635e93082740a97cb8d94e33cd3d7e9 Mon Sep 17 00:00:00 2001 From: Adam Jakab Date: Wed, 18 Mar 2020 11:17:27 +0100 Subject: [PATCH 27/39] changed GRC naming to module name: common --- beetsplug/goingrunning/command.py | 42 +++++++++++++++---------------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/beetsplug/goingrunning/command.py b/beetsplug/goingrunning/command.py index 0d6276d..c20e508 100644 --- a/beetsplug/goingrunning/command.py +++ b/beetsplug/goingrunning/command.py @@ -20,7 +20,7 @@ from beets.ui import Subcommand, decargs from beets.util.confit import Subview, NotFoundError -from beetsplug.goingrunning import common as GRC +from beetsplug.goingrunning import common # The plugin __PLUGIN_NAME__ = u'goingrunning' @@ -41,7 +41,7 @@ class GoingRunningCommand(Subcommand): def __init__(self, cfg): self.config = cfg - self.log = GRC.get_beets_logger() + self.log = common.get_beets_logger() self.parser = OptionParser(usage='beet goingrunning [training] [options] [QUERY...]') @@ -161,25 +161,25 @@ def handle_training(self): return # 1) order items by scoring system (need ordering in config) - GRC.score_library_items(training, lib_items) + common.score_library_items(training, lib_items) sorted_lib_items = sorted(lib_items, key=operator.attrgetter('ordering_score')) # 2) select random items n from the ordered list(T=length) - by # chosing n times song from the remaining songs between 1 and m # where m = T/n - duration = GRC.get_training_attribute(training, "duration") - # sel_items = GRC.get_randomized_items(lib_items, duration) + duration = common.get_training_attribute(training, "duration") + # sel_items = common.get_randomized_items(lib_items, duration) sel_items = self._get_items_for_duration(sorted_lib_items, duration) - total_time = GRC.get_duration_of_items(sel_items) + total_time = common.get_duration_of_items(sel_items) # @todo: check if total time is close to duration - (config might be # too restrictive or too few songs) # Show some info self._say("Available songs: {}".format(len(lib_items))) self._say("Selected songs: {}".format(len(sel_items))) - self._say("Planned training duration: {0}".format(GRC.get_human_readable_time(duration * 60))) - self._say("Total song duration: {}".format(GRC.get_human_readable_time(total_time))) + self._say("Planned training duration: {0}".format(common.get_human_readable_time(duration * 60))) + self._say("Total song duration: {}".format(common.get_human_readable_time(total_time))) # Show the selected songs and exit flds = ["bpm", "year", "length", "artist", "title"] @@ -194,7 +194,7 @@ def handle_training(self): self._say("Run!") def _clean_target_path(self, training: Subview): - target_name = GRC.get_training_attribute(training, "target") + target_name = common.get_training_attribute(training, "target") if self._get_target_attribute_for_training(training, "clean_target"): dst_path = self._get_destination_path_for_training(training) @@ -232,7 +232,7 @@ def _clean_target_path(self, training: Subview): os.remove(dst_path) def _copy_items_to_target(self, training: Subview, rnd_items): - target_name = GRC.get_training_attribute(training, "target") + target_name = common.get_training_attribute(training, "target") dst_path = self._get_destination_path_for_training(training) self._say("Copying to target[{0}]: {1}".format(target_name, dst_path)) @@ -257,7 +257,7 @@ def random_string(length=6): cnt += 1 def _get_target_for_training(self, training: Subview): - target_name = GRC.get_training_attribute(training, "target") + target_name = common.get_training_attribute(training, "target") self.log.debug("Finding target: {0}".format(target_name)) if not self.config["targets"][target_name].exists(): @@ -267,7 +267,7 @@ def _get_target_for_training(self, training: Subview): return self.config["targets"][target_name] def _get_target_attribute_for_training(self, training: Subview, attrib: str = "name"): - target_name = GRC.get_training_attribute(training, "target") + target_name = common.get_training_attribute(training, "target") self.log.debug("Getting attribute[{0}] for target: {1}".format(attrib, target_name)) target = self._get_target_for_training(training) @@ -283,7 +283,7 @@ def _get_target_attribute_for_training(self, training: Subview, attrib: str = "n except NotFoundError: attrib_val = None else: - attrib_val = GRC.get_target_attribute(target, attrib) + attrib_val = common.get_target_attribute(target, attrib) self.log.debug( "Found target[{0}] attribute[{1}] path: {2}".format(target_name, attrib, attrib_val)) @@ -291,7 +291,7 @@ def _get_target_attribute_for_training(self, training: Subview, attrib: str = "n return attrib_val def _get_destination_path_for_training(self, training: Subview): - target_name = GRC.get_training_attribute(training, "target") + target_name = common.get_training_attribute(training, "target") root = self._get_target_attribute_for_training(training, "device_root") path = self._get_target_attribute_for_training(training, "device_path") path = path or "" @@ -320,7 +320,7 @@ def _get_destination_path_for_training(self, training: Subview): def _get_items_for_duration(self, items, duration): selected = [] total_time = 0 - _min, _max, _sum, _avg = GRC.get_min_max_sum_avg_for_items(items, "length") + _min, _max, _sum, _avg = common.get_min_max_sum_avg_for_items(items, "length") if _avg > 0: est_num_songs = round(duration * 60 / _avg) @@ -354,18 +354,18 @@ def _gather_query_elements(self, training: Subview): flavour_query = [] # Append the query elements from the configuration - tconf = GRC.get_training_attribute(training, "query") + tconf = common.get_training_attribute(training, "query") if tconf: for key in tconf.keys(): - training_query.append(GRC.get_beet_query_formatted_string(key, tconf.get(key))) + training_query.append(common.get_beet_query_formatted_string(key, tconf.get(key))) # Append the query elements from the flavours defined on the training - flavours = GRC.get_training_attribute(training, "use_flavours") + flavours = common.get_training_attribute(training, "use_flavours") if flavours: flavours = [flavours] if type(flavours) == str else flavours for flavour_name in flavours: flavour: Subview = self.config["flavours"][flavour_name] - flavour_query += GRC.get_flavour_elements(flavour) + flavour_query += common.get_flavour_elements(flavour) self.log.debug("Command query elements: {}".format(command_query)) self.log.debug("Training query elements: {}".format(training_query)) @@ -435,11 +435,11 @@ def list_training_attributes(self, training_name: str): training_keys = training.keys() self._say("{0} ::: {1}".format("=" * 40, training_name)) - training_keys = list(set(GRC.MUST_HAVE_TRAINING_KEYS) | set(training_keys)) + training_keys = list(set(common.MUST_HAVE_TRAINING_KEYS) | set(training_keys)) training_keys.sort() for tkey in training_keys: - tval = GRC.get_training_attribute(training, tkey) + tval = common.get_training_attribute(training, tkey) if isinstance(tval, dict): value = [] for k in tval: From cf631a30ce5ecc7bc9f9102f3f5b77e226072ddc Mon Sep 17 00:00:00 2001 From: Adam Jakab Date: Wed, 18 Mar 2020 11:29:32 +0100 Subject: [PATCH 28/39] readme fixes --- README.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 6f4cb59..171f29f 100644 --- a/README.md +++ b/README.md @@ -149,6 +149,8 @@ Any key not defined in a specific training will be looked up from the `fallback` ### Flavours The flavours section serves the purpose of defining named queries. If you have 5 different high intensity trainings different in length but sharing queries about bpm, mood and loudness, you can create a single definition here, called flavour, and reuse that flavour in your different trainings with the `use_flavours` key. +**Note**: Because flavours are only used to group query elements, the `query` key should not be used here (like it is in trainings). + ```yaml goingrunning: flavours: @@ -191,13 +193,13 @@ Check what would be done for the `10K` training: $ beet goingrunning 10K --dry-run -Let's go! Copy your songs to your target based on the `10K` training and using the plugin shorthand: +Let's go! Copy your songs to your device based on the `10K` training and using the plugin shorthand: + + $ beet run 10K - $ beet run longrun - s Do the same as above but today you feel Ska: - $ beet run longrun genre:ska + $ beet run 10K genre:ska ## Issues From 6b3197e0ec983bed3828e5c2bba1464d5d027d17 Mon Sep 17 00:00:00 2001 From: Adam Jakab Date: Wed, 18 Mar 2020 11:29:58 +0100 Subject: [PATCH 29/39] code cleanup --- beetsplug/goingrunning/command.py | 2 +- beetsplug/goingrunning/common.py | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/beetsplug/goingrunning/command.py b/beetsplug/goingrunning/command.py index c20e508..ccf993c 100644 --- a/beetsplug/goingrunning/command.py +++ b/beetsplug/goingrunning/command.py @@ -276,7 +276,7 @@ def _get_target_attribute_for_training(self, training: Subview, attrib: str = "n if attrib == "name": attrib_val = target_name - if attrib in ("device_root", "device_path", "delete_from_device"): + elif attrib in ("device_root", "device_path", "delete_from_device"): # these should NOT propagate up try: attrib_val = target[attrib].get() diff --git a/beetsplug/goingrunning/common.py b/beetsplug/goingrunning/common.py index 8b0e543..f95fcd6 100644 --- a/beetsplug/goingrunning/common.py +++ b/beetsplug/goingrunning/common.py @@ -9,7 +9,6 @@ from beets.library import Item from beets.util.confit import Subview -from beets.random import random_objs MUST_HAVE_TRAINING_KEYS = ['query', 'duration', 'target'] MUST_HAVE_TARGET_KEYS = ['device_root', 'device_path'] From b3ea055c373dd7e4181ce41bdab2509cbe6e2589 Mon Sep 17 00:00:00 2001 From: jackisback Date: Wed, 18 Mar 2020 11:53:25 +0100 Subject: [PATCH 30/39] revisited coverage --- .coveragerc | 3 --- 1 file changed, 3 deletions(-) diff --git a/.coveragerc b/.coveragerc index 13cc757..948914f 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1,8 +1,5 @@ [report] omit = - */pyshared/* - */python?.?/* - */site-packages/nose/* */test/* beetsplug/__init__.py exclude_lines = From 24e48779de1cc24bbdc74324d85352f5d4c944ba Mon Sep 17 00:00:00 2001 From: jackisback Date: Wed, 18 Mar 2020 11:55:28 +0100 Subject: [PATCH 31/39] removing IDE related settings --- .idea/.gitignore | 2 -- .idea/BeetsPluginGoingRunning.iml | 11 ----------- .idea/copyright/JACK.xml | 6 ------ .idea/copyright/profiles_settings.xml | 3 --- .idea/dataSources.xml | 11 ----------- .idea/inspectionProfiles/profiles_settings.xml | 7 ------- .idea/misc.xml | 10 ---------- .idea/modules.xml | 8 -------- .idea/other.xml | 6 ------ .idea/rSettings.xml | 4 ---- .idea/terminal.xml | 8 -------- .idea/vcs.xml | 6 ------ 12 files changed, 82 deletions(-) delete mode 100644 .idea/.gitignore delete mode 100644 .idea/BeetsPluginGoingRunning.iml delete mode 100644 .idea/copyright/JACK.xml delete mode 100644 .idea/copyright/profiles_settings.xml delete mode 100644 .idea/dataSources.xml delete mode 100644 .idea/inspectionProfiles/profiles_settings.xml delete mode 100644 .idea/misc.xml delete mode 100644 .idea/modules.xml delete mode 100644 .idea/other.xml delete mode 100644 .idea/rSettings.xml delete mode 100644 .idea/terminal.xml delete mode 100644 .idea/vcs.xml diff --git a/.idea/.gitignore b/.idea/.gitignore deleted file mode 100644 index 5c98b42..0000000 --- a/.idea/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -# Default ignored files -/workspace.xml \ No newline at end of file diff --git a/.idea/BeetsPluginGoingRunning.iml b/.idea/BeetsPluginGoingRunning.iml deleted file mode 100644 index 8c36f29..0000000 --- a/.idea/BeetsPluginGoingRunning.iml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - - - \ No newline at end of file diff --git a/.idea/copyright/JACK.xml b/.idea/copyright/JACK.xml deleted file mode 100644 index 271b970..0000000 --- a/.idea/copyright/JACK.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - \ No newline at end of file diff --git a/.idea/copyright/profiles_settings.xml b/.idea/copyright/profiles_settings.xml deleted file mode 100644 index eaf7386..0000000 --- a/.idea/copyright/profiles_settings.xml +++ /dev/null @@ -1,3 +0,0 @@ - - - \ No newline at end of file diff --git a/.idea/dataSources.xml b/.idea/dataSources.xml deleted file mode 100644 index e7dd5c9..0000000 --- a/.idea/dataSources.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - sqlite.xerial - true - org.sqlite.JDBC - jdbc:sqlite:$USER_HOME$/.config/beets/real_library.db - - - \ No newline at end of file diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml deleted file mode 100644 index dd4c951..0000000 --- a/.idea/inspectionProfiles/profiles_settings.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml deleted file mode 100644 index 6ccece8..0000000 --- a/.idea/misc.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml deleted file mode 100644 index cb95c83..0000000 --- a/.idea/modules.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/.idea/other.xml b/.idea/other.xml deleted file mode 100644 index a708ec7..0000000 --- a/.idea/other.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - \ No newline at end of file diff --git a/.idea/rSettings.xml b/.idea/rSettings.xml deleted file mode 100644 index 78e3b75..0000000 --- a/.idea/rSettings.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - - \ No newline at end of file diff --git a/.idea/terminal.xml b/.idea/terminal.xml deleted file mode 100644 index 14eaed1..0000000 --- a/.idea/terminal.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml deleted file mode 100644 index 94a25f7..0000000 --- a/.idea/vcs.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file From f8a181581f4f89d7f3e69ed502bb0d4b727ff1a2 Mon Sep 17 00:00:00 2001 From: jackisback Date: Wed, 18 Mar 2020 11:58:16 +0100 Subject: [PATCH 32/39] update --- .gitignore | 29 ++--------------------------- 1 file changed, 2 insertions(+), 27 deletions(-) diff --git a/.gitignore b/.gitignore index 5ae8091..0411853 100644 --- a/.gitignore +++ b/.gitignore @@ -136,30 +136,5 @@ dmypy.json # Pyre type checker .pyre/ -### JetBrains template -# User-specific stuff -.idea/**/workspace.xml -.idea/**/tasks.xml -.idea/**/usage.statistics.xml -.idea/**/dictionaries -.idea/**/shelf - -# Generated files -.idea/**/contentModel.xml - -# Sensitive or high-churn files -.idea/**/dataSources/ -.idea/**/dataSources.ids -.idea/**/dataSources.local.xml -.idea/**/sqlDataSources.xml -.idea/**/dynamic.xml -.idea/**/uiDesigner.xml -.idea/**/dbnavigator.xml - -# mpeltonen/sbt-idea plugin -.idea_modules/ - -# Cursive Clojure plugin -.idea/replstate.xml - - +### JetBrains +.idea/ From 9762690a1f223b42e18ca97426a1e7ab687da0cc Mon Sep 17 00:00:00 2001 From: jackisback Date: Wed, 18 Mar 2020 12:47:10 +0100 Subject: [PATCH 33/39] exchanged print for stdout --- beetsplug/goingrunning/command.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/beetsplug/goingrunning/command.py b/beetsplug/goingrunning/command.py index ccf993c..5954b53 100644 --- a/beetsplug/goingrunning/command.py +++ b/beetsplug/goingrunning/command.py @@ -9,6 +9,7 @@ import random import os import string +from sys import stdout from optparse import OptionParser from pathlib import Path @@ -465,8 +466,8 @@ def verify_configuration_upgrade(self): raise RuntimeError("Offending key in training({}): {}".format(training_name, tkey)) def _say(self, msg): - """Log and print to stdout + """Log and write to stdout """ self.log.debug(msg) if not self.cfg_quiet: - print(msg) + stdout.write(msg) From 77fce8361068c3259530b37d6a3c4990e72d40e4 Mon Sep 17 00:00:00 2001 From: Adam Jakab Date: Wed, 18 Mar 2020 18:03:26 +0100 Subject: [PATCH 34/39] fixing logging --- beetsplug/goingrunning/command.py | 3 +- test/functional/001_configuration_test.py | 8 ++--- test/helper.py | 44 +++++++++++++++-------- 3 files changed, 35 insertions(+), 20 deletions(-) diff --git a/beetsplug/goingrunning/command.py b/beetsplug/goingrunning/command.py index 5954b53..cbf4cd4 100644 --- a/beetsplug/goingrunning/command.py +++ b/beetsplug/goingrunning/command.py @@ -470,4 +470,5 @@ def _say(self, msg): """ self.log.debug(msg) if not self.cfg_quiet: - stdout.write(msg) + print(msg) + # stdout.write(msg + "\n") diff --git a/test/functional/001_configuration_test.py b/test/functional/001_configuration_test.py index 01c9201..4e481fd 100644 --- a/test/functional/001_configuration_test.py +++ b/test/functional/001_configuration_test.py @@ -27,11 +27,11 @@ def test_plugin_no_config(self): def test_obsolete_config(self): self.reset_beets(config_file=b"obsolete.yml") - with capture_stdout() as out: - self.runcli(PLUGIN_NAME) - self.assertIn("INCOMPATIBLE PLUGIN CONFIGURATION", out.getvalue()) - self.assertIn("Offending key in training(training-1): song_bpm", out.getvalue()) + stdout = self.run_with_output(PLUGIN_NAME) + + self.assertIn("INCOMPATIBLE PLUGIN CONFIGURATION", stdout) + self.assertIn("Offending key in training(training-1): song_bpm", stdout) def test_default_config_sanity(self): self.assertTrue(self.config[PLUGIN_NAME].exists()) diff --git a/test/helper.py b/test/helper.py index 1eb75e6..f3a30f1 100644 --- a/test/helper.py +++ b/test/helper.py @@ -71,13 +71,12 @@ def capture_log(logger='beets', suppress_output=True): @contextmanager -def capture_stdout(suppress_output=True): +def capture_stdout(): """Save stdout in a StringIO. >>> with capture_stdout() as output: ... print('spam') ... >>> output.getvalue() - 'spam' """ orig = sys.stdout sys.stdout = capture = StringIO() @@ -85,10 +84,8 @@ def capture_stdout(suppress_output=True): yield sys.stdout finally: sys.stdout = orig - # if not suppress_output: print(capture.getvalue()) - @contextmanager def control_stdin(userinput=None): """Sends ``input`` to stdin. @@ -246,7 +243,7 @@ def _setup_beets(self, config_file: bytes, beet_files: list = None): self.config.read() self.config['plugins'] = [] - self.config['verbose'] = True + self.config['verbose'] = False self.config['ui']['color'] = False self.config['threaded'] = False self.config['import']['copy'] = False @@ -277,7 +274,7 @@ def _copy_files_to_beetsdir(self, file_list: list): file_name = file_name.decode() dst = os.path.join(self.beetsdir.decode(), file_name) - print("Copy({}) to beetsdir: {}".format(src, file_name)) + # print("Copy({}) to beetsdir: {}".format(src, file_name)) shutil.copyfile(src, dst) @@ -326,15 +323,32 @@ def unload_plugins(): plugins._classes = set() plugins._instances = {} - def runcli(self, *args): - # TODO mock stdin + # def runcli(self, *args): + # # TODO mock stdin + # with capture_stdout() as out: + # try: + # ui._raw_main(_convert_args(list(args)), self.lib) + # except ui.UserError as u: + # # TODO remove this and handle exceptions in tests + # print(u.args[0]) + # return out.getvalue() + + def run_command(self, *args, **kwargs): + """Run a beets command with an arbitrary amount of arguments. The + Library` defaults to `self.lib`, but can be overridden with + the keyword argument `lib`. + """ + sys.argv = ['beet'] # avoid leakage from test suite args + lib = None + if hasattr(self, 'lib'): + lib = self.lib + lib = kwargs.get('lib', lib) + beets.ui._raw_main(_convert_args(list(args)), lib) + + def run_with_output(self, *args): with capture_stdout() as out: - try: - ui._raw_main(_convert_args(list(args)), self.lib) - except ui.UserError as u: - # TODO remove this and handle exceptions in tests - print(u.args[0]) - return out.getvalue() + self.run_command(*args) + return util.text_string(out.getvalue()) def lib_path(self, path): return os.path.join(self.beetsdir, path.replace(b'/', bytestring_path(os.sep))) @@ -378,7 +392,7 @@ def create_item(self, **values): values_.update(values) values_['title'] = values_['title'].format(item_count) - print("Creating Item: {}".format(values_)) + #print("Creating Item: {}".format(values_)) values_['db'] = self.lib item = Item(**values_) From 003676e10fffff257137cd60bf806d61ad4071e2 Mon Sep 17 00:00:00 2001 From: Adam Jakab Date: Wed, 18 Mar 2020 19:37:19 +0100 Subject: [PATCH 35/39] moving logging around --- beetsplug/goingrunning/command.py | 67 ++++----- beetsplug/goingrunning/common.py | 13 +- test/functional/000_basic_test.py | 29 ++-- test/functional/001_configuration_test.py | 5 +- test/functional/002_command_test.py | 159 +++++++++------------- test/helper.py | 12 +- test/unit/000_common_test.py | 21 ++- 7 files changed, 138 insertions(+), 168 deletions(-) diff --git a/beetsplug/goingrunning/command.py b/beetsplug/goingrunning/command.py index cbf4cd4..564d49a 100644 --- a/beetsplug/goingrunning/command.py +++ b/beetsplug/goingrunning/command.py @@ -6,15 +6,14 @@ # import operator -import random import os +import random import string -from sys import stdout +from glob import glob from optparse import OptionParser from pathlib import Path - from shutil import copyfile -from glob import glob + from beets.dbcore.db import Results from beets.dbcore.queryparse import parse_query_part from beets.library import Library, Item, parse_query_string @@ -30,7 +29,6 @@ class GoingRunningCommand(Subcommand): - log = None config: Subview = None lib: Library = None query = None @@ -42,7 +40,6 @@ class GoingRunningCommand(Subcommand): def __init__(self, cfg): self.config = cfg - self.log = common.get_beets_logger() self.parser = OptionParser(usage='beet goingrunning [training] [options] [QUERY...]') @@ -110,14 +107,11 @@ def func(self, lib: Library, options, arguments): self._say("* I promise it will not happen again ;)") self._say("* " + str(e)) self._say("* The plugin will exit now.") - self._say("*" * 80) + common.say("*" * 80) return # You must either pass a training name or request listing if len(self.query) < 1 and not (options.list or options.version): - self.log.warning( - "You can either pass the name of a training or request a " - "listing (--list)!") self.parser.print_help() return @@ -208,7 +202,7 @@ def _clean_target_path(self, training: Subview): os.path.join(dst_path, "*.{}".format(ext))) for f in target_file_list: - self.log.debug("Deleting: {}".format(f)) + self._say("Deleting: {}".format(f), log_only=True) os.remove(f) additional_files = self._get_target_attribute_for_training(training, @@ -225,11 +219,10 @@ def _clean_target_path(self, training: Subview): dst_path = os.path.realpath(root.joinpath(path)) if not os.path.isfile(dst_path): - self.log.debug( - "The file to delete does not exist: {0}".format(path)) + self._say("The file to delete does not exist: {0}".format(path), log_only=True) continue - self.log.debug("Deleting: {}".format(dst_path)) + self._say("Deleting: {}".format(dst_path), log_only=True) os.remove(dst_path) def _copy_items_to_target(self, training: Subview, rnd_items): @@ -246,20 +239,20 @@ def random_string(length=6): src = os.path.realpath(item.get("path").decode("utf-8")) if not os.path.isfile(src): # todo: this is bad enough to interrupt! create option for this - self.log.warning("File does not exist: {}".format(src)) + self._say("File does not exist: {}".format(src)) continue fn, ext = os.path.splitext(src) gen_filename = "{0}_{1}{2}".format(str(cnt).zfill(6), random_string(), ext) dst = "{0}/{1}".format(dst_path, gen_filename) - self.log.debug("Copying[{1}]: {0}".format(src, gen_filename)) + self._say("Copying[{1}]: {0}".format(src, gen_filename), log_only=True) copyfile(src, dst) cnt += 1 def _get_target_for_training(self, training: Subview): target_name = common.get_training_attribute(training, "target") - self.log.debug("Finding target: {0}".format(target_name)) + self._say("Finding target: {0}".format(target_name), log_only=True) if not self.config["targets"][target_name].exists(): self._say("The target name[{0}] is not defined!".format(target_name)) @@ -269,8 +262,8 @@ def _get_target_for_training(self, training: Subview): def _get_target_attribute_for_training(self, training: Subview, attrib: str = "name"): target_name = common.get_training_attribute(training, "target") - self.log.debug("Getting attribute[{0}] for target: {1}".format(attrib, - target_name)) + self._say("Getting attribute[{0}] for target: {1}".format(attrib, + target_name), log_only=True) target = self._get_target_for_training(training) if not target: return @@ -286,8 +279,8 @@ def _get_target_attribute_for_training(self, training: Subview, attrib: str = "n else: attrib_val = common.get_target_attribute(target, attrib) - self.log.debug( - "Found target[{0}] attribute[{1}] path: {2}".format(target_name, attrib, attrib_val)) + self._say( + "Found target[{0}] attribute[{1}] path: {2}".format(target_name, attrib, attrib_val), log_only=True) return attrib_val @@ -313,8 +306,8 @@ def _get_destination_path_for_training(self, training: Subview): dst_path)) return - self.log.debug( - "Found target[{0}] path: {0}".format(target_name, dst_path)) + self._say( + "Found target[{0}] path: {0}".format(target_name, dst_path), log_only=True) return dst_path @@ -333,8 +326,8 @@ def _get_items_for_duration(self, items, duration): else: bin_size = 0 - self.log.debug("Estimated number of songs: {}".format(est_num_songs)) - self.log.debug("Bin Size: {}".format(bin_size)) + self._say("Estimated number of songs: {}".format(est_num_songs), log_only=True) + self._say("Bin Size: {}".format(bin_size), log_only=True) for i in range(0, est_num_songs): bin_start = round(i * bin_size) @@ -368,12 +361,12 @@ def _gather_query_elements(self, training: Subview): flavour: Subview = self.config["flavours"][flavour_name] flavour_query += common.get_flavour_elements(flavour) - self.log.debug("Command query elements: {}".format(command_query)) - self.log.debug("Training query elements: {}".format(training_query)) - self.log.debug("Flavour query elements: {}".format(flavour_query)) + self._say("Command query elements: {}".format(command_query), log_only=True) + self._say("Training query elements: {}".format(training_query), log_only=True) + self._say("Flavour query elements: {}".format(flavour_query), log_only=True) raw_combined_query = command_query + training_query + flavour_query - self.log.debug("Raw combined query elements: {}".format(raw_combined_query)) + self._say("Raw combined query elements: {}".format(raw_combined_query), log_only=True) # Remove duplicate keys combined_query = [] @@ -384,14 +377,14 @@ def _gather_query_elements(self, training: Subview): used_keys.append(key) combined_query.append(query_part) - self.log.debug("Clean combined query elements: {}".format(combined_query)) + self._say("Clean combined query elements: {}".format(combined_query), log_only=True) return combined_query def _retrieve_library_items(self, training: Subview): full_query = self._gather_query_elements(training) parsed_query = parse_query_string(" ".join(full_query), Item)[0] - self.log.debug("Song selection query: {}".format(parsed_query)) + self._say("Song selection query: {}".format(parsed_query), log_only=True) return self.lib.items(parsed_query) @@ -429,7 +422,7 @@ def list_trainings(self): def list_training_attributes(self, training_name: str): if not self.config["trainings"].exists() or not self.config["trainings"][training_name].exists(): - self.log.debug("Training[{0}] does not exist.".format(training_name)) + self._say("Training[{0}] does not exist.".format(training_name), log_only=True) return training: Subview = self.config["trainings"][training_name] @@ -465,10 +458,6 @@ def verify_configuration_upgrade(self): if tkey in ["song_bpm", "song_len"]: raise RuntimeError("Offending key in training({}): {}".format(training_name, tkey)) - def _say(self, msg): - """Log and write to stdout - """ - self.log.debug(msg) - if not self.cfg_quiet: - print(msg) - # stdout.write(msg + "\n") + def _say(self, msg, log_only=False): + log_only = True if self.cfg_quiet else log_only + common.say(msg, log_only) diff --git a/beetsplug/goingrunning/common.py b/beetsplug/goingrunning/common.py index f95fcd6..b6b4f33 100644 --- a/beetsplug/goingrunning/common.py +++ b/beetsplug/goingrunning/common.py @@ -6,6 +6,7 @@ # import logging +import sys from beets.library import Item from beets.util.confit import Subview @@ -20,15 +21,23 @@ KNOWN_TEXTUAL_FLEX_ATTRIBUTES = ["gender", "genre_rosamerica", "rhythm", "voice_instrumental", "chords_key", "chords_scale"] +__logger__ = logging.getLogger('beets.goingrunning') + + +def say(msg, log_only=False): + """Log and write to stdout + """ + __logger__.debug(msg) + if not log_only: + sys.stdout.write(msg + "\n") -def get_beets_logger(): - return logging.getLogger('beets.goingrunning') def get_human_readable_time(seconds): m, s = divmod(seconds, 60) h, m = divmod(m, 60) return "%d:%02d:%02d" % (h, m, s) + def get_beet_query_formatted_string(key, val): quote_val = type(val) == str and " " in val fmt = "{k}:'{v}'" if quote_val else "{k}:{v}" diff --git a/test/functional/000_basic_test.py b/test/functional/000_basic_test.py index e6c1669..733cc9d 100644 --- a/test/functional/000_basic_test.py +++ b/test/functional/000_basic_test.py @@ -7,8 +7,7 @@ from test.helper import ( FunctionalTestHelper, Assertions, - PLUGIN_NAME, PLUGIN_SHORT_NAME, PLUGIN_SHORT_DESCRIPTION, - capture_log, capture_stdout + PLUGIN_NAME, PLUGIN_SHORT_NAME, PLUGIN_SHORT_DESCRIPTION ) @@ -18,28 +17,20 @@ class BasicTest(FunctionalTestHelper, Assertions): """ def test_application(self): - with capture_stdout() as out: - self.runcli() - - self.assertIn(PLUGIN_NAME, out.getvalue()) - self.assertIn(PLUGIN_SHORT_DESCRIPTION, out.getvalue()) + stdout = self.run_with_output() + self.assertIn(PLUGIN_NAME, stdout) + self.assertIn(PLUGIN_SHORT_DESCRIPTION, stdout) def test_application_version(self): - with capture_stdout() as out: - self.runcli("version") - - self.assertIn("plugins: {0}".format(PLUGIN_NAME), out.getvalue()) + stdout = self.run_with_output("version") + self.assertIn("plugins: {0}".format(PLUGIN_NAME), stdout) def test_plugin_no_arguments(self): self.reset_beets(config_file=b"empty.yml") - with capture_stdout() as out: - self.runcli(PLUGIN_NAME) - - self.assertIn("Usage: beet goingrunning [training] [options] [QUERY...]", out.getvalue()) + stdout = self.run_with_output(PLUGIN_NAME) + self.assertIn("Usage: beet goingrunning [training] [options] [QUERY...]", stdout) def test_plugin_shortname_no_arguments(self): self.reset_beets(config_file=b"empty.yml") - with capture_stdout() as out: - self.runcli(PLUGIN_SHORT_NAME) - - self.assertIn("Usage: beet goingrunning [training] [options] [QUERY...]", out.getvalue()) + stdout = self.run_with_output(PLUGIN_SHORT_NAME) + self.assertIn("Usage: beet goingrunning [training] [options] [QUERY...]", stdout) diff --git a/test/functional/001_configuration_test.py b/test/functional/001_configuration_test.py index 4e481fd..30613ec 100644 --- a/test/functional/001_configuration_test.py +++ b/test/functional/001_configuration_test.py @@ -7,8 +7,7 @@ from beets.util.confit import Subview -from test.helper import FunctionalTestHelper, Assertions, PLUGIN_NAME, PLUGIN_SHORT_DESCRIPTION, capture_log, \ - capture_stdout +from test.helper import FunctionalTestHelper, Assertions, PLUGIN_NAME class ConfigurationTest(FunctionalTestHelper, Assertions): @@ -27,9 +26,7 @@ def test_plugin_no_config(self): def test_obsolete_config(self): self.reset_beets(config_file=b"obsolete.yml") - stdout = self.run_with_output(PLUGIN_NAME) - self.assertIn("INCOMPATIBLE PLUGIN CONFIGURATION", stdout) self.assertIn("Offending key in training(training-1): song_bpm", stdout) diff --git a/test/functional/002_command_test.py b/test/functional/002_command_test.py index 91b8e9a..56af20a 100644 --- a/test/functional/002_command_test.py +++ b/test/functional/002_command_test.py @@ -5,10 +5,8 @@ # License: See LICENSE.txt # -from beets.util.confit import Subview - -from test.helper import FunctionalTestHelper, Assertions, PLUGIN_NAME, PLUGIN_SHORT_DESCRIPTION, capture_log, \ - capture_stdout, get_single_line_from_output, get_value_separated_from_output, convert_time_to_seconds +from test.helper import FunctionalTestHelper, Assertions, PLUGIN_NAME, get_single_line_from_output, \ + get_value_separated_from_output, convert_time_to_seconds class CommandTest(FunctionalTestHelper, Assertions): @@ -16,77 +14,63 @@ class CommandTest(FunctionalTestHelper, Assertions): """ def test_plugin_version(self): - with capture_stdout() as out: - self.runcli(PLUGIN_NAME, "--version") - from beetsplug.goingrunning.version import __version__ - self.assertIn("Goingrunning(beets-{})".format(PLUGIN_NAME), out.getvalue()) - self.assertIn("v{}".format(__version__), out.getvalue()) + + stdout = self.run_with_output(PLUGIN_NAME, "--version") + self.assertIn("Goingrunning(beets-{})".format(PLUGIN_NAME), stdout) + self.assertIn("v{}".format(__version__), stdout) + + stdout = self.run_with_output(PLUGIN_NAME, "-v") + self.assertIn("Goingrunning(beets-{})".format(PLUGIN_NAME), stdout) + self.assertIn("v{}".format(__version__), stdout) def test_training_listing_empty(self): self.reset_beets(config_file=b"empty.yml") - with capture_stdout() as out: - self.runcli(PLUGIN_NAME, "--list") - self.assertIn("You have not created any trainings yet.", out.getvalue()) + stdout = self.run_with_output(PLUGIN_NAME, "--list") + self.assertIn("You have not created any trainings yet.", stdout) - with capture_stdout() as out: - self.runcli(PLUGIN_NAME, "-l") - self.assertIn("You have not created any trainings yet.", out.getvalue()) + stdout = self.run_with_output(PLUGIN_NAME, "-l") + self.assertIn("You have not created any trainings yet.", stdout) def test_training_listing_default(self): - with capture_stdout() as out: - self.runcli(PLUGIN_NAME, "--list") - - output = out.getvalue() - self.assertIn("::: training-1", output) - self.assertIn("::: training-2", output) - self.assertIn("::: training-3", output) + stdout = self.run_with_output(PLUGIN_NAME, "--list") + self.assertIn("::: training-1", stdout) + self.assertIn("::: training-2", stdout) + self.assertIn("::: training-3", stdout) def test_training_handling_inexistent(self): training_name = "sitting_on_the_sofa" - with capture_stdout() as out: - self.runcli(PLUGIN_NAME, training_name) - - self.assertIn("There is no training[{0}] registered with this name!".format(training_name), out.getvalue()) + stdout = self.run_with_output(PLUGIN_NAME, training_name) + self.assertIn("There is no training[{0}] registered with this name!".format(training_name), stdout) def test_training_song_count(self): training_name = "training-1" - with capture_stdout() as out: - self.runcli(PLUGIN_NAME, training_name, "--count") - self.assertIn("Number of songs available: {}".format(0), out.getvalue()) + stdout = self.run_with_output(PLUGIN_NAME, training_name, "--count") + self.assertIn("Number of songs available: {}".format(0), stdout) - with capture_stdout() as out: - self.runcli(PLUGIN_NAME, training_name, "-c") - self.assertIn("Number of songs available: {}".format(0), out.getvalue()) + stdout = self.run_with_output(PLUGIN_NAME, training_name, "-c") + self.assertIn("Number of songs available: {}".format(0), stdout) def test_training_no_songs(self): training_name = "training-1" - with capture_stdout() as out: - self.runcli(PLUGIN_NAME, training_name) - - self.assertIn("Handling training: {0}".format(training_name), out.getvalue()) - self.assertIn("There are no songs in your library that match this training!", out.getvalue()) + stdout = self.run_with_output(PLUGIN_NAME, training_name) + self.assertIn("Handling training: {0}".format(training_name), stdout) + self.assertIn("There are no songs in your library that match this training!", stdout) def test_training_undefined_target(self): self.add_single_item_to_library() - training_name = "bad-target-1" - with capture_stdout() as out: - self.runcli(PLUGIN_NAME, training_name) - + stdout = self.run_with_output(PLUGIN_NAME, training_name) target_name = "inexistent_target" - self.assertIn("The target name[{0}] is not defined!".format(target_name), out.getvalue()) + self.assertIn("The target name[{0}] is not defined!".format(target_name), stdout) def test_training_bad_target(self): self.add_single_item_to_library() - training_name = "bad-target-2" - with capture_stdout() as out: - self.runcli(PLUGIN_NAME, training_name) - + stdout = self.run_with_output(PLUGIN_NAME, training_name) target_name = "MPD_3" target_path = "/media/this/probably/does/not/exist/" - self.assertIn("The target[{0}] path does not exist: {1}".format(target_name, target_path), out.getvalue()) + self.assertIn("The target[{0}] path does not exist: {1}".format(target_name, target_path), stdout) def test_handling_training_1(self): """Simple query based song selection @@ -98,8 +82,7 @@ def test_handling_training_1(self): self.ensure_training_target_path(training_name) - with capture_stdout() as out: - self.runcli(PLUGIN_NAME, training_name) + stdout = self.run_with_output(PLUGIN_NAME, training_name) """ Output for "training-1": @@ -117,40 +100,38 @@ def test_handling_training_1(self): """ - output = out.getvalue() - prefix = "Handling training:" - self.assertIn(prefix, output) - value = get_value_separated_from_output(output, prefix) + self.assertIn(prefix, stdout) + value = get_value_separated_from_output(stdout, prefix) self.assertEqual(training_name, value) prefix = "Available songs:" - self.assertIn(prefix, output) - value = int(get_value_separated_from_output(output, prefix)) + self.assertIn(prefix, stdout) + value = int(get_value_separated_from_output(stdout, prefix)) self.assertEqual(10, value) prefix = "Selected songs:" - self.assertIn(prefix, output) - value = int(get_value_separated_from_output(output, prefix)) + self.assertIn(prefix, stdout) + value = int(get_value_separated_from_output(stdout, prefix)) self.assertGreater(value, 0) self.assertLessEqual(value, 10) prefix = "Planned training duration:" - self.assertIn(prefix, output) - value = get_value_separated_from_output(output, prefix) + self.assertIn(prefix, stdout) + value = get_value_separated_from_output(stdout, prefix) seconds = convert_time_to_seconds(value) self.assertEqual("0:10:00", value) self.assertEqual(600, seconds) # Do not test for efficiency here prefix = "Total song duration:" - self.assertIn(prefix, output) - value = get_value_separated_from_output(output, prefix) + self.assertIn(prefix, stdout) + value = get_value_separated_from_output(stdout, prefix) seconds = convert_time_to_seconds(value) self.assertGreater(seconds, 0) prefix = "Run!" - line = get_single_line_from_output(output, prefix) + line = get_single_line_from_output(stdout, prefix) self.assertEqual(prefix, line) def test_handling_training_2(self): @@ -176,8 +157,7 @@ def test_handling_training_2(self): self.ensure_training_target_path(training_name) - with capture_stdout() as out: - self.runcli(PLUGIN_NAME, training_name) + stdout = self.run_with_output(PLUGIN_NAME, training_name) """ Output for "training-2": @@ -196,40 +176,38 @@ def test_handling_training_2(self): """ - output = out.getvalue() - prefix = "Handling training:" - self.assertIn(prefix, output) - value = get_value_separated_from_output(output, prefix) + self.assertIn(prefix, stdout) + value = get_value_separated_from_output(stdout, prefix) self.assertEqual(training_name, value) prefix = "Available songs:" - self.assertIn(prefix, output) - value = int(get_value_separated_from_output(output, prefix)) + self.assertIn(prefix, stdout) + value = int(get_value_separated_from_output(stdout, prefix)) self.assertEqual(10, value) prefix = "Selected songs:" - self.assertIn(prefix, output) - value = int(get_value_separated_from_output(output, prefix)) + self.assertIn(prefix, stdout) + value = int(get_value_separated_from_output(stdout, prefix)) self.assertGreater(value, 0) self.assertLessEqual(value, 10) prefix = "Planned training duration:" - self.assertIn(prefix, output) - value = get_value_separated_from_output(output, prefix) + self.assertIn(prefix, stdout) + value = get_value_separated_from_output(stdout, prefix) seconds = convert_time_to_seconds(value) self.assertEqual("0:10:00", value) self.assertEqual(600, seconds) # Do not test for efficiency here prefix = "Total song duration:" - self.assertIn(prefix, output) - value = get_value_separated_from_output(output, prefix) + self.assertIn(prefix, stdout) + value = get_value_separated_from_output(stdout, prefix) seconds = convert_time_to_seconds(value) self.assertGreater(seconds, 0) prefix = "Run!" - line = get_single_line_from_output(output, prefix) + line = get_single_line_from_output(stdout, prefix) self.assertEqual(prefix, line) def test_handling_training_3(self): @@ -259,8 +237,7 @@ def test_handling_training_3(self): self.ensure_training_target_path(training_name) - with capture_stdout() as out: - self.runcli(PLUGIN_NAME, training_name) + stdout = self.run_with_output(PLUGIN_NAME, training_name) """ Output for "training-2": @@ -279,38 +256,36 @@ def test_handling_training_3(self): """ - output = out.getvalue() - prefix = "Handling training:" - self.assertIn(prefix, output) - value = get_value_separated_from_output(output, prefix) + self.assertIn(prefix, stdout) + value = get_value_separated_from_output(stdout, prefix) self.assertEqual(training_name, value) prefix = "Available songs:" - self.assertIn(prefix, output) - value = int(get_value_separated_from_output(output, prefix)) + self.assertIn(prefix, stdout) + value = int(get_value_separated_from_output(stdout, prefix)) self.assertEqual(10, value) prefix = "Selected songs:" - self.assertIn(prefix, output) - value = int(get_value_separated_from_output(output, prefix)) + self.assertIn(prefix, stdout) + value = int(get_value_separated_from_output(stdout, prefix)) self.assertGreater(value, 0) self.assertLessEqual(value, 10) prefix = "Planned training duration:" - self.assertIn(prefix, output) - value = get_value_separated_from_output(output, prefix) + self.assertIn(prefix, stdout) + value = get_value_separated_from_output(stdout, prefix) seconds = convert_time_to_seconds(value) self.assertEqual("0:10:00", value) self.assertEqual(600, seconds) # Do not test for efficiency here prefix = "Total song duration:" - self.assertIn(prefix, output) - value = get_value_separated_from_output(output, prefix) + self.assertIn(prefix, stdout) + value = get_value_separated_from_output(stdout, prefix) seconds = convert_time_to_seconds(value) self.assertGreater(seconds, 0) prefix = "Run!" - line = get_single_line_from_output(output, prefix) + line = get_single_line_from_output(stdout, prefix) self.assertEqual(prefix, line) diff --git a/test/helper.py b/test/helper.py index f3a30f1..f2aa8d7 100644 --- a/test/helper.py +++ b/test/helper.py @@ -6,7 +6,6 @@ # # References: https://docs.python.org/3/library/unittest.html # -import json import os import shutil import sys @@ -25,7 +24,6 @@ from beets.library import Item # from beets.mediafile import MediaFile from beets.util import ( - MoveOperation, syspath, bytestring_path, displayable_path, @@ -60,9 +58,9 @@ def capture_log(logger='beets', suppress_output=True): capture = LogCapture() log = logging.getLogger(logger) log.propagate = True - if suppress_output: - # Is this too violent? - log.handlers = [] + # if suppress_output: + # Is this too violent? + # log.handlers = [] log.addHandler(capture) try: yield capture.messages @@ -243,7 +241,7 @@ def _setup_beets(self, config_file: bytes, beet_files: list = None): self.config.read() self.config['plugins'] = [] - self.config['verbose'] = False + self.config['verbose'] = True self.config['ui']['color'] = False self.config['threaded'] = False self.config['import']['copy'] = False @@ -398,7 +396,7 @@ def create_item(self, **values): item = Item(**values_) if 'path' not in values: - item['path'] = 'audio.' + item['format'].lower() + item['path'] = 'test/fixtures/song.' + item['format'].lower() # mtime needs to be set last since other assignments reset it. item.mtime = 12345 diff --git a/test/unit/000_common_test.py b/test/unit/000_common_test.py index 5982031..28415c1 100644 --- a/test/unit/000_common_test.py +++ b/test/unit/000_common_test.py @@ -5,11 +5,11 @@ # License: See LICENSE.txt # -from test.helper import UnitTestHelper, Assertions, get_plugin_configuration -from beetsplug.goingrunning import common - from logging import Logger +from beetsplug.goingrunning import common +from test.helper import UnitTestHelper, Assertions, get_plugin_configuration, capture_stdout + class CommonTest(UnitTestHelper, Assertions): """Test methods in the beetsplug.goingrunning.common module @@ -20,9 +20,20 @@ def test_module_values(self): self.assertTrue(hasattr(common, "MUST_HAVE_TARGET_KEYS")) self.assertTrue(hasattr(common, "KNOWN_NUMERIC_FLEX_ATTRIBUTES")) self.assertTrue(hasattr(common, "KNOWN_TEXTUAL_FLEX_ATTRIBUTES")) + self.assertTrue(hasattr(common, "__logger__")) + self.assertIsInstance(common.__logger__, Logger) + + def test_say(self): + test_message = "one two three" + + with capture_stdout() as out: + common.say(test_message) + self.assertIn(test_message, out.getvalue()) - def test_get_beets_logger(self): - self.assertIsInstance(common.get_beets_logger(), Logger) + # todo: make it work + # with capture_log() as logs: + # common.say(test_message) + # self.assertIn(test_message, '\n'.join(logs)) def test_get_beets_global_config(self): self.assertEqual("0:00:00", common.get_human_readable_time(0), "Bad time format!") From 45442da0137bdf47197062b9d7531ff6c4d65b6e Mon Sep 17 00:00:00 2001 From: Adam Jakab Date: Wed, 18 Mar 2020 20:55:18 +0100 Subject: [PATCH 36/39] removed BEETSDIR --- .gitignore | 43 ++++++---------------------- BEETSDIR/config.yaml | 67 -------------------------------------------- test/helper.py | 5 ++-- 3 files changed, 11 insertions(+), 104 deletions(-) delete mode 100644 BEETSDIR/config.yaml diff --git a/.gitignore b/.gitignore index 0411853..9106c4e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,25 +1,21 @@ -# Created by .ignore support plugin (hsz.mobi) +## JetBrains +.idea/ + +## OS specific +.DS_Store -# Project +## Project coverage/ BEETSDIR/* !BEETSDIR/config.yaml -### Python template -# Byte-compiled / optimized / DLL files +## Python specific __pycache__/ -*.py[cod] -*$py.class - -# C extensions -*.so # Distribution / packaging .Python build/ -develop-eggs/ dist/ -downloads/ eggs/ .eggs/ lib/ @@ -36,8 +32,6 @@ share/python-wheels/ MANIFEST # PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec @@ -54,9 +48,6 @@ htmlcov/ .cache nosetests.xml coverage.xml -*.cover -*.py,cover -.hypothesis/ .pytest_cache/ # Translations @@ -92,23 +83,6 @@ ipython_config.py # pyenv .python-version -# pipenv -# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. -# However, in case of collaboration, if having platform-specific dependencies or dependencies -# having no cross-platform support, pipenv may install dependencies that don't work, or not -# install all needed dependencies. -#Pipfile.lock - -# PEP 582; used by e.g. github.com/David-OConnor/pyflow -__pypackages__/ - -# Celery stuff -celerybeat-schedule -celerybeat.pid - -# SageMath parsed files -*.sage.py - # Environments .env .venv @@ -136,5 +110,4 @@ dmypy.json # Pyre type checker .pyre/ -### JetBrains -.idea/ + diff --git a/BEETSDIR/config.yaml b/BEETSDIR/config.yaml deleted file mode 100644 index 9b60428..0000000 --- a/BEETSDIR/config.yaml +++ /dev/null @@ -1,67 +0,0 @@ -# DEVELOPMENT CONFIGURATION FILE -# USAGE: export environment variable BEETSDIR=/path/to/BEETSDIR -# then: `beets config -p` should list this file - -#directory: Music -#library: library.db -directory: /Volumes/J/Music/ -library: ~/.config/beets/real_library.db -asciify_paths: yes -id3v23: yes - -pluginpath: - - ~/Documents/Projects/Python/BeetsPluginGoingRunning/beetsplug/ - -plugins: - - info - - goingrunning - -#format_item: "[format:$format][bpm:$bpm] ::: $path" -format_item: "[MA:$mood_aggressive][bpm:$bpm][gender:$gender][year:$year][genre:$genre] $artist ::: $title" - -import: - copy: yes - autotag: no - -goingrunning: - targets: - test: - device_root: ~/Documents/Projects/Python/BeetsPluginGoingRunning/BEETSDIR/Target1/ - device_path: MUSIC/AUTO/ - clean_target: yes - delete_from_device: - - xyz.txt - SONY: - device_root: /Volumes/WALKMAN/ - device_path: MUSIC/AUTO/ - clean_target: yes - delete_from_device: - - STDBDATA.DAT - - STDBSTR.DAT - trainings: - fallback: - alias: FALLBACK - query: - bpm: 90..140 - duration: 60 - target: test - test: - query: - mood_party: 0.8.. - use_flavours: [] - duration: 30 - halfmarathon: - alias: HM - query: - bpm: 90..150 - length: 120..300 - mood_aggressive: 0.3..0.7 - ordering: - year: 100 - duration: 60 - flavours: - sunshine: - ^genre: Reggae - chillout: - bpm: 1..120 - mood_happy: 0.5..0.99 diff --git a/test/helper.py b/test/helper.py index f2aa8d7..f7d6896 100644 --- a/test/helper.py +++ b/test/helper.py @@ -6,6 +6,7 @@ # # References: https://docs.python.org/3/library/unittest.html # + import os import shutil import sys @@ -22,7 +23,6 @@ from beets import ui from beets import util from beets.library import Item -# from beets.mediafile import MediaFile from beets.util import ( syspath, bytestring_path, @@ -84,6 +84,7 @@ def capture_stdout(): sys.stdout = orig print(capture.getvalue()) + @contextmanager def control_stdin(userinput=None): """Sends ``input`` to stdin. @@ -390,7 +391,7 @@ def create_item(self, **values): values_.update(values) values_['title'] = values_['title'].format(item_count) - #print("Creating Item: {}".format(values_)) + # print("Creating Item: {}".format(values_)) values_['db'] = self.lib item = Item(**values_) From 8a134defae02d9829dc8af3a0acd84d037e96ab3 Mon Sep 17 00:00:00 2001 From: Adam Jakab Date: Wed, 18 Mar 2020 20:57:40 +0100 Subject: [PATCH 37/39] excluding BEETSDIR --- .gitignore | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 9106c4e..8a98158 100644 --- a/.gitignore +++ b/.gitignore @@ -6,8 +6,7 @@ ## Project coverage/ -BEETSDIR/* -!BEETSDIR/config.yaml +BEETSDIR/ ## Python specific __pycache__/ From 6cd13986e49ff02fbac22859aa917fe86b93fec7 Mon Sep 17 00:00:00 2001 From: Adam Jakab Date: Wed, 18 Mar 2020 23:06:21 +0100 Subject: [PATCH 38/39] pretty formatted listing and song display --- beetsplug/goingrunning/command.py | 63 +++++++++++++++++++++-------- beetsplug/goingrunning/common.py | 2 +- test/functional/002_command_test.py | 6 +-- 3 files changed, 50 insertions(+), 21 deletions(-) diff --git a/beetsplug/goingrunning/command.py b/beetsplug/goingrunning/command.py index 564d49a..b08024c 100644 --- a/beetsplug/goingrunning/command.py +++ b/beetsplug/goingrunning/command.py @@ -176,8 +176,9 @@ def handle_training(self): self._say("Planned training duration: {0}".format(common.get_human_readable_time(duration * 60))) self._say("Total song duration: {}".format(common.get_human_readable_time(total_time))) - # Show the selected songs and exit - flds = ["bpm", "year", "length", "artist", "title"] + # Show the selected songs + flds = self._get_training_query_element_keys(training) + flds += ["artist", "title"] self.display_library_items(sel_items, flds, prefix="Selected: ") # todo: move this inside the nex methods to show what would be done @@ -340,6 +341,14 @@ def _get_items_for_duration(self, items, duration): return selected + def _get_training_query_element_keys(self, training): + answer = [] + query_elements = self._gather_query_elements(training) + for el in query_elements: + answer.append(el.split(":")[0]) + + return answer + def _gather_query_elements(self, training: Subview): """Order(strongest to weakest): command -> training -> flavours """ @@ -391,7 +400,10 @@ def _retrieve_library_items(self, training: Subview): def display_library_items(self, items, fields, prefix=""): fmt = prefix for field in fields: - fmt += "[{0}:{{{0}}}]".format(field) + if field in ["artist", "album", "title"]: + fmt += "- {{{0}}} ".format(field) + else: + fmt += "[{0}: {{{0}}}] ".format(field) for item in items: kwargs = {} @@ -400,6 +412,10 @@ def display_library_items(self, items, fields, prefix=""): if hasattr(item, field): fld_val = item[field] + if type(fld_val) in [float, int]: + fld_val = round(fld_val, 3) + fld_val = "{:7.3f}".format(fld_val) + kwargs[field] = fld_val try: self._say(fmt.format(**kwargs)) @@ -407,9 +423,6 @@ def display_library_items(self, items, fields, prefix=""): pass def list_trainings(self): - """ - # @todo: order keys - """ if not self.config["trainings"].exists() or len(self.config["trainings"].keys()) == 0: self._say("You have not created any trainings yet.") return @@ -425,22 +438,38 @@ def list_training_attributes(self, training_name: str): self._say("Training[{0}] does not exist.".format(training_name), log_only=True) return + display_name = "[ {} ]".format(training_name) + self._say("\n{0}".format(display_name.center(80, "="))) + training: Subview = self.config["trainings"][training_name] - training_keys = training.keys() - self._say("{0} ::: {1}".format("=" * 40, training_name)) + training_keys = list(set(common.MUST_HAVE_TRAINING_KEYS) | set(training.keys())) + final_keys = ["duration", "query", "use_flavours", "combined_query", "ordering", "target"] + final_keys.extend(tk for tk in training_keys if tk not in final_keys) - training_keys = list(set(common.MUST_HAVE_TRAINING_KEYS) | set(training_keys)) - training_keys.sort() + for key in final_keys: + val = common.get_training_attribute(training, key) + + # Handle non-existent (made up) keys + if key == "combined_query" and common.get_training_attribute(training, "use_flavours"): + val = self._gather_query_elements(training) + + if val is None: + continue + + if key == "duration": + val = common.get_human_readable_time(val * 60) + elif key == "ordering": + val = dict(val) + elif key == "query": + pass - for tkey in training_keys: - tval = common.get_training_attribute(training, tkey) - if isinstance(tval, dict): + if isinstance(val, dict): value = [] - for k in tval: - value.append("{key}({val})".format(key=k, val=tval[k])) - tval = ", ".join(value) + for k in val: + value.append("{key}({val})".format(key=k, val=val[k])) + val = ", ".join(value) - self._say("{0}: {1}".format(tkey, tval)) + self._say("{0}: {1}".format(key, val)) def show_version_information(self): from beetsplug.goingrunning.version import __version__ diff --git a/beetsplug/goingrunning/common.py b/beetsplug/goingrunning/common.py index b6b4f33..707ae8f 100644 --- a/beetsplug/goingrunning/common.py +++ b/beetsplug/goingrunning/common.py @@ -11,7 +11,7 @@ from beets.library import Item from beets.util.confit import Subview -MUST_HAVE_TRAINING_KEYS = ['query', 'duration', 'target'] +MUST_HAVE_TRAINING_KEYS = ['duration', 'query', 'target'] MUST_HAVE_TARGET_KEYS = ['device_root', 'device_path'] KNOWN_NUMERIC_FLEX_ATTRIBUTES = ["danceable", "mood_acoustic", "mood_aggressive", "mood_electronic", "mood_happy", diff --git a/test/functional/002_command_test.py b/test/functional/002_command_test.py index 56af20a..cce5b86 100644 --- a/test/functional/002_command_test.py +++ b/test/functional/002_command_test.py @@ -34,9 +34,9 @@ def test_training_listing_empty(self): def test_training_listing_default(self): stdout = self.run_with_output(PLUGIN_NAME, "--list") - self.assertIn("::: training-1", stdout) - self.assertIn("::: training-2", stdout) - self.assertIn("::: training-3", stdout) + self.assertIn("[ training-1 ]", stdout) + self.assertIn("[ training-2 ]", stdout) + self.assertIn("[ training-3 ]", stdout) def test_training_handling_inexistent(self): training_name = "sitting_on_the_sofa" From 6abadf9372054afbcfb89599892e0cd7038d0472 Mon Sep 17 00:00:00 2001 From: Adam Jakab Date: Wed, 18 Mar 2020 23:33:15 +0100 Subject: [PATCH 39/39] added debug warning about short song list --- beetsplug/goingrunning/command.py | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/beetsplug/goingrunning/command.py b/beetsplug/goingrunning/command.py index b08024c..fe14d69 100644 --- a/beetsplug/goingrunning/command.py +++ b/beetsplug/goingrunning/command.py @@ -312,13 +312,15 @@ def _get_destination_path_for_training(self, training: Subview): return dst_path - def _get_items_for_duration(self, items, duration): + def _get_items_for_duration(self, items, requested_duration): + """ fixme: this must become much more accurate - the entire selection concept is to be revisited + """ selected = [] total_time = 0 _min, _max, _sum, _avg = common.get_min_max_sum_avg_for_items(items, "length") if _avg > 0: - est_num_songs = round(duration * 60 / _avg) + est_num_songs = round(requested_duration * 60 / _avg) else: est_num_songs = 0 @@ -335,9 +337,18 @@ def _get_items_for_duration(self, items, duration): bin_end = round(bin_start + bin_size) song_index = random.randint(bin_start, bin_end) try: - selected.append(items[song_index]) + item: Item = items[song_index] except IndexError: - pass + continue + + song_len = round(item.get("length")) + total_time += song_len + selected.append(item) + + self._say("Total time in list: {}".format(common.get_human_readable_time(total_time)), log_only=True) + + if total_time < requested_duration * 60: + self._say("Song list is too short!!!", log_only=True) return selected