Source code for musicdb.mdbapi.randy

# MusicDB,  a music manager with web-bases UI that focus on music.
# Copyright (C) 2017 - 2021  Ralf Stemmer <ralf.stemmer@gmx.net>
# 
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
# 
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
# 
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
"""
The *randy* module provides a way to select random songs or videos that can be put into the Song/Video Queue.
The selection of random music follows certain constraints.


Song Selection Algorithm
------------------------

Selecting a random song is done in two stages, the `Database Stage`_ and the `Blacklist Stage`_
The first stage selects a song from a limited set of songs, the second stage checks if the song is on a blacklist.
In each stage, a song can be rejected and the algorithm starts at the beginning.


.. warning::

    The process of trying to get a good song gets repeated until a good song is found.
    If over constraint, this ends in an infinite loop!


Database Stage
^^^^^^^^^^^^^^

In the first stage, a song gets chosen by the database via :meth:`musicdb.lib.db.musicdb.MusicDatabase.GetRandomSong`.
There are 4 parameters that define the constraints applied on set of possible songs:

    - The activated genres as maintained by the :mod:`musicdb.lib.cfg.mdbstate` module.
    - The flag if *disabled* songs shall be excluded
    - The flag if *hated* songs shall be excluded
    - Minimum and maximum length of a song in seconds

Some of them can be configured in the MusicDB configuration file:

    .. code-block:: ini

        [Randy]
        nodisabled=True
        nohated=True
        minsonglen=120

Because the database only takes album tags into account, the song tags gets checked afterwards.
If the song has a confirmed genre tag, and if this tag does not match the filter, the song gets rejected.
Song genres that are automatically set by an algorithm (and not confirmed by the user) will be ignored because the algorithm may be wrong.


Blacklist Stage
^^^^^^^^^^^^^^^

The selected song from the first stage now gets compared to the blacklists via 
:meth:`musicdb.mdbapi.blacklist.BlacklistInterface.CheckAllListsForSong` or
:meth:`musicdb.mdbapi.blacklist.BlacklistInterface.CheckSongList`.
If the song, or its album or artist, is listed in one of blacklist, 
then the song, a song from the same album or from the same artist was played recently.
So, the chosen song gets dropped and the finding-process starts again.

Is the blacklist length set to 0, the specific blacklist is disabled


Video Selection Algorithm
-------------------------

The selection of a video is a slightly simplified way as done for Songs.
For the blacklist stage it does not considers any Artist and Album associations and therefore the corresponding blacklists.

"""

import logging
import datetime
from musicdb.lib.cfg.musicdb    import MusicDBConfig
from musicdb.lib.db.musicdb     import MusicDatabase
from musicdb.lib.cfg.mdbstate   import MDBState
from musicdb.mdbapi.blacklist   import BlacklistInterface



[docs]class Randy(object): """ This class provides methods to get a random song or video under certain constraints. Args: config: :class:`~musicdb.lib.cfg.musicdb.MusicDBConfig` object holding the MusicDB Configuration database: A :class:`~musicdb.lib.db.musicdb.MusicDatabase` instance Raises: TypeError: When the arguments are not of the correct type. """ def __init__(self, config, database): if type(config) != MusicDBConfig: raise TypeError("config argument not of type MusicDBConfig") if type(database) != MusicDatabase: raise TypeError("database argument not of type MusicDatabase") self.db = database self.cfg = config self.mdbstate = MDBState(self.cfg.directories.state, self.db) self.blacklist = BlacklistInterface(self.cfg, self.db) # Load most important keys self.nodisabled = self.cfg.randy.nodisabled self.nohated = self.cfg.randy.nohated self.nohidden = self.cfg.randy.nohidden self.nobadfile = self.cfg.randy.nobadfile self.nolivemusic = self.cfg.randy.nolivemusic self.minlen = self.cfg.randy.minsonglen self.maxlen = self.cfg.randy.maxsonglen self.maxtries = self.cfg.randy.maxtries
[docs] def GetSong(self): """ This method chooses a random song in a two-stage process as described in the module description. .. warning:: Due to too hard constraints, it may be possible that it becomes impossible to find a new song. If this is the case, the method returns ``None``. The amount of tries can be configured in the MusicDB Configuration Returns: A song from the :class:`~musicdb.lib.db.musicdb.MusicDatabase` or ``None`` if an error occurred. """ global BlacklistLock global Blacklist logging.debug("Randy starts looking for a random song …") t_start = datetime.datetime.now() filterlist = self.mdbstate.GetFilterList() if not filterlist: logging.warning("No Genre selected! \033[1;30m(Selecting random song from the whole collection)") else: logging.debug("Genre filter: %s", str(filterlist)) # Get Random Song - this may take several tries song = None tries = 0 while not song: tries += 1 if tries > self.maxtries: logging.error("There was no valid song found within %i tries! \033[1;30m(Check the constraints)", self.maxtries) return None # STAGE 1: Get Mathematical random song (under certain constraints) try: song = self.db.GetRandomSong(filterlist, self.nodisabled, self.nohated, self.nohidden, self.nobadfile, self.nolivemusic, self.minlen, self.maxlen) except Exception as e: logging.error("Getting random song failed with error: \"%s\"!", str(e)) return None if not song: logging.error("There is no song fulfilling the constraints! \033[1;30m(Check the stage 1 constraints)") return None logging.debug("Candidate for next song: \033[0;35m" + song["path"]) # The MusicDatabase method GetRandomSong only looks for album genres. # The song genre may be different and not in the set of the filterlist. try: songgenres = self.db.GetTargetTags("song", song["id"], MusicDatabase.TAG_CLASS_GENRE) # Create a set of tagnames if there are tags for this song. # Ignore AI set tags because they may be wrong if songgenres: tagnames = { songgenre["name"] for songgenre in songgenres if songgenre["approval"] >= 1 } else: tagnames = { } # If the tag name set was successfully created, compare it with the selected genres if tagnames: logging.debug("Checking for intersection of song-genre and active-genre: %s%s", str(tagnames), str(filterlist)) if not tagnames & set(filterlist): logging.debug("song is of different genre than album and not in activated genres. (Song genres: %s)", str(tagnames)) song = None continue else: logging.debug("The song candidate has no genre tags. Assuming it matches the album genre.") except Exception as e: logging.error("Song tag check failed with exception: \"%s\"!", str(e)) return None # STAGE 2: Make randomness feeling random by checking if the song/album/artist was recently played if self.blacklist.CheckAllListsForSong(song): song = None continue # New song found \o/ t_stop = datetime.datetime.now() logging.debug("Randy found the following song after %s : \033[0;36m%s", str(t_stop-t_start), song["path"]) return song
[docs] def GetSongFromAlbum(self, albumid): """ Get a random song from a specific album. If the selected song is listed in the blacklist for songs, a new one will be selected. Entries in the album and artist blacklist will be ignored because the artist and album is forced by the user. But the song gets added to the blacklist for songs, as well as the album and artist gets added. The genre of the song gets completely ignored. The user wants to have a song from the given album, so it gets one. .. warning:: This is a dangerous method. An album only has a very limited set of songs. If all the songs are listed in the blacklist, the method would get caught in an infinite loop. To avoid this, there are only a limited amount of tries to find a random song. If the limit is reached, the method returns ``None``. The amount of tries can be configured in the MusicDB Configuration Args: albumid (int): ID of the album the song shall come from Returns: A song from the :class:`~musicdb.lib.db.musicdb.MusicDatabase` or ``None`` if an error occurred. """ global BlacklistLock global Blacklist # Get parameters song = None tries = 0 # there is just a very limited set of possible songs. Avoid infinite loop when all songs are on the blacklist while not song and tries <= self.maxtries: tries += 1 # STAGE 1: Get Mathematical random song (under certain constraints) try: song = self.db.GetRandomSong(None, self.nodisabled, self.nohated, self.nohidden, self.nobadfile, self.nolivemusic, self.minlen, self.maxlen, albumid) except Exception as e: logging.error("Getting random song failed with error: \"%s\"!", str(e)) return None logging.debug("Candidate for next song: \033[0;35m" + song["path"]) # STAGE 2: Make randomness feeling random by checking if the song was recently played # only check, if that song is in the blacklist. Artist and album is forced by the user if self.blacklist.CheckSongList(song): song = None continue if not song: logging.warning("The loop that should find a new random song did not deliver a song! \033[1;30m(This happens when there are too many songs of the given album are already on the blacklist)") return None # Add song to queue logging.debug("Randy adds the following song after %s tries: \033[0;36m%s", tries, song["path"]) return song
[docs] def GetVideo(self): """ This method chooses a random video in a simplified two-stage process as described in the module description. This method will be refined in future to fully behave like the :meth:`~GetSong` method. .. warning:: Due to too hard constraints, it may be possible that it becomes impossible to find a new song. If this is the case, the method returns ``None``. The amount of tries can be configured in the MusicDB Configuration Returns: A video from the :class:`~musicdb.lib.db.musicdb.MusicDatabase` or ``None`` if an error occurred. """ global BlacklistLock global Blacklist logging.debug("Randy starts looking for a random video …") import traceback trackback.print_stack() t_start = datetime.datetime.now() filterlist = self.mdbstate.GetFilterList() if not filterlist: logging.warning("No Genre selected! \033[1;30m(Selecting random video from the whole collection)") else: logging.debug("Genre filter: %s", str(filterlist)) # Get Random Video - this may take several tries video = None tries = 0 while not video: tries += 1 if tries > self.maxtries: logging.error("There was no valid video found within %i tries! \033[1;30m(Check the constraints)", self.maxtries) return None # STAGE 1: Get Mathematical random video (under certain constraints) try: video = self.db.GetRandomVideo(filterlist, self.nodisabled, self.nohated, self.minlen) except Exception as e: logging.error("Getting random video failed with error: \"%s\"!", str(e)) return None if not video: logging.error("There is no video fulfilling the constraints! \033[1;30m(Check the stage 1 constraints)") return None logging.debug("Candidate for next video: \033[0;35m" + video["path"]) # STAGE 2: Make randomness feeling random by checking if the video/artist was recently played if self.blacklist.CheckAllListsForVideo(video): video = None continue # New video found \o/ t_stop = datetime.datetime.now() logging.debug("Randy found the following video after %s : \033[0;36m%s", str(t_stop-t_start), video["path"]) return video
# vim: tabstop=4 expandtab shiftwidth=4 softtabstop=4