From d22a8a94087baf1a49195c5f39074860ea183192 Mon Sep 17 00:00:00 2001 From: ATawzer <34928044+ATawzer@users.noreply.github.com> Date: Wed, 14 Apr 2021 18:38:51 -0600 Subject: [PATCH 01/29] updated dependencies --- .gitignore | 139 +++++++++++++++++++++++++ Pipfile | 1 + Pipfile.lock | 279 +++++++++++++++++++++++++++++++++++++++++++-------- 3 files changed, 376 insertions(+), 43 deletions(-) diff --git a/.gitignore b/.gitignore index e46cd3b..93636f2 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,142 @@ storm/config/config_secret.json .ipynb_checkpoints token.json + +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +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 + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .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 +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ \ No newline at end of file diff --git a/Pipfile b/Pipfile index a4e7e84..4b80207 100644 --- a/Pipfile +++ b/Pipfile @@ -12,6 +12,7 @@ pymongo = "*" pyssl = "*" python-dotenv = "*" tqdm = "*" +awswrangler = "*" [dev-packages] diff --git a/Pipfile.lock b/Pipfile.lock index daa7e9e..15bc41f 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "3642243a02f20e937f3cddc5091dcfe14b28d120de3cf94d1fc6796ab320ada2" + "sha256": "969cebf233385040a6b16dfb44facfea7746a5ca86d3b2a83e310e2f199d247f" }, "pipfile-spec": 6, "requires": { @@ -16,14 +16,6 @@ ] }, "default": { - "appnope": { - "hashes": [ - "sha256:93aa393e9d6c54c5cd570ccadd8edad61ea0c4b9ea7a01409020c9aa019eb442", - "sha256:dd83cd4b5b460958838f6eb3000c660b1f9caf2a5b1de4264e941512f603258a" - ], - "markers": "sys_platform == 'darwin' and platform_system == 'Darwin'", - "version": "==0.1.2" - }, "argon2-cffi": { "hashes": [ "sha256:05a8ac07c7026542377e38389638a8a1e9b78f1cd8439cd7493b39f08dd75fbf", @@ -47,6 +39,13 @@ ], "version": "==20.1.0" }, + "asn1crypto": { + "hashes": [ + "sha256:4bcdf33c861c7d40bdcd74d8e4dd7661aac320fcdf40b9a3f95b4ee12fde2fa8", + "sha256:f4f6e119474e58e04a2b1af817eb585b4fd72bdd89b998624712b5c99be7641c" + ], + "version": "==1.4.0" + }, "async-generator": { "hashes": [ "sha256:01c7bf666359b4967d2cda0000cc2e4af16a0ae098cbffcb8472fb9e8ad6585b", @@ -63,6 +62,15 @@ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==20.3.0" }, + "awswrangler": { + "hashes": [ + "sha256:5c1c0f4dab87f218241801aaccad3a9c3da81502671de65579befb4563450ed1", + "sha256:9620c2dfac8481e17d726ab4bfb0bdbdd5d700dfb7c979ded338125bde4bfb16", + "sha256:be3275c323ff44aa16286c0cfc17edd64adbb2fb95a35bc17eb21e5c543201f4" + ], + "index": "pypi", + "version": "==2.6.0" + }, "backcall": { "hashes": [ "sha256:5cbdbf27be5e7cfadb448baf0aa95508f91f2bbc6c6437cd9cd06e2a4c215e1e", @@ -70,6 +78,14 @@ ], "version": "==0.2.0" }, + "beautifulsoup4": { + "hashes": [ + "sha256:4c98143716ef1cb40bf7f39a8e3eec8f8b009509e74904ba3a7b315431577e35", + "sha256:84729e322ad1d5b4d25f805bfa05b902dd96450f43842c4e99067d5e1369eb25", + "sha256:fff47e031e34ec82bf17e00da8f592fe7de69aeea38be00523c04623c04fb666" + ], + "version": "==4.9.3" + }, "bleach": { "hashes": [ "sha256:6123ddc1052673e52bab52cdc955bcb57a015264a1c57d37bea2f6b817af0125", @@ -78,6 +94,22 @@ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", "version": "==3.3.0" }, + "boto3": { + "hashes": [ + "sha256:41b1ba590e887b85520c0e97e811630b8eeb71860c9b1faa3190c3bd45856176", + "sha256:ed640c17c97af289be4693740c1cbf95a456e9c495e3973a1ed6f51a396846d2" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'", + "version": "==1.17.52" + }, + "botocore": { + "hashes": [ + "sha256:cd24db07268d3b9356cb745aeb6de1e4aaa73b555843b9f8650f5b4068051013", + "sha256:dd5f5808ec48a999b9634b387ad6ab7a1a23ba1f9712a875066d234808f8aa62" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'", + "version": "==1.20.52" + }, "certifi": { "hashes": [ "sha256:1a4995114262bffbc2413b159f2a1a480c969de6e6eb13ee966d470af86af59c", @@ -135,13 +167,21 @@ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", "version": "==4.0.0" }, + "colorama": { + "hashes": [ + "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b", + "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2" + ], + "markers": "sys_platform == 'win32'", + "version": "==0.4.4" + }, "decorator": { "hashes": [ - "sha256:d9f2d2863183a3c0df05f4b786f2e6b8752c093b3547a558f287bf3022fd2bf4", - "sha256:f2e71efb39412bfd23d878e896a51b07744f2e2250b2e87d158e76828c5ae202" + "sha256:6f201a6c4dac3d187352661f508b9364ec8091217442c9478f1f83c003a0f060", + "sha256:945d84890bb20cc4a2f4a31fc4311c0c473af65ea318617f13a7257c9a58bc98" ], "markers": "python_version >= '3.5'", - "version": "==5.0.6" + "version": "==5.0.7" }, "defusedxml": { "hashes": [ @@ -167,6 +207,12 @@ "markers": "python_version >= '2.7'", "version": "==0.3" }, + "et-xmlfile": { + "hashes": [ + "sha256:614d9722d572f6246302c4491846d2c393c199cfa4edc9af593437691683335b" + ], + "version": "==1.0.1" + }, "idna": { "hashes": [ "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6", @@ -221,6 +267,14 @@ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", "version": "==2.11.3" }, + "jmespath": { + "hashes": [ + "sha256:b85d0567b8666149a93172712e68920734333c0ce7e89b78b3e987f71e5ed4f9", + "sha256:cdf6525904cc597730141d61b36f2e4b8ecc257c420fa2f4549bac2c2d0cb72f" + ], + "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==0.10.0" + }, "jsonschema": { "hashes": [ "sha256:4e5b3cf8216f577bee9ce139cbe72eca3ea4f292ec60928ff24758ce626cd163", @@ -276,6 +330,48 @@ "markers": "python_version >= '3.6'", "version": "==1.0.0" }, + "lxml": { + "hashes": [ + "sha256:079f3ae844f38982d156efce585bc540c16a926d4436712cf4baee0cce487a3d", + "sha256:0fbcf5565ac01dff87cbfc0ff323515c823081c5777a9fc7703ff58388c258c3", + "sha256:122fba10466c7bd4178b07dba427aa516286b846b2cbd6f6169141917283aae2", + "sha256:1b7584d421d254ab86d4f0b13ec662a9014397678a7c4265a02a6d7c2b18a75f", + "sha256:26e761ab5b07adf5f555ee82fb4bfc35bf93750499c6c7614bd64d12aaa67927", + "sha256:289e9ca1a9287f08daaf796d96e06cb2bc2958891d7911ac7cae1c5f9e1e0ee3", + "sha256:2a9d50e69aac3ebee695424f7dbd7b8c6d6eb7de2a2eb6b0f6c7db6aa41e02b7", + "sha256:33bb934a044cf32157c12bfcfbb6649807da20aa92c062ef51903415c704704f", + "sha256:3439c71103ef0e904ea0a1901611863e51f50b5cd5e8654a151740fde5e1cade", + "sha256:39b78571b3b30645ac77b95f7c69d1bffc4cf8c3b157c435a34da72e78c82468", + "sha256:4289728b5e2000a4ad4ab8da6e1db2e093c63c08bdc0414799ee776a3f78da4b", + "sha256:4bff24dfeea62f2e56f5bab929b4428ae6caba2d1eea0c2d6eb618e30a71e6d4", + "sha256:542d454665a3e277f76954418124d67516c5f88e51a900365ed54a9806122b83", + "sha256:5a0a14e264069c03e46f926be0d8919f4105c1623d620e7ec0e612a2e9bf1c04", + "sha256:66e575c62792c3f9ca47cb8b6fab9e35bab91360c783d1606f758761810c9791", + "sha256:74f7d8d439b18fa4c385f3f5dfd11144bb87c1da034a466c5b5577d23a1d9b51", + "sha256:7610b8c31688f0b1be0ef882889817939490a36d0ee880ea562a4e1399c447a1", + "sha256:76fa7b1362d19f8fbd3e75fe2fb7c79359b0af8747e6f7141c338f0bee2f871a", + "sha256:7728e05c35412ba36d3e9795ae8995e3c86958179c9770e65558ec3fdfd3724f", + "sha256:8157dadbb09a34a6bd95a50690595e1fa0af1a99445e2744110e3dca7831c4ee", + "sha256:820628b7b3135403540202e60551e741f9b6d3304371712521be939470b454ec", + "sha256:884ab9b29feaca361f7f88d811b1eea9bfca36cf3da27768d28ad45c3ee6f969", + "sha256:89b8b22a5ff72d89d48d0e62abb14340d9e99fd637d046c27b8b257a01ffbe28", + "sha256:92e821e43ad382332eade6812e298dc9701c75fe289f2a2d39c7960b43d1e92a", + "sha256:b007cbb845b28db4fb8b6a5cdcbf65bacb16a8bd328b53cbc0698688a68e1caa", + "sha256:bc4313cbeb0e7a416a488d72f9680fffffc645f8a838bd2193809881c67dd106", + "sha256:bccbfc27563652de7dc9bdc595cb25e90b59c5f8e23e806ed0fd623755b6565d", + "sha256:c4f05c5a7c49d2fb70223d0d5bcfbe474cf928310ac9fa6a7c6dddc831d0b1d4", + "sha256:ce256aaa50f6cc9a649c51be3cd4ff142d67295bfc4f490c9134d0f9f6d58ef0", + "sha256:d2e35d7bf1c1ac8c538f88d26b396e73dd81440d59c1ef8522e1ea77b345ede4", + "sha256:df7c53783a46febb0e70f6b05df2ba104610f2fb0d27023409734a3ecbb78fb2", + "sha256:efac139c3f0bf4f0939f9375af4b02c5ad83a622de52d6dfa8e438e8e01d0eb0", + "sha256:efd7a09678fd8b53117f6bae4fa3825e0a22b03ef0a932e070c0bdbb3a35e654", + "sha256:f2380a6376dfa090227b663f9678150ef27543483055cc327555fb592c5967e2", + "sha256:f8380c03e45cf09f8557bdaa41e1fa7c81f3ae22828e1db470ab2a6c96d8bc23", + "sha256:f90ba11136bfdd25cae3951af8da2e95121c9b9b93727b1b896e3fa105b2f586" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", + "version": "==4.6.3" + }, "markupsafe": { "hashes": [ "sha256:00bc623926325b26bb9605ae9eae8a215691f33cae5df11ca5424f06f2d1f473", @@ -411,6 +507,14 @@ "markers": "python_version >= '3.7'", "version": "==1.20.2" }, + "openpyxl": { + "hashes": [ + "sha256:46af4eaf201a89b610fcca177eed957635f88770a5462fb6aae4a2a52b0ff516", + "sha256:6456a3b472e1ef0facb1129f3c6ef00713cebf62e736cd7a75bcc3247432f251" + ], + "markers": "python_version >= '3.6'", + "version": "==3.0.7" + }, "packaging": { "hashes": [ "sha256:5b327ac1320dc863dca72f4514ecc086f31186744b84a230374cc1fd776feae5", @@ -421,25 +525,25 @@ }, "pandas": { "hashes": [ - "sha256:09761bf5f8c741d47d4b8b9073288de1be39bbfccc281d70b889ade12b2aad29", - "sha256:0f27fd1adfa256388dc34895ca5437eaf254832223812afd817a6f73127f969c", - "sha256:43e00770552595c2250d8d712ec8b6e08ca73089ac823122344f023efa4abea3", - "sha256:46fc671c542a8392a4f4c13edc8527e3a10f6cb62912d856f82248feb747f06e", - "sha256:475b7772b6e18a93a43ea83517932deff33954a10d4fbae18d0c1aba4182310f", - "sha256:4d821b9b911fc1b7d428978d04ace33f0af32bb7549525c8a7b08444bce46b74", - "sha256:5e3c8c60541396110586bcbe6eccdc335a38e7de8c217060edaf4722260b158f", - "sha256:621c044a1b5e535cf7dcb3ab39fca6f867095c3ef223a524f18f60c7fee028ea", - "sha256:72ffcea00ae8ffcdbdefff800284311e155fbb5ed6758f1a6110fc1f8f8f0c1c", - "sha256:8a051e957c5206f722e83f295f95a2cf053e890f9a1fba0065780a8c2d045f5d", - "sha256:97b1954533b2a74c7e20d1342c4f01311d3203b48f2ebf651891e6a6eaf01104", - "sha256:9f5829e64507ad10e2561b60baf285c470f3c4454b007c860e77849b88865ae7", - "sha256:a93e34f10f67d81de706ce00bf8bb3798403cabce4ccb2de10c61b5ae8786ab5", - "sha256:d59842a5aa89ca03c2099312163ffdd06f56486050e641a45d926a072f04d994", - "sha256:dbb255975eb94143f2e6ec7dadda671d25147939047839cd6b8a4aff0379bb9b", - "sha256:df6f10b85aef7a5bb25259ad651ad1cc1d6bb09000595cab47e718cbac250b1d" + "sha256:167693a80abc8eb28051fbd184c1b7afd13ce2c727a5af47b048f1ea3afefff4", + "sha256:2111c25e69fa9365ba80bbf4f959400054b2771ac5d041ed19415a8b488dc70a", + "sha256:298f0553fd3ba8e002c4070a723a59cdb28eda579f3e243bc2ee397773f5398b", + "sha256:2b063d41803b6a19703b845609c0b700913593de067b552a8b24dd8eeb8c9895", + "sha256:2cb7e8f4f152f27dc93f30b5c7a98f6c748601ea65da359af734dd0cf3fa733f", + "sha256:52d2472acbb8a56819a87aafdb8b5b6d2b3386e15c95bde56b281882529a7ded", + "sha256:612add929bf3ba9d27b436cc8853f5acc337242d6b584203f207e364bb46cb12", + "sha256:649ecab692fade3cbfcf967ff936496b0cfba0af00a55dfaacd82bdda5cb2279", + "sha256:68d7baa80c74aaacbed597265ca2308f017859123231542ff8a5266d489e1858", + "sha256:8d4c74177c26aadcfb4fd1de6c1c43c2bf822b3e0fc7a9b409eeaf84b3e92aaa", + "sha256:971e2a414fce20cc5331fe791153513d076814d30a60cd7348466943e6e909e4", + "sha256:9db70ffa8b280bb4de83f9739d514cd0735825e79eef3a61d312420b9f16b758", + "sha256:b730add5267f873b3383c18cac4df2527ac4f0f0eed1c6cf37fcb437e25cf558", + "sha256:bd659c11a4578af740782288cac141a322057a2e36920016e0fc7b25c5a4b686", + "sha256:c601c6fdebc729df4438ec1f62275d6136a0dd14d332fc0e8ce3f7d2aadb4dd6", + "sha256:d0877407359811f7b853b548a614aacd7dea83b0c0c84620a9a643f180060950" ], "index": "pypi", - "version": "==1.2.3" + "version": "==1.2.4" }, "pandocfilters": { "hashes": [ @@ -455,13 +559,13 @@ "markers": "python_version >= '3.6'", "version": "==0.8.2" }, - "pexpect": { + "pg8000": { "hashes": [ - "sha256:0b48a55dcb3c05f3329815901ea4fc1537514d6ba867a152b581d69ae3710937", - "sha256:fc65a43959d153d0114afe13997d439c22823a27cefceb5ff35c2178c6784c0c" + "sha256:240a5e7c3118ea07179a02ff8daeacf93d68ab9546ea140ca9d77970c4c5fc9d", + "sha256:35baf2c8bf5445e85f516449474b547dbbd0e08c0baa3a6b20aa355a92eb72da" ], - "markers": "sys_platform != 'win32'", - "version": "==4.8.0" + "markers": "python_version >= '3.6'", + "version": "==1.18.0" }, "pickleshare": { "hashes": [ @@ -486,13 +590,32 @@ "markers": "python_full_version >= '3.6.1'", "version": "==3.0.18" }, - "ptyprocess": { - "hashes": [ - "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35", - "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220" + "pyarrow": { + "hashes": [ + "sha256:03e2435da817bc2b5d0fad6f2e53305eb36c24004ddfcb2b30e4217a1a80cf22", + "sha256:2be3a9eab4bfd00024dc3c83fa03de1c1d04a0f47ebaf3dc483cd100546eacbf", + "sha256:2c3353d38d137f1158595b3b18dcef711f3d8fdb57cf7ae2d861d07235064bc1", + "sha256:2d5c95eb04a3d2e786e097b53534893eade6c8b3faf10f53a06143384b4446b1", + "sha256:31e6fc0868963aba4e6b8a3e218c9a5ff347bca870d622da0b3d58269d0c5398", + "sha256:3b46487c45faaea8d1a5aa65002e2832ae2e1c9e68ecb461cda4fa59891cf490", + "sha256:3ea6574d1ae2d9bff7e6e1715f64c31bdc01b42387a5c78311a8ce9c09cfe135", + "sha256:4bf8cc43e1db1e0517466209ee8e8f459d9b5e1b4074863317f2a965cf59889e", + "sha256:5faa2dc73444bdcf042f121383965a47362be1f946303d46e8fd80f8d26cd90c", + "sha256:72206cde1857d5420601feae75f53921cffab4326b42262a858c7b8be67982b7", + "sha256:960a9b0fd599601ddac42f16d5acf049637ec08957359c6741d6eb2bf0dbae97", + "sha256:978bbe8ec9090d1133a25f00f32ed92600f9d315fbfa29a17952bee01f0d7fe5", + "sha256:a07e286e81ceb20f8f0c45f69760d2ebc434fe83794d5f9b44f89fc2dc6dc24d", + "sha256:a76031ef19d11db2fef79a97cc69997c97bea35aa07efbe042a177c7e3b1a390", + "sha256:b08c119cc2b9fcd1567797fedb245a2f4352a3084a22b7298272afe7cf7a4730", + "sha256:b1cf92df9f336f31706249e543dc0ffce3c67a78204ce540f1173c6c07dfafec", + "sha256:b7a8903f2b8a80498725ef5d4a35cd7dd5a98b74e080d42692545e61a6cbfbe4", + "sha256:bf6684fe9e38f8ddb696e38901461eab783ec1d565974ebd5862270320b3e27f", + "sha256:cfea99a01d844c3db5e25374a6cdcf3b5ba1698bfe95d41272c295a4581e884c", + "sha256:d5666a7fa2668f3ff95df028c2072d59e8b17e73d682068e8505dafa2688f3cc", + "sha256:dec007a0f7adba86bd170252140ede01646b45c3a470d5862ce00d8e40cd29bd" ], - "markers": "os_name != 'nt'", - "version": "==0.7.0" + "markers": "python_version >= '3.6'", + "version": "==3.0.0" }, "pycparser": { "hashes": [ @@ -580,6 +703,14 @@ "index": "pypi", "version": "==3.11.3" }, + "pymysql": { + "hashes": [ + "sha256:41fc3a0c5013d5f039639442321185532e3e2c8924687abe6537de157d403641", + "sha256:816927a350f38d56072aeca5dfb10221fe1dc653745853d30a216637f5d7ad36" + ], + "markers": "python_version >= '3.6'", + "version": "==1.0.2" + }, "pyparsing": { "hashes": [ "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1", @@ -626,6 +757,38 @@ ], "version": "==2021.1" }, + "pywin32": { + "hashes": [ + "sha256:1c204a81daed2089e55d11eefa4826c05e604d27fe2be40b6bf8db7b6a39da63", + "sha256:27a30b887afbf05a9cbb05e3ffd43104a9b71ce292f64a635389dbad0ed1cd85", + "sha256:350c5644775736351b77ba68da09a39c760d75d2467ecec37bd3c36a94fbed64", + "sha256:60a8fa361091b2eea27f15718f8eb7f9297e8d51b54dbc4f55f3d238093d5190", + "sha256:638b68eea5cfc8def537e43e9554747f8dee786b090e47ead94bfdafdb0f2f50", + "sha256:8151e4d7a19262d6694162d6da85d99a16f8b908949797fd99c83a0bfaf5807d", + "sha256:a3b4c48c852d4107e8a8ec980b76c94ce596ea66d60f7a697582ea9dce7e0db7", + "sha256:b1609ce9bd5c411b81f941b246d683d6508992093203d4eb7f278f4ed1085c3f", + "sha256:d7e8c7efc221f10d6400c19c32a031add1c4a58733298c09216f57b4fde110dc", + "sha256:fbb3b1b0fbd0b4fc2a3d1d81fe0783e30062c1abed1d17c32b7879d55858cfae" + ], + "markers": "sys_platform == 'win32'", + "version": "==300" + }, + "pywinpty": { + "hashes": [ + "sha256:1e525a4de05e72016a7af27836d512db67d06a015aeaf2fa0180f8e6a039b3c2", + "sha256:2740eeeb59297593a0d3f762269b01d0285c1b829d6827445fcd348fb47f7e70", + "sha256:2d7e9c881638a72ffdca3f5417dd1563b60f603e1b43e5895674c2a1b01f95a0", + "sha256:33df97f79843b2b8b8bc5c7aaf54adec08cc1bae94ee99dfb1a93c7a67704d95", + "sha256:5fb2c6c6819491b216f78acc2c521b9df21e0f53b9a399d58a5c151a3c4e2a2d", + "sha256:8fc5019ff3efb4f13708bd3b5ad327589c1a554cb516d792527361525a7cb78c", + "sha256:b358cb552c0f6baf790de375fab96524a0498c9df83489b8c23f7f08795e966b", + "sha256:dbd838de92de1d4ebf0dce9d4d5e4fc38d0b7b1de837947a18b57a882f219139", + "sha256:dd22c8efacf600730abe4a46c1388355ce0d4ab75dc79b15d23a7bd87bf05b48", + "sha256:e854211df55d107f0edfda8a80b39dfc87015bef52a8fe6594eb379240d81df2" + ], + "markers": "os_name == 'nt'", + "version": "==0.5.7" + }, "pyzmq": { "hashes": [ "sha256:13465c1ff969cab328bc92f7015ce3843f6e35f8871ad79d236e4fbc85dbe4cb", @@ -679,6 +842,13 @@ ], "version": "==1.9.0" }, + "redshift-connector": { + "hashes": [ + "sha256:b4e587715ae62d9bab53ea89fb3348811d2dacee4ec59a9d8a2be5b108a84542" + ], + "markers": "python_version >= '3.5'", + "version": "==2.0.877" + }, "requests": { "hashes": [ "sha256:27973dd4a904a4f13b263a19c866c13b92a39ed1c964655f025f3f8d3d75b804", @@ -687,6 +857,21 @@ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", "version": "==2.25.1" }, + "s3transfer": { + "hashes": [ + "sha256:35627b86af8ff97e7ac27975fe0a98a312814b46c6333d8a6b889627bcd80994", + "sha256:efa5bd92a897b6a8d5c1383828dca3d52d0790e0756d49740563a3fb6ed03246" + ], + "version": "==0.3.7" + }, + "scramp": { + "hashes": [ + "sha256:ac578bf7b49645ca1083117e40f4e8af2073b003750d5bf21b3285ff342a4f33", + "sha256:c1d0b8d6f890e4e72ccd9bae23e802bfb377d50c2843396e5997d262fbfe2103" + ], + "markers": "python_version >= '3.6'", + "version": "==1.2.2" + }, "send2trash": { "hashes": [ "sha256:60001cc07d707fe247c94f74ca6ac0d3255aabcb930529690897ca2a39db28b2", @@ -702,13 +887,21 @@ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.15.0" }, + "soupsieve": { + "hashes": [ + "sha256:052774848f448cf19c7e959adf5566904d525f33a3f8b6ba6f6f8f26ec7de0cc", + "sha256:c2c1c2d44f158cdbddab7824a9af8c4f83c76b1e23e049479aa432feb6c4c23b" + ], + "markers": "python_version >= '3.0'", + "version": "==2.2.1" + }, "spotipy": { "hashes": [ - "sha256:1164f4bb327a2b98492a020d120f095dafcdb86e7f99ad2fdfb5bdd95eb4493a", - "sha256:29c60c8b99da1c4b9f0d722169bc31e624b8c07d7186b8eadd9c02e8d2d42cbf" + "sha256:8acbc18dd44e1c22b3da500ca9225c5d2f7476f2e68d5d56a317b0b8c87ec8a5", + "sha256:f7293b808696807e9acec6bdcff63f7dcc3cc1b148c0c4b4299ef43c966f7177" ], "index": "pypi", - "version": "==2.17.1" + "version": "==2.18.0" }, "terminado": { "hashes": [ From 4902a757e082da3a4a9a0c9460cc37c64d6d8d84 Mon Sep 17 00:00:00 2001 From: ATawzer <34928044+ATawzer@users.noreply.github.com> Date: Thu, 15 Apr 2021 16:15:38 -0600 Subject: [PATCH 02/29] added main "interface" for calling storms --- weatherboy.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 weatherboy.py diff --git a/weatherboy.py b/weatherboy.py new file mode 100644 index 0000000..e69de29 From 0ea8481732d35b5464fa196fe8c6b58126935dbb Mon Sep 17 00:00:00 2001 From: ATawzer <34928044+ATawzer@users.noreply.github.com> Date: Thu, 15 Apr 2021 16:15:49 -0600 Subject: [PATCH 03/29] switching back to mongo --- .cache-1241528689 | 2 +- Pipfile | 1 - Pipfile.lock | 179 +------------------------------------------- src/db.py | 20 ++++- src/storm_client.py | 64 ++++++++++++++-- 5 files changed, 79 insertions(+), 187 deletions(-) diff --git a/.cache-1241528689 b/.cache-1241528689 index 77c84bc..084392d 100644 --- a/.cache-1241528689 +++ b/.cache-1241528689 @@ -1 +1 @@ -{"access_token": "BQCXOeVx3tqRXMnJYZvyArG-IeXrr2w9JYbTNffOmHY0n1Sw2xsAse8KUJH193GwRDJnpAlylbrNwJzqfBQMvFOqHF1dgOBrovrPe2Udp3wWHCvs6k55gplLE4BlTjDH7IQdfpM-DSeHqfCnhndyqfIH80uTNWseiFgRsfjTMjUV9vysotl3np0uefImINLMLdhDrebuiOrjO_OvRxWl9lQ", "token_type": "Bearer", "expires_in": 3600, "scope": "playlist-modify-private playlist-modify-public user-follow-modify user-follow-read", "expires_at": 1618268185, "refresh_token": "AQAsxkWjXR0Iw8q65vbKmXUR0cOGEM8liRshm9vhsJbDenCcjijwBgyKF91oCqQ8NjdD8fwk3uO-NKGUVWYtWRF0E2f5ydGSyFlJRi29TR1Zyw71OKdaIs89XzUBfCOOO0M"} \ No newline at end of file +{"access_token": "BQAY3167-1hf3r-k18-0gcDmJg3wQriQCvnOmCxeCRhS4_KjkwX4RA9fmYSAt6ghguWkAu3aKFdgqsENYsxxDgZP6U2iCSBfV6PwKk3uBiir790xKXytuy0D2U_MA1BhgqtVjNDJcVFEg94CtIxcu-Gp824w9Tz8LNBERAr89tf86knS4UKMhRTv8j3afybcRLN903HWT2wGkWno3KxRH40", "token_type": "Bearer", "expires_in": 3600, "scope": "playlist-modify-private playlist-modify-public user-follow-modify user-follow-read", "expires_at": 1618449365, "refresh_token": "AQAsxkWjXR0Iw8q65vbKmXUR0cOGEM8liRshm9vhsJbDenCcjijwBgyKF91oCqQ8NjdD8fwk3uO-NKGUVWYtWRF0E2f5ydGSyFlJRi29TR1Zyw71OKdaIs89XzUBfCOOO0M"} \ No newline at end of file diff --git a/Pipfile b/Pipfile index 4b80207..a4e7e84 100644 --- a/Pipfile +++ b/Pipfile @@ -12,7 +12,6 @@ pymongo = "*" pyssl = "*" python-dotenv = "*" tqdm = "*" -awswrangler = "*" [dev-packages] diff --git a/Pipfile.lock b/Pipfile.lock index 15bc41f..0228f25 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "969cebf233385040a6b16dfb44facfea7746a5ca86d3b2a83e310e2f199d247f" + "sha256": "3642243a02f20e937f3cddc5091dcfe14b28d120de3cf94d1fc6796ab320ada2" }, "pipfile-spec": 6, "requires": { @@ -39,13 +39,6 @@ ], "version": "==20.1.0" }, - "asn1crypto": { - "hashes": [ - "sha256:4bcdf33c861c7d40bdcd74d8e4dd7661aac320fcdf40b9a3f95b4ee12fde2fa8", - "sha256:f4f6e119474e58e04a2b1af817eb585b4fd72bdd89b998624712b5c99be7641c" - ], - "version": "==1.4.0" - }, "async-generator": { "hashes": [ "sha256:01c7bf666359b4967d2cda0000cc2e4af16a0ae098cbffcb8472fb9e8ad6585b", @@ -62,15 +55,6 @@ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==20.3.0" }, - "awswrangler": { - "hashes": [ - "sha256:5c1c0f4dab87f218241801aaccad3a9c3da81502671de65579befb4563450ed1", - "sha256:9620c2dfac8481e17d726ab4bfb0bdbdd5d700dfb7c979ded338125bde4bfb16", - "sha256:be3275c323ff44aa16286c0cfc17edd64adbb2fb95a35bc17eb21e5c543201f4" - ], - "index": "pypi", - "version": "==2.6.0" - }, "backcall": { "hashes": [ "sha256:5cbdbf27be5e7cfadb448baf0aa95508f91f2bbc6c6437cd9cd06e2a4c215e1e", @@ -78,14 +62,6 @@ ], "version": "==0.2.0" }, - "beautifulsoup4": { - "hashes": [ - "sha256:4c98143716ef1cb40bf7f39a8e3eec8f8b009509e74904ba3a7b315431577e35", - "sha256:84729e322ad1d5b4d25f805bfa05b902dd96450f43842c4e99067d5e1369eb25", - "sha256:fff47e031e34ec82bf17e00da8f592fe7de69aeea38be00523c04623c04fb666" - ], - "version": "==4.9.3" - }, "bleach": { "hashes": [ "sha256:6123ddc1052673e52bab52cdc955bcb57a015264a1c57d37bea2f6b817af0125", @@ -94,22 +70,6 @@ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", "version": "==3.3.0" }, - "boto3": { - "hashes": [ - "sha256:41b1ba590e887b85520c0e97e811630b8eeb71860c9b1faa3190c3bd45856176", - "sha256:ed640c17c97af289be4693740c1cbf95a456e9c495e3973a1ed6f51a396846d2" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'", - "version": "==1.17.52" - }, - "botocore": { - "hashes": [ - "sha256:cd24db07268d3b9356cb745aeb6de1e4aaa73b555843b9f8650f5b4068051013", - "sha256:dd5f5808ec48a999b9634b387ad6ab7a1a23ba1f9712a875066d234808f8aa62" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'", - "version": "==1.20.52" - }, "certifi": { "hashes": [ "sha256:1a4995114262bffbc2413b159f2a1a480c969de6e6eb13ee966d470af86af59c", @@ -207,12 +167,6 @@ "markers": "python_version >= '2.7'", "version": "==0.3" }, - "et-xmlfile": { - "hashes": [ - "sha256:614d9722d572f6246302c4491846d2c393c199cfa4edc9af593437691683335b" - ], - "version": "==1.0.1" - }, "idna": { "hashes": [ "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6", @@ -267,14 +221,6 @@ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", "version": "==2.11.3" }, - "jmespath": { - "hashes": [ - "sha256:b85d0567b8666149a93172712e68920734333c0ce7e89b78b3e987f71e5ed4f9", - "sha256:cdf6525904cc597730141d61b36f2e4b8ecc257c420fa2f4549bac2c2d0cb72f" - ], - "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==0.10.0" - }, "jsonschema": { "hashes": [ "sha256:4e5b3cf8216f577bee9ce139cbe72eca3ea4f292ec60928ff24758ce626cd163", @@ -330,48 +276,6 @@ "markers": "python_version >= '3.6'", "version": "==1.0.0" }, - "lxml": { - "hashes": [ - "sha256:079f3ae844f38982d156efce585bc540c16a926d4436712cf4baee0cce487a3d", - "sha256:0fbcf5565ac01dff87cbfc0ff323515c823081c5777a9fc7703ff58388c258c3", - "sha256:122fba10466c7bd4178b07dba427aa516286b846b2cbd6f6169141917283aae2", - "sha256:1b7584d421d254ab86d4f0b13ec662a9014397678a7c4265a02a6d7c2b18a75f", - "sha256:26e761ab5b07adf5f555ee82fb4bfc35bf93750499c6c7614bd64d12aaa67927", - "sha256:289e9ca1a9287f08daaf796d96e06cb2bc2958891d7911ac7cae1c5f9e1e0ee3", - "sha256:2a9d50e69aac3ebee695424f7dbd7b8c6d6eb7de2a2eb6b0f6c7db6aa41e02b7", - "sha256:33bb934a044cf32157c12bfcfbb6649807da20aa92c062ef51903415c704704f", - "sha256:3439c71103ef0e904ea0a1901611863e51f50b5cd5e8654a151740fde5e1cade", - "sha256:39b78571b3b30645ac77b95f7c69d1bffc4cf8c3b157c435a34da72e78c82468", - "sha256:4289728b5e2000a4ad4ab8da6e1db2e093c63c08bdc0414799ee776a3f78da4b", - "sha256:4bff24dfeea62f2e56f5bab929b4428ae6caba2d1eea0c2d6eb618e30a71e6d4", - "sha256:542d454665a3e277f76954418124d67516c5f88e51a900365ed54a9806122b83", - "sha256:5a0a14e264069c03e46f926be0d8919f4105c1623d620e7ec0e612a2e9bf1c04", - "sha256:66e575c62792c3f9ca47cb8b6fab9e35bab91360c783d1606f758761810c9791", - "sha256:74f7d8d439b18fa4c385f3f5dfd11144bb87c1da034a466c5b5577d23a1d9b51", - "sha256:7610b8c31688f0b1be0ef882889817939490a36d0ee880ea562a4e1399c447a1", - "sha256:76fa7b1362d19f8fbd3e75fe2fb7c79359b0af8747e6f7141c338f0bee2f871a", - "sha256:7728e05c35412ba36d3e9795ae8995e3c86958179c9770e65558ec3fdfd3724f", - "sha256:8157dadbb09a34a6bd95a50690595e1fa0af1a99445e2744110e3dca7831c4ee", - "sha256:820628b7b3135403540202e60551e741f9b6d3304371712521be939470b454ec", - "sha256:884ab9b29feaca361f7f88d811b1eea9bfca36cf3da27768d28ad45c3ee6f969", - "sha256:89b8b22a5ff72d89d48d0e62abb14340d9e99fd637d046c27b8b257a01ffbe28", - "sha256:92e821e43ad382332eade6812e298dc9701c75fe289f2a2d39c7960b43d1e92a", - "sha256:b007cbb845b28db4fb8b6a5cdcbf65bacb16a8bd328b53cbc0698688a68e1caa", - "sha256:bc4313cbeb0e7a416a488d72f9680fffffc645f8a838bd2193809881c67dd106", - "sha256:bccbfc27563652de7dc9bdc595cb25e90b59c5f8e23e806ed0fd623755b6565d", - "sha256:c4f05c5a7c49d2fb70223d0d5bcfbe474cf928310ac9fa6a7c6dddc831d0b1d4", - "sha256:ce256aaa50f6cc9a649c51be3cd4ff142d67295bfc4f490c9134d0f9f6d58ef0", - "sha256:d2e35d7bf1c1ac8c538f88d26b396e73dd81440d59c1ef8522e1ea77b345ede4", - "sha256:df7c53783a46febb0e70f6b05df2ba104610f2fb0d27023409734a3ecbb78fb2", - "sha256:efac139c3f0bf4f0939f9375af4b02c5ad83a622de52d6dfa8e438e8e01d0eb0", - "sha256:efd7a09678fd8b53117f6bae4fa3825e0a22b03ef0a932e070c0bdbb3a35e654", - "sha256:f2380a6376dfa090227b663f9678150ef27543483055cc327555fb592c5967e2", - "sha256:f8380c03e45cf09f8557bdaa41e1fa7c81f3ae22828e1db470ab2a6c96d8bc23", - "sha256:f90ba11136bfdd25cae3951af8da2e95121c9b9b93727b1b896e3fa105b2f586" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", - "version": "==4.6.3" - }, "markupsafe": { "hashes": [ "sha256:00bc623926325b26bb9605ae9eae8a215691f33cae5df11ca5424f06f2d1f473", @@ -507,14 +411,6 @@ "markers": "python_version >= '3.7'", "version": "==1.20.2" }, - "openpyxl": { - "hashes": [ - "sha256:46af4eaf201a89b610fcca177eed957635f88770a5462fb6aae4a2a52b0ff516", - "sha256:6456a3b472e1ef0facb1129f3c6ef00713cebf62e736cd7a75bcc3247432f251" - ], - "markers": "python_version >= '3.6'", - "version": "==3.0.7" - }, "packaging": { "hashes": [ "sha256:5b327ac1320dc863dca72f4514ecc086f31186744b84a230374cc1fd776feae5", @@ -559,14 +455,6 @@ "markers": "python_version >= '3.6'", "version": "==0.8.2" }, - "pg8000": { - "hashes": [ - "sha256:240a5e7c3118ea07179a02ff8daeacf93d68ab9546ea140ca9d77970c4c5fc9d", - "sha256:35baf2c8bf5445e85f516449474b547dbbd0e08c0baa3a6b20aa355a92eb72da" - ], - "markers": "python_version >= '3.6'", - "version": "==1.18.0" - }, "pickleshare": { "hashes": [ "sha256:87683d47965c1da65cdacaf31c8441d12b8044cdec9aca500cd78fc2c683afca", @@ -590,33 +478,6 @@ "markers": "python_full_version >= '3.6.1'", "version": "==3.0.18" }, - "pyarrow": { - "hashes": [ - "sha256:03e2435da817bc2b5d0fad6f2e53305eb36c24004ddfcb2b30e4217a1a80cf22", - "sha256:2be3a9eab4bfd00024dc3c83fa03de1c1d04a0f47ebaf3dc483cd100546eacbf", - "sha256:2c3353d38d137f1158595b3b18dcef711f3d8fdb57cf7ae2d861d07235064bc1", - "sha256:2d5c95eb04a3d2e786e097b53534893eade6c8b3faf10f53a06143384b4446b1", - "sha256:31e6fc0868963aba4e6b8a3e218c9a5ff347bca870d622da0b3d58269d0c5398", - "sha256:3b46487c45faaea8d1a5aa65002e2832ae2e1c9e68ecb461cda4fa59891cf490", - "sha256:3ea6574d1ae2d9bff7e6e1715f64c31bdc01b42387a5c78311a8ce9c09cfe135", - "sha256:4bf8cc43e1db1e0517466209ee8e8f459d9b5e1b4074863317f2a965cf59889e", - "sha256:5faa2dc73444bdcf042f121383965a47362be1f946303d46e8fd80f8d26cd90c", - "sha256:72206cde1857d5420601feae75f53921cffab4326b42262a858c7b8be67982b7", - "sha256:960a9b0fd599601ddac42f16d5acf049637ec08957359c6741d6eb2bf0dbae97", - "sha256:978bbe8ec9090d1133a25f00f32ed92600f9d315fbfa29a17952bee01f0d7fe5", - "sha256:a07e286e81ceb20f8f0c45f69760d2ebc434fe83794d5f9b44f89fc2dc6dc24d", - "sha256:a76031ef19d11db2fef79a97cc69997c97bea35aa07efbe042a177c7e3b1a390", - "sha256:b08c119cc2b9fcd1567797fedb245a2f4352a3084a22b7298272afe7cf7a4730", - "sha256:b1cf92df9f336f31706249e543dc0ffce3c67a78204ce540f1173c6c07dfafec", - "sha256:b7a8903f2b8a80498725ef5d4a35cd7dd5a98b74e080d42692545e61a6cbfbe4", - "sha256:bf6684fe9e38f8ddb696e38901461eab783ec1d565974ebd5862270320b3e27f", - "sha256:cfea99a01d844c3db5e25374a6cdcf3b5ba1698bfe95d41272c295a4581e884c", - "sha256:d5666a7fa2668f3ff95df028c2072d59e8b17e73d682068e8505dafa2688f3cc", - "sha256:dec007a0f7adba86bd170252140ede01646b45c3a470d5862ce00d8e40cd29bd" - ], - "markers": "python_version >= '3.6'", - "version": "==3.0.0" - }, "pycparser": { "hashes": [ "sha256:2d475327684562c3a96cc71adf7dc8c4f0565175cf86b6d7a404ff4c771f15f0", @@ -703,14 +564,6 @@ "index": "pypi", "version": "==3.11.3" }, - "pymysql": { - "hashes": [ - "sha256:41fc3a0c5013d5f039639442321185532e3e2c8924687abe6537de157d403641", - "sha256:816927a350f38d56072aeca5dfb10221fe1dc653745853d30a216637f5d7ad36" - ], - "markers": "python_version >= '3.6'", - "version": "==1.0.2" - }, "pyparsing": { "hashes": [ "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1", @@ -842,13 +695,6 @@ ], "version": "==1.9.0" }, - "redshift-connector": { - "hashes": [ - "sha256:b4e587715ae62d9bab53ea89fb3348811d2dacee4ec59a9d8a2be5b108a84542" - ], - "markers": "python_version >= '3.5'", - "version": "==2.0.877" - }, "requests": { "hashes": [ "sha256:27973dd4a904a4f13b263a19c866c13b92a39ed1c964655f025f3f8d3d75b804", @@ -857,21 +703,6 @@ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", "version": "==2.25.1" }, - "s3transfer": { - "hashes": [ - "sha256:35627b86af8ff97e7ac27975fe0a98a312814b46c6333d8a6b889627bcd80994", - "sha256:efa5bd92a897b6a8d5c1383828dca3d52d0790e0756d49740563a3fb6ed03246" - ], - "version": "==0.3.7" - }, - "scramp": { - "hashes": [ - "sha256:ac578bf7b49645ca1083117e40f4e8af2073b003750d5bf21b3285ff342a4f33", - "sha256:c1d0b8d6f890e4e72ccd9bae23e802bfb377d50c2843396e5997d262fbfe2103" - ], - "markers": "python_version >= '3.6'", - "version": "==1.2.2" - }, "send2trash": { "hashes": [ "sha256:60001cc07d707fe247c94f74ca6ac0d3255aabcb930529690897ca2a39db28b2", @@ -887,14 +718,6 @@ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.15.0" }, - "soupsieve": { - "hashes": [ - "sha256:052774848f448cf19c7e959adf5566904d525f33a3f8b6ba6f6f8f26ec7de0cc", - "sha256:c2c1c2d44f158cdbddab7824a9af8c4f83c76b1e23e049479aa432feb6c4c23b" - ], - "markers": "python_version >= '3.0'", - "version": "==2.2.1" - }, "spotipy": { "hashes": [ "sha256:8acbc18dd44e1c22b3da500ca9225c5d2f7476f2e68d5d56a317b0b8c87ec8a5", diff --git a/src/db.py b/src/db.py index 4018f03..d00daed 100644 --- a/src/db.py +++ b/src/db.py @@ -1,6 +1,24 @@ -from pymongo import MongoClient import os import json +from pymongo import MongoClient + from dotenv import load_dotenv load_dotenv() +class StormDB: + """ + Manages the Dynamodb connections, reading and writing. + """ + def __init__(self): + + # Build mongo client and db + self.mc = MongoClient(os.getenv('mongo_uri')) + self.db = self.mc[os.getenv('storm_db')] + + # initialize collections + self.artists = self.db['artists'] + self.albums = self.db['albums'] + self.storms = self.db['storm_metadata'] + self.tracks = self.db['tracks'] + + \ No newline at end of file diff --git a/src/storm_client.py b/src/storm_client.py index 8142e79..761fc2c 100644 --- a/src/storm_client.py +++ b/src/storm_client.py @@ -10,10 +10,27 @@ import json # DB -from pymongo import MongoClient +import boto3 +import awswrangler as wr from dotenv import load_dotenv load_dotenv() +class StormDB: + """ + Manages the MongoDB connections, reading and writing. + """ + def __init__(self): + + # Build mongo client and db + self.mc = MongoClient(os.getenv('mongo_uri')) + self.db = self.mc[os.getenv('storm_db')] + + # initialize collections + self.artists = self.db['artists'] + self.albums = self.db['albums'] + self.storms = self.db['storm_metadata'] + self.tracks = self.db['tracks'] + class StormClient: @@ -24,10 +41,6 @@ def __init__(self, user_id): self.client_id = os.getenv('storm_client_id') # API app id self.client_secret = os.getenv('storm_client_secret') # API app secret - # DB connection - self.mc = MongoClient(os.getenv('mongodb_uri')) - self.db = self.mc['storm'] - # Spotify API connection self.sp = None self.token_end = None @@ -58,8 +71,47 @@ def get_new_token(self): self.token_end = dt.datetime.timestamp(dt.datetime.now() + dt.timedelta(minutes=59)) json.dump({'token':self.token, 'expires':str(self.token_end)}, open('token.json', 'w')) + def + +class StormRunner: + """ + Orchestrates a storm run + """ + def __init__(self, client, db, config): + + self.sc = client + self.sdb = StormDB + self.config = config + + def Run(self): + """ + Storm Orchestration based on a configuration. + """ + + return False + +class Storm: + """ + Main callable that initiates and saves storm data + """ + def __init__(self, user_id, storm_name): + + self.sc = StormClient(user_id) + self.sdb = StormDB() + self.storm_name = storm_name + + # init + self.config = {} + + def load_configuration(self): + + if len(self.storms.find({"name":storm_name}, {})): + print("No existing storm found for that name.") + print("Please use the configuration creator or add a config.json to the config_loader directory.") + else: + storm -storm = StormClient('1241528689') +storm = Storm('1241528689') # A class to manage all of the storm functions and authentication From a191a929b1009f3df522bb5e1a6c2f4f9cac7027 Mon Sep 17 00:00:00 2001 From: ATawzer <34928044+ATawzer@users.noreply.github.com> Date: Thu, 15 Apr 2021 16:24:51 -0600 Subject: [PATCH 04/29] template_config --- config_loader/example_config.json | 10 ++++++++++ src/storm_client.py | 6 ++++-- weatherboy.py | 1 + 3 files changed, 15 insertions(+), 2 deletions(-) create mode 100644 config_loader/example_config.json diff --git a/config_loader/example_config.json b/config_loader/example_config.json new file mode 100644 index 0000000..bdf243a --- /dev/null +++ b/config_loader/example_config.json @@ -0,0 +1,10 @@ +{ + "storm_name":"something descriptive with no spaces", + "good_targets":"playlist id corresponding to the playlist with 'good' targets", + "great_targets":"playlist id corresponding to the playlist with 'great' targets", + "additional_playlists":[ + "Sample 1 where more music is stored", + "sample 2 where even more is stored"], + "archive":false, + "delivery_sample_size":50 +} \ No newline at end of file diff --git a/src/storm_client.py b/src/storm_client.py index 761fc2c..57ee2a2 100644 --- a/src/storm_client.py +++ b/src/storm_client.py @@ -71,7 +71,8 @@ def get_new_token(self): self.token_end = dt.datetime.timestamp(dt.datetime.now() + dt.timedelta(minutes=59)) json.dump({'token':self.token, 'expires':str(self.token_end)}, open('token.json', 'w')) - def + + class StormRunner: """ @@ -94,11 +95,12 @@ class Storm: """ Main callable that initiates and saves storm data """ - def __init__(self, user_id, storm_name): + def __init__(self, user_id, storm_name, start_date=None): self.sc = StormClient(user_id) self.sdb = StormDB() self.storm_name = storm_name + self.start_date = start_date # init self.config = {} diff --git a/weatherboy.py b/weatherboy.py index e69de29..9d7c7dc 100644 --- a/weatherboy.py +++ b/weatherboy.py @@ -0,0 +1 @@ +# Calling the storms!! \ No newline at end of file From a1c4eb3100483f2b7c2a4df373424776a89d9dcb Mon Sep 17 00:00:00 2001 From: ATawzer <34928044+ATawzer@users.noreply.github.com> Date: Fri, 16 Apr 2021 17:07:50 -0600 Subject: [PATCH 05/29] More prepping and changing --- .cache-1241528689 | 2 +- config_loader/example_config.json | 10 -- config_loader/film_vg_instrumental.json | 20 ++++ src/helper.py | 8 ++ src/storm_client.py | 149 ++++++++++++++++++------ 5 files changed, 143 insertions(+), 46 deletions(-) delete mode 100644 config_loader/example_config.json create mode 100644 config_loader/film_vg_instrumental.json create mode 100644 src/helper.py diff --git a/.cache-1241528689 b/.cache-1241528689 index 084392d..a8ab3d1 100644 --- a/.cache-1241528689 +++ b/.cache-1241528689 @@ -1 +1 @@ -{"access_token": "BQAY3167-1hf3r-k18-0gcDmJg3wQriQCvnOmCxeCRhS4_KjkwX4RA9fmYSAt6ghguWkAu3aKFdgqsENYsxxDgZP6U2iCSBfV6PwKk3uBiir790xKXytuy0D2U_MA1BhgqtVjNDJcVFEg94CtIxcu-Gp824w9Tz8LNBERAr89tf86knS4UKMhRTv8j3afybcRLN903HWT2wGkWno3KxRH40", "token_type": "Bearer", "expires_in": 3600, "scope": "playlist-modify-private playlist-modify-public user-follow-modify user-follow-read", "expires_at": 1618449365, "refresh_token": "AQAsxkWjXR0Iw8q65vbKmXUR0cOGEM8liRshm9vhsJbDenCcjijwBgyKF91oCqQ8NjdD8fwk3uO-NKGUVWYtWRF0E2f5ydGSyFlJRi29TR1Zyw71OKdaIs89XzUBfCOOO0M"} \ No newline at end of file +{"access_token": "BQBReHv45W35FBAfsdGR9ANKBqvM51tflI20xD-jmMj0Ii8nQOcZHPBDG7RHLHyBSxkUp_MZjUKl3u1-sLR8WKdG3UOImlC-_0WUB5sOwn7Z4beWDrZBjUb9TveHmC7ufrjwD1IGzwsGK1N0Uj4cDlNWSxxikyJSo3mNIBvyEGk8oBp-9Yp6MzrrxnmJddR1VfFeSALIDS4U5NyMSdDrOEI", "token_type": "Bearer", "expires_in": 3600, "scope": "user-follow-read playlist-modify-private playlist-modify-public user-follow-modify", "expires_at": 1618616030, "refresh_token": "AQAsxkWjXR0Iw8q65vbKmXUR0cOGEM8liRshm9vhsJbDenCcjijwBgyKF91oCqQ8NjdD8fwk3uO-NKGUVWYtWRF0E2f5ydGSyFlJRi29TR1Zyw71OKdaIs89XzUBfCOOO0M"} \ No newline at end of file diff --git a/config_loader/example_config.json b/config_loader/example_config.json deleted file mode 100644 index bdf243a..0000000 --- a/config_loader/example_config.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "storm_name":"something descriptive with no spaces", - "good_targets":"playlist id corresponding to the playlist with 'good' targets", - "great_targets":"playlist id corresponding to the playlist with 'great' targets", - "additional_playlists":[ - "Sample 1 where more music is stored", - "sample 2 where even more is stored"], - "archive":false, - "delivery_sample_size":50 -} \ No newline at end of file diff --git a/config_loader/film_vg_instrumental.json b/config_loader/film_vg_instrumental.json new file mode 100644 index 0000000..08155a9 --- /dev/null +++ b/config_loader/film_vg_instrumental.json @@ -0,0 +1,20 @@ +{ + "storm_name":"film_vg_instrumental", + "good_targets":"3K9no6AflSDYiiMzignAm7", + "great_targets":"0R1gw1JbcOFD0r8IzrbtYP", + "rolling_good":{"is_active":true, "palylist":"1SZS16UcW0XOzgh6UWXA9S"}, + "full_storm_delivery":{"is_active":true, "playlist":"7fnvajjUoWBQDo8iFNMH3s", "rank_ordered":true}, + "sample_storm_delivery":{"is_active":true, "playlist":"1Q8WS7Xj51WCHZctXGDsrp", "sample_size":50}, + "additional_input_playlists":{ + "Much Needed":"7N3pwZE1N38wcdiuLxiPvq", + "Room on the Boat":"1SZS16UcW0XOzgh6UWXA9S", + "Refuge":"3K9no6AflSDYiiMzignAm7", + "Safety":"0R1gw1JbcOFD0r8IzrbtYP", + "Shelter from the Storm":"2yueH0i9C2daBRawYIc9P8", + "Soundtracked":"37i9dQZF1DWW7gj0FcGEx6", + "Soundtrack for Study":"0hZNf3tcMT4x03FyjKYJ3M", + "Film Music - Movie Scores":"5GhatXsZVNYxrhqEAfZPLR", + "Video Game Soundtracks":"3Iwd2RiXCzmm1AMUpRAaHO", + "Video Game Music Unofficial":"3aI7ztMmDhMHhYe1KOPFLG" + } +} \ No newline at end of file diff --git a/src/helper.py b/src/helper.py new file mode 100644 index 0000000..8cfdab9 --- /dev/null +++ b/src/helper.py @@ -0,0 +1,8 @@ +import time +import sys + +def slow_print(string='', t=.0001): + for letter in string: + sys.stdout.write(letter) + time.sleep(t) + print() diff --git a/src/storm_client.py b/src/storm_client.py index 57ee2a2..06d3974 100644 --- a/src/storm_client.py +++ b/src/storm_client.py @@ -10,9 +10,12 @@ import json # DB -import boto3 -import awswrangler as wr +from pymongo import MongoClient from dotenv import load_dotenv + +# Internal +from helper import * +print = slow_print # for fun load_dotenv() class StormDB: @@ -23,7 +26,7 @@ def __init__(self): # Build mongo client and db self.mc = MongoClient(os.getenv('mongo_uri')) - self.db = self.mc[os.getenv('storm_db')] + self.db = self.mc[os.getenv('db_name')] # initialize collections self.artists = self.db['artists'] @@ -31,6 +34,28 @@ def __init__(self): self.storms = self.db['storm_metadata'] self.tracks = self.db['tracks'] + def get_config(self, storm_name): + """ + returns a storm configuration given its name, assuming it exists. + """ + q = {'name':storm_name} + cols = {'config':1} + r = list(self.storms.find(q, cols)) + + if len(r) == 0: + raise KeyError(f"{storm_name} not found, no configuration to load.") + else: + return r[0]['config'] + + def get_all_configs(self): + """ + Returns all configurations in DB. + """ + q = {} + cols = {"name":1, "_id":0} + r = list(self.storms.find(q, cols)) + + return [x['name'] for x in r] class StormClient: @@ -43,42 +68,56 @@ def __init__(self, user_id): # Spotify API connection self.sp = None - self.token_end = None - self.get_token() + self.sp_cc = oauth2.SpotifyClientCredentials(self.client_id, self.client_secret) + self.token = self.sp_cc.get_access_token() + + # Good + print("Storm Client successfully connected to Spotify.\n") + # Authentication - def get_token(self): + def refresh_token(self): - if os.path.exists('token.json'): - with json.load(open('token.json', "r")) as f: - if dt.datetime.fromtimestamp(f['expires']) < dt.datetime.now(): - self.token = f['token'] - self.token_end = f['expires'] + try: + return self.sp_cc.get_access_token() + except: + self.token = util.prompt_for_user_token(self.user_id, + scope=self.scope, + client_id=self.client_id, + client_secret=self.client_secret, + redirect_uri='http://localhost/') - else: - self.get_new_token() + def get_playlist_tracks(self, playlist_id): + """ Returns a playlists tracks """ - self.sp = spotipy.Spotify(auth=self.token) + lim = 50 + more_tracks = True + offset = 0 - def get_new_token(self): + self.refresh_token() + playlist_results = self.sp.user_playlist_tracks(self.user_id, playlist_id, limit=lim, offset=offset) + + if len(playlist_results['items']) < lim: + more_tracks = False - self.token = util.prompt_for_user_token(self.user_id, - scope=self.scope, - client_id=self.client_id, - client_secret=self.client_secret, - redirect_uri='http://localhost/') + while more_tracks: - self.token_end = dt.datetime.timestamp(dt.datetime.now() + dt.timedelta(minutes=59)) - json.dump({'token':self.token, 'expires':str(self.token_end)}, open('token.json', 'w')) + self.check_token() + offset += lim + batch = self.sp.user_playlist_tracks(self.user_id, playlist_id, limit=lim, offset=offset) + playlist_results['items'].extend(batch['items']) - + if len(batch['items']) < lim: + more_tracks = False + response_df = pd.DataFrame(playlist_results['items']) + return response_df class StormRunner: """ Orchestrates a storm run """ - def __init__(self, client, db, config): + def __init__(self, client, db, config, start_date=None): self.sc = client self.sdb = StormDB @@ -89,31 +128,71 @@ def Run(self): Storm Orchestration based on a configuration. """ - return False + print(f"Runner {self.config['storm_name']} Started Successfully!\n") + + print("Initializing Playlists. . .") + self.load_playlists() + self.clean_playlists() + self.save_playlists() + + + def load_playlists(self): + """ + Pulls down playlist info and writes it back to db + """ + + print("Loading Great Targets . . .") + great_targets = self.sc.get_playlist_tracks(self.config['great_targets']) + + print("Loading Good Targets . . . ") + good_targets = self.sc.get_playlist_tracks(self.config['good_targets']) + + print("Loading Additional Playlists . . .") + aps = {} + for ap in self.config['additional_playlists']: + aps[ap] = self.sc.get_playlist_tracks(self.config['additional_playlists'][ap]) + class Storm: """ Main callable that initiates and saves storm data """ - def __init__(self, user_id, storm_name, start_date=None): + def __init__(self, user_id, storm_names, start_date=None): + self.print_initial_screen() self.sc = StormClient(user_id) self.sdb = StormDB() - self.storm_name = storm_name + self.storm_names = storm_names self.start_date = start_date + self.runners = {} # init - self.config = {} + self.load_configurations() + self.Run() - def load_configuration(self): + def print_initial_screen(self): - if len(self.storms.find({"name":storm_name}, {})): - print("No existing storm found for that name.") - print("Please use the configuration creator or add a config.json to the config_loader directory.") - else: - storm + print("A Storm is Brewing. . .\n") + time.sleep(.5) + + def load_configurations(self): + """ + Load in all of the configurations metadata. + """ + print("Loading Configurations . . .") + for storm_name in self.storm_names: + print(f"Loading: {storm_name}") + self.runners[storm_name] = StormRunner(self.sc, self.sdb, self.sdb.get_config(storm_name)) + print("Success!") + print() + + def Run(self): + + print("Sending off Storm Runners. . . ") + for storm_name in self.runners: + self.runners[storm_name].Run() -storm = Storm('1241528689') +storm = Storm('1241528689', ['film_vg_instrumental']) # A class to manage all of the storm functions and authentication From ae2410537c279c9f569046d153bf11eb6a7543dd Mon Sep 17 00:00:00 2001 From: ATawzer <34928044+ATawzer@users.noreply.github.com> Date: Sat, 17 Apr 2021 15:29:52 -0600 Subject: [PATCH 06/29] updating repo 4/17 --- config_loader/film_vg_instrumental.json | 23 ++- scratch.py | 8 + src/storm_client.py | 251 +++++++++++++++++------- src/weatherboy.py | 12 ++ weatherboy.py | 1 - 5 files changed, 217 insertions(+), 78 deletions(-) create mode 100644 scratch.py create mode 100644 src/weatherboy.py delete mode 100644 weatherboy.py diff --git a/config_loader/film_vg_instrumental.json b/config_loader/film_vg_instrumental.json index 08155a9..2d5af98 100644 --- a/config_loader/film_vg_instrumental.json +++ b/config_loader/film_vg_instrumental.json @@ -6,15 +6,18 @@ "full_storm_delivery":{"is_active":true, "playlist":"7fnvajjUoWBQDo8iFNMH3s", "rank_ordered":true}, "sample_storm_delivery":{"is_active":true, "playlist":"1Q8WS7Xj51WCHZctXGDsrp", "sample_size":50}, "additional_input_playlists":{ - "Much Needed":"7N3pwZE1N38wcdiuLxiPvq", - "Room on the Boat":"1SZS16UcW0XOzgh6UWXA9S", - "Refuge":"3K9no6AflSDYiiMzignAm7", - "Safety":"0R1gw1JbcOFD0r8IzrbtYP", - "Shelter from the Storm":"2yueH0i9C2daBRawYIc9P8", - "Soundtracked":"37i9dQZF1DWW7gj0FcGEx6", - "Soundtrack for Study":"0hZNf3tcMT4x03FyjKYJ3M", - "Film Music - Movie Scores":"5GhatXsZVNYxrhqEAfZPLR", - "Video Game Soundtracks":"3Iwd2RiXCzmm1AMUpRAaHO", - "Video Game Music Unofficial":"3aI7ztMmDhMHhYe1KOPFLG" + "is_active":true, + "playlists":{ + "Much Needed":"7N3pwZE1N38wcdiuLxiPvq", + "Room on the Boat":"1SZS16UcW0XOzgh6UWXA9S", + "Refuge":"3K9no6AflSDYiiMzignAm7", + "Safety":"0R1gw1JbcOFD0r8IzrbtYP", + "Shelter from the Storm":"2yueH0i9C2daBRawYIc9P8", + "Soundtracked":"37i9dQZF1DWW7gj0FcGEx6", + "Soundtrack for Study":"0hZNf3tcMT4x03FyjKYJ3M", + "Film Music - Movie Scores":"5GhatXsZVNYxrhqEAfZPLR", + "Video Game Soundtracks":"3Iwd2RiXCzmm1AMUpRAaHO", + "Video Game Music Unofficial":"3aI7ztMmDhMHhYe1KOPFLG" + } } } \ No newline at end of file diff --git a/scratch.py b/scratch.py new file mode 100644 index 0000000..f481aa1 --- /dev/null +++ b/scratch.py @@ -0,0 +1,8 @@ +storm = Storm(['film_vg_instrumental']) + +sr = StormRunner('film_vg_instrumental') +sr.prepare_playlists() + + +sc = StormClient('1241528689') +test = sc.get_artists_from_tracks([]) diff --git a/src/storm_client.py b/src/storm_client.py index 06d3974..10238b2 100644 --- a/src/storm_client.py +++ b/src/storm_client.py @@ -15,7 +15,7 @@ # Internal from helper import * -print = slow_print # for fun +#print = slow_print # for fun load_dotenv() class StormDB: @@ -33,6 +33,7 @@ def __init__(self): self.albums = self.db['albums'] self.storms = self.db['storm_metadata'] self.tracks = self.db['tracks'] + self.playlists = self.db['playlists'] def get_config(self, storm_name): """ @@ -57,6 +58,44 @@ def get_all_configs(self): return [x['name'] for x in r] + # Playlist + def get_playlist_collection_date(self, playlist_id): + """ + Gets a playlists last collection date. + """ + q = {"_id":playlist_id} + cols = {"last_collected_date":1} + r = list(self.playlists.find(q, cols)) + + # If not found print old date + if len(r) == 0: + return '2000-01-01' # Long ago + elif len(r) == 1: + return r[0]['last_collected_date'] + else: + raise Exception("Playlist Ambiguous, should be unique to table.") + + + def update_playlist(self, pr): + + q = {'_id':pr['_id']} + + # Add new entry or update existing one + record = pr + changelog_update = { + 'snapshot':pr['info']['snapshot_id'], + 'tracks':pr['tracks'] + } + + # Update static fields + exclude_keys = ['changelog'] + update_dict = {k: pr[k] for k in set(list(pr.keys())) - set(exclude_keys)} + self.playlists.update_one(q, {"$set":record}, upsert=True) + + # Push to append fields (date as new key) + for key in exclude_keys: + self.playlists.update_one(q, {"$set":{f"{key}.{pr['last_collected']}":changelog_update}}, upsert=True) + class StormClient: def __init__(self, user_id): @@ -67,136 +106,214 @@ def __init__(self, user_id): self.client_secret = os.getenv('storm_client_secret') # API app secret # Spotify API connection - self.sp = None self.sp_cc = oauth2.SpotifyClientCredentials(self.client_id, self.client_secret) - self.token = self.sp_cc.get_access_token() + self.token = None + + # Authenticate + self.refresh_connection() # Good print("Storm Client successfully connected to Spotify.\n") # Authentication - def refresh_token(self): - + def refresh_connection(self): + """ + Get a cached token (again) or try to get a new one. + Call this before any api call to make sure it won't get credential error. + """ try: - return self.sp_cc.get_access_token() + self.token = self.sp_cc.get_access_token(as_dict=False) + self.sp = spotipy.Spotify(auth=self.token) except: + print("Looks like a new User, couldn't get access token. Trying authenticating.") self.token = util.prompt_for_user_token(self.user_id, scope=self.scope, client_id=self.client_id, client_secret=self.client_secret, redirect_uri='http://localhost/') + self.sp = spotipy.Spotify(auth=self.token) + + def get_playlist_info(self, playlist_id): + """ Returns subset of playlist metadata """ + + # params + fields = 'description,id,name,owner,snapshot_id' + + # Get the info + self.refresh_connection() + return self.sp.playlist(playlist_id, fields=fields) def get_playlist_tracks(self, playlist_id): - """ Returns a playlists tracks """ + """ + Return subset of information about a playlists tracks + """ - lim = 50 - more_tracks = True + # Call info + lim = 100 offset = 0 + fields = 'items(track(id))' # only getting the ids, get info about them later + + # Get number of tracks trying to get (faster to know then go in blind) + self.refresh_connection() + total = int(self.sp.user_playlist_tracks(self.user_id, playlist_id, fields='total')['total']) + print(f"Total Tracks: {total}") + + # loop through and append track ids + result = ['' for x in range(total)] # List of track ids pre-initialized + for i in tqdm(range(int(np.ceil(total/lim)))): + self.refresh_connection() + response = self.sp.user_playlist_tracks(self.user_id, playlist_id, fields=fields, limit=lim, offset=(i*lim)) + + result[i*lim:(i*lim)+len(response['items'])] = [x['track']['id'] for x in response['items']] + + return result + + def get_artists_from_tracks(self, tracks): + """ + Returns list of artist_ids given track_ids + """ + + # Call Info + lim = 100 + offset = 0 + fields = 'artists(id)' + self.refresh_connection() + + + return self.sp.tracks(tracks[:50]) - self.refresh_token() - playlist_results = self.sp.user_playlist_tracks(self.user_id, playlist_id, limit=lim, offset=offset) - - if len(playlist_results['items']) < lim: - more_tracks = False - while more_tracks: - self.check_token() - offset += lim - batch = self.sp.user_playlist_tracks(self.user_id, playlist_id, limit=lim, offset=offset) - playlist_results['items'].extend(batch['items']) - if len(batch['items']) < lim: - more_tracks = False - response_df = pd.DataFrame(playlist_results['items']) - return response_df class StormRunner: """ Orchestrates a storm run """ - def __init__(self, client, db, config, start_date=None): + def __init__(self, storm_name, start_date=None): + + print(f"Initializing Runner for {storm_name}") + self.sdb = StormDB() + self.config = self.sdb.get_config(storm_name) + self.sc = StormClient(self.config['user_id']) + self.name = storm_name - self.sc = client - self.sdb = StormDB - self.config = config + # metadata + self.run_date = dt.datetime.now().strftime('%Y-%m-%d') + self.run_record = {'config':self.config, + 'run_date':self.run_date, + 'playlists':[], + 'input_tracks':[], + 'artists':[]} + + print(f"Runner {storm_name} Started Successfully!\n") + #self.Run() def Run(self): """ Storm Orchestration based on a configuration. """ - print(f"Runner {self.config['storm_name']} Started Successfully!\n") - print("Initializing Playlists. . .") - self.load_playlists() - self.clean_playlists() - self.save_playlists() + self.prepare_playlists() - - def load_playlists(self): + print("Collecting track info.") + self.prepare_input_track_list() + + # Object Based orchestration + def prepare_playlists(self): """ - Pulls down playlist info and writes it back to db + Initial Playlist setup orchestration """ - + print("Loading Great Targets . . .") - great_targets = self.sc.get_playlist_tracks(self.config['great_targets']) + self.load_playlist(self.config['great_targets']) - print("Loading Good Targets . . . ") - good_targets = self.sc.get_playlist_tracks(self.config['good_targets']) + print("Loading Good Targets . . .") + self.load_playlist(self.config['great_targets']) - print("Loading Additional Playlists . . .") - aps = {} - for ap in self.config['additional_playlists']: - aps[ap] = self.sc.get_playlist_tracks(self.config['additional_playlists'][ap]) + # Check for additional playlists + if 'additional_input_playlists' in self.config.keys(): + if self.config['additional_input_playlists']['is_active']: + for ap, ap_id in self.config['additional_input_playlists']['playlists'].items(): + print(f"Loading Additional Playlist: {ap}") + self.load_playlist(ap_id) + + ## ---- Future Version ---- + # Check if we need to move rolling + # Check what songs remain in sample and full delivery + + print("Playlists Prepared. \n") + + def prepare_input_track_list(self): + """ + Collects artists from track list + """ + + # First check in the db for track info + tracks_collected = [] + artists = self.sc.get_artists_from_tracks(self.run_record['input_tracks']) + + # Low Level orchestration + def load_playlist(self, playlist_id): + """ + Pulls down playlist info and writes it back to db + """ + + # Determine if playlists need examining + if self.run_date != self.sdb.get_playlist_collection_date(playlist_id): + + # Acquire data + playlist_record = {'_id':playlist_id, + 'last_collected':self.run_date} + + playlist_record['info'] = self.sc.get_playlist_info(playlist_id) + playlist_record['tracks'] = np.unique(self.sc.get_playlist_tracks(playlist_id)) + playlist_record['artists'] = self.sc.get_playlist_artists(playlist_record['tracks']) + + # Update run record + self.run_record['playlists'].append(playlist_id) + self.run_record['input_tracks'].extend([x for x in playlist_record['tracks'] if x not in run_record['input_tracks']) + self.run_record['input_artists'].extend([x for x in playlist_record['artists'] if x not in run_record['input_artists']]) + + print("Writing changes to DB") + self.sdb.update_playlist(playlist_record) + + else: + print("Skipping Load, already collected today.") + + + + class Storm: """ Main callable that initiates and saves storm data """ - def __init__(self, user_id, storm_names, start_date=None): + def __init__(self, storm_names, start_date=None): self.print_initial_screen() - self.sc = StormClient(user_id) self.sdb = StormDB() self.storm_names = storm_names self.start_date = start_date self.runners = {} - # init - self.load_configurations() - self.Run() - def print_initial_screen(self): print("A Storm is Brewing. . .\n") time.sleep(.5) - - def load_configurations(self): - """ - Load in all of the configurations metadata. - """ - print("Loading Configurations . . .") - for storm_name in self.storm_names: - print(f"Loading: {storm_name}") - self.runners[storm_name] = StormRunner(self.sc, self.sdb, self.sdb.get_config(storm_name)) - print("Success!") - print() def Run(self): - print("Sending off Storm Runners. . . ") - for storm_name in self.runners: - self.runners[storm_name].Run() - -storm = Storm('1241528689', ['film_vg_instrumental']) - + print("Spinning up Storm Runners. . . ") + for storm_name in self.storm_names: + self.runners[storm_name] = StormRunner(storm_name) # A class to manage all of the storm functions and authentication -class Storm: +class StormOld: """ Single object for running and saving data frm the storm run. Call Storm.Run() to generate a playlist from saved artists. diff --git a/src/weatherboy.py b/src/weatherboy.py new file mode 100644 index 0000000..6507f38 --- /dev/null +++ b/src/weatherboy.py @@ -0,0 +1,12 @@ +# Modeling + +class WeatherBoy: + + def __init__(self, tracks): + + self.tracks = tracks + + def rank_order(): + + return False + diff --git a/weatherboy.py b/weatherboy.py deleted file mode 100644 index 9d7c7dc..0000000 --- a/weatherboy.py +++ /dev/null @@ -1 +0,0 @@ -# Calling the storms!! \ No newline at end of file From 48101ceaa87a741e2647656b1d875f6924c99df0 Mon Sep 17 00:00:00 2001 From: ATawzer <34928044+ATawzer@users.noreply.github.com> Date: Mon, 19 Apr 2021 16:17:21 -0600 Subject: [PATCH 07/29] skeleton setup for runner, playlist and tracks don --- scratch.py | 15 ++++++- src/storm_client.py | 107 +++++++++++++++++++++++++++++++++----------- 2 files changed, 95 insertions(+), 27 deletions(-) diff --git a/scratch.py b/scratch.py index f481aa1..139d35b 100644 --- a/scratch.py +++ b/scratch.py @@ -1,8 +1,19 @@ storm = Storm(['film_vg_instrumental']) sr = StormRunner('film_vg_instrumental') -sr.prepare_playlists() +sr.load_playlists() + + sc = StormClient('1241528689') -test = sc.get_artists_from_tracks([]) +test = sc.get_artist_info(["0360rTDeUjEyBXaz2Ki00a", +"07vycW8ICLf5hKb22PFWXw", +"0HDxlFsXwyrpufs4YgTNMm", +"0InzETPzx4u2fVgldqQOcd", +"0QxmfaZ2M3gLqL3f7Tap8r", +"0UM4gJJKawZSZuJxYcIwJS", +"0UncJfL7Vqvm9WFuWQSVBC", +"0YC192cP3KPCRWx8zr8MfZ", +"0Z6bE6kOVhh2DHZPMUz2Sr", +"0bdJp8l3a1uJRKe2YaAcE9"]) diff --git a/src/storm_client.py b/src/storm_client.py index 10238b2..8914cab 100644 --- a/src/storm_client.py +++ b/src/storm_client.py @@ -64,14 +64,14 @@ def get_playlist_collection_date(self, playlist_id): Gets a playlists last collection date. """ q = {"_id":playlist_id} - cols = {"last_collected_date":1} + cols = {"last_collected":1} r = list(self.playlists.find(q, cols)) # If not found print old date if len(r) == 0: return '2000-01-01' # Long ago elif len(r) == 1: - return r[0]['last_collected_date'] + return r[0]['last_collected'] else: raise Exception("Playlist Ambiguous, should be unique to table.") @@ -146,7 +146,7 @@ def get_playlist_info(self, playlist_id): def get_playlist_tracks(self, playlist_id): """ - Return subset of information about a playlists tracks + Return subset of information about a playlists tracks (unique) """ # Call info @@ -167,7 +167,7 @@ def get_playlist_tracks(self, playlist_id): result[i*lim:(i*lim)+len(response['items'])] = [x['track']['id'] for x in response['items']] - return result + return np.unique(result).tolist() def get_artists_from_tracks(self, tracks): """ @@ -175,16 +175,41 @@ def get_artists_from_tracks(self, tracks): """ # Call Info - lim = 100 - offset = 0 - fields = 'artists(id)' - self.refresh_connection() + id_lim = 50 + batches = np.array_split(tracks, int(np.ceil(len(tracks)/id_lim))) + # Get Artists + artists = [] + for batch in tqdm(batches): + self.refresh_connection() + response = self.sp.tracks(batch, market='US')['tracks'] + [artists.extend(x['artists']) for x in response] - return self.sp.tracks(tracks[:50]) + # Filter to just ids + return np.unique([x['id'] for x in artists]).tolist() + def get_artist_info(self, artists): + """ + Gets a subset of artist info from a list of ids + """ + # Call info + id_lim = 50 + keys = ['followers', 'genres', 'id', 'name', 'popularity'] + batches = np.array_split(artists, int(np.ceil(len(artists)/id_lim))) + + # Get All artist info + result = [] + for batch in tqdm(batches): + self.refresh_connection() + response = self.sp.artists(batch)['artists'] + result.extend(response) + # Filter to just relevant fields + for i in range(len(result)): + result[i] = {k: result[i][k] for k in keys} + + return result @@ -206,9 +231,9 @@ def __init__(self, storm_name, start_date=None): 'run_date':self.run_date, 'playlists':[], 'input_tracks':[], - 'artists':[]} + 'input_artists':[]} - print(f"Runner {storm_name} Started Successfully!\n") + print(f"{self.name} Started Successfully!\n") #self.Run() def Run(self): @@ -216,14 +241,34 @@ def Run(self): Storm Orchestration based on a configuration. """ - print("Initializing Playlists. . .") - self.prepare_playlists() + print(f"{self.name} - Step 1 / 8 - Collecting Playlist Tracks and Artists. . .") + self.collect_playlist_info() - print("Collecting track info.") - self.prepare_input_track_list() + print(f"{self.name} - Step 2 / 8 - Collecting Artist info. . .") + self.collect_artist_info() + + print(f"{self.name} - Step 3 / 8 - Collecting Albums . . .") + self.collect_album_info() + + print(f"{self.name} - Step 4 / 8 - Collecting Eligible Tracks . . .") + self.collect_storm_tracks() + + print(f"{self.name} - Step 5 / 8 - Filtering Track List . . .") + self.filter_storm_tracks() + + print(f"{self.name} - Step 6 / 8 - Handing off to Weatherboy . . . ") + self.call_weatherboy() + + print(f"{self.name} - Step 7 / 8 - Writing to Spotify . . .") + self.write_storm_tracks() + + print(f"{self.name} - Step 8 / 8 - Saving Storm Run . . .") + self.save_run_record() + + print(f"{self.name} - Complete!\n") # Object Based orchestration - def prepare_playlists(self): + def collect_playlist_info(self): """ Initial Playlist setup orchestration """ @@ -249,14 +294,26 @@ def prepare_playlists(self): print("Playlists Prepared. \n") - def prepare_input_track_list(self): + def collect_artist_info(self): """ - Collects artists from track list + Loads in the data from the run_records artists """ - # First check in the db for track info - tracks_collected = [] - artists = self.sc.get_artists_from_tracks(self.run_record['input_tracks']) + # get data for artists we don't know + known_artists = self.sdb.get_known_artist_ids() + new_artists = [x for x in self.run_record['input_artists'] if x not in known_artists] + + if len(new_artists) > 0: + print(f"{len(new_artists)} New Artists Found! Getting their info now.") + new_artist_info = self.sc.get_artists_info(new_artists) + self.sdb.update_artists(new_artist_info) + + else: + print("No new Artists found.") + + print("Artist Info Collection Done.\n") + + # Low Level orchestration def load_playlist(self, playlist_id): @@ -272,13 +329,13 @@ def load_playlist(self, playlist_id): 'last_collected':self.run_date} playlist_record['info'] = self.sc.get_playlist_info(playlist_id) - playlist_record['tracks'] = np.unique(self.sc.get_playlist_tracks(playlist_id)) - playlist_record['artists'] = self.sc.get_playlist_artists(playlist_record['tracks']) + playlist_record['tracks'] = self.sc.get_playlist_tracks(playlist_id) + playlist_record['artists'] = self.sc.get_artists_from_tracks(playlist_record['tracks']) # Update run record self.run_record['playlists'].append(playlist_id) - self.run_record['input_tracks'].extend([x for x in playlist_record['tracks'] if x not in run_record['input_tracks']) - self.run_record['input_artists'].extend([x for x in playlist_record['artists'] if x not in run_record['input_artists']]) + self.run_record['input_tracks'].extend([x for x in playlist_record['tracks'] if x not in self.run_record['input_tracks']]) + self.run_record['input_artists'].extend([x for x in playlist_record['artists'] if x not in self.run_record['input_artists']]) print("Writing changes to DB") self.sdb.update_playlist(playlist_record) From a35267ba054a05aef9c5646ea7d0a350208dcabe Mon Sep 17 00:00:00 2001 From: ATawzer <34928044+ATawzer@users.noreply.github.com> Date: Mon, 19 Apr 2021 17:57:39 -0600 Subject: [PATCH 08/29] added but did not test artist writing --- scratch.py | 5 +++- src/storm_client.py | 69 ++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 66 insertions(+), 8 deletions(-) diff --git a/scratch.py b/scratch.py index 139d35b..f046702 100644 --- a/scratch.py +++ b/scratch.py @@ -1,8 +1,11 @@ storm = Storm(['film_vg_instrumental']) sr = StormRunner('film_vg_instrumental') -sr.load_playlists() +sr.collect_playlist_info() +sr.collect_artist_info() +sdb = StormDB() +sdb.get_loaded_playlist_tracks('0R1gw1JbcOFD0r8IzrbtYP') diff --git a/src/storm_client.py b/src/storm_client.py index 8914cab..80f4485 100644 --- a/src/storm_client.py +++ b/src/storm_client.py @@ -75,7 +75,6 @@ def get_playlist_collection_date(self, playlist_id): else: raise Exception("Playlist Ambiguous, should be unique to table.") - def update_playlist(self, pr): q = {'_id':pr['_id']} @@ -96,6 +95,55 @@ def update_playlist(self, pr): for key in exclude_keys: self.playlists.update_one(q, {"$set":{f"{key}.{pr['last_collected']}":changelog_update}}, upsert=True) + def get_loaded_playlist_tracks(self, playlist_id): + """ + Returns a playlists most recently collected tracks + """ + q = {"_id":playlist_id} + cols = {'tracks':1, "_id":0} + r = list(self.playlists.find(q, cols)) + + if len(r) == 0: + raise ValueError(f"Playlist {playlist_id} not found.") + else: + return r[0]['tracks'] + + def get_loaded_playlist_artists(self, playlist_id): + """ + Returns a playlists most recently collected artists + """ + q = {"_id":playlist_id} + cols = {'artists':1, "_id":0} + r = list(self.playlists.find(q, cols)) + + if len(r) == 0: + raise ValueError(f"Playlist {playlist_id} not found.") + else: + return r[0]['artists'] + + # Artists + def get_known_artist_ids(self): + """ + Returns all ids from the artists db. + """ + + q = {} + cols = {"_id":1} + r = list(self.artists.find(q, cols)) + + return r + + def update_artists(self, artist_info): + """ + Updates the artist db with new info + """ + + for artist in tqdm(artist_info): + q = {"_id":artist['id']} + self.artists.update(q, {"$set":artist_info}, upsert=True) + + + class StormClient: def __init__(self, user_id): @@ -232,6 +280,7 @@ def __init__(self, storm_name, start_date=None): 'playlists':[], 'input_tracks':[], 'input_artists':[]} + #!!!!!!!! self.last_run = self.sdb.get_last_run(storm_name) print(f"{self.name} Started Successfully!\n") #self.Run() @@ -306,6 +355,8 @@ def collect_artist_info(self): if len(new_artists) > 0: print(f"{len(new_artists)} New Artists Found! Getting their info now.") new_artist_info = self.sc.get_artists_info(new_artists) + + print("Writing their info to DB . . .") self.sdb.update_artists(new_artist_info) else: @@ -332,16 +383,20 @@ def load_playlist(self, playlist_id): playlist_record['tracks'] = self.sc.get_playlist_tracks(playlist_id) playlist_record['artists'] = self.sc.get_artists_from_tracks(playlist_record['tracks']) - # Update run record - self.run_record['playlists'].append(playlist_id) - self.run_record['input_tracks'].extend([x for x in playlist_record['tracks'] if x not in self.run_record['input_tracks']]) - self.run_record['input_artists'].extend([x for x in playlist_record['artists'] if x not in self.run_record['input_artists']]) - print("Writing changes to DB") self.sdb.update_playlist(playlist_record) else: - print("Skipping Load, already collected today.") + print("Skipping API Load, already collected today.") + + # Get the playlists tracks from DB + input_tracks = self.sdb.get_loaded_playlist_tracks(playlist_id) + input_artists = self.sdb.get_loaded_playlist_artists(playlist_id) + + # Update run record + self.run_record['playlists'].append(playlist_id) + self.run_record['input_tracks'].extend([x for x in input_tracks if x not in self.run_record['input_tracks']]) + self.run_record['input_artists'].extend([x for x in input_artists if x not in self.run_record['input_artists']]) From 4e54ff8149ba4bfe90077ffc61b71c3a9b0a74ac Mon Sep 17 00:00:00 2001 From: ATawzer <34928044+ATawzer@users.noreply.github.com> Date: Tue, 20 Apr 2021 10:15:16 -0600 Subject: [PATCH 09/29] artist updating and acquisition done and tested --- src/storm_client.py | 50 ++++++++++++++++++++++++++++++++++++++------- 1 file changed, 43 insertions(+), 7 deletions(-) diff --git a/src/storm_client.py b/src/storm_client.py index 80f4485..93d13be 100644 --- a/src/storm_client.py +++ b/src/storm_client.py @@ -34,6 +34,7 @@ def __init__(self): self.storms = self.db['storm_metadata'] self.tracks = self.db['tracks'] self.playlists = self.db['playlists'] + self.runs = self.db['runs'] def get_config(self, storm_name): """ @@ -58,6 +59,21 @@ def get_all_configs(self): return [x['name'] for x in r] + def get_last_run(self, storm_name): + """ + returns the run_record from last storm run under a given name + """ + q = {"name":storm_name} + cols = {} + r = list(self.runs.find(q, cols)) + + if len(r) == 0: + return None + elif len(r) > 0: + max_run_idx = np.argmax(np.array([dt.datetime(x['run_date']) for x in r])) + return r[max_run_idx] + + # Playlist def get_playlist_collection_date(self, playlist_id): """ @@ -131,7 +147,7 @@ def get_known_artist_ids(self): cols = {"_id":1} r = list(self.artists.find(q, cols)) - return r + return [x['_id'] for x in r] def update_artists(self, artist_info): """ @@ -140,8 +156,14 @@ def update_artists(self, artist_info): for artist in tqdm(artist_info): q = {"_id":artist['id']} - self.artists.update(q, {"$set":artist_info}, upsert=True) + # Writing updates (formatting changes) + artist['last_updated'] = dt.datetime.now().strftime('%Y-%m-%d') + artist['total_followers'] = artist['followers']['total'] + del artist['followers'] + del artist['id'] + + self.artists.update_one(q, {"$set":artist}, upsert=True) class StormClient: @@ -276,11 +298,12 @@ def __init__(self, storm_name, start_date=None): # metadata self.run_date = dt.datetime.now().strftime('%Y-%m-%d') self.run_record = {'config':self.config, + 'storm_name':self.name, 'run_date':self.run_date, 'playlists':[], 'input_tracks':[], 'input_artists':[]} - #!!!!!!!! self.last_run = self.sdb.get_last_run(storm_name) + self.last_run = self.sdb.get_last_run(self.name) print(f"{self.name} Started Successfully!\n") #self.Run() @@ -290,6 +313,9 @@ def Run(self): Storm Orchestration based on a configuration. """ + print(f"{self.name} - Step 0 / 8 - Initializing using last run.") + self.load_last_run() + print(f"{self.name} - Step 1 / 8 - Collecting Playlist Tracks and Artists. . .") self.collect_playlist_info() @@ -317,6 +343,19 @@ def Run(self): print(f"{self.name} - Complete!\n") # Object Based orchestration + def load_last_run(self): + """ + Loads in relevant information from last run. + """ + + if self.last_run is None: + print("Storm is new, nothing to load") + + else: + print("Appending last runs tracks and artists.") + self.run_record['input_tracks'].extend(self.last_run['input_tracks']) + self.run_record['input_artists'].extend(self.last_run['input_artists']) + def collect_playlist_info(self): """ Initial Playlist setup orchestration @@ -340,7 +379,6 @@ def collect_playlist_info(self): # Check what songs remain in sample and full delivery - print("Playlists Prepared. \n") def collect_artist_info(self): @@ -354,7 +392,7 @@ def collect_artist_info(self): if len(new_artists) > 0: print(f"{len(new_artists)} New Artists Found! Getting their info now.") - new_artist_info = self.sc.get_artists_info(new_artists) + new_artist_info = self.sc.get_artist_info(new_artists) print("Writing their info to DB . . .") self.sdb.update_artists(new_artist_info) @@ -364,8 +402,6 @@ def collect_artist_info(self): print("Artist Info Collection Done.\n") - - # Low Level orchestration def load_playlist(self, playlist_id): """ From 1e3329f659e96348f10a2757f47a7aff0ef915a9 Mon Sep 17 00:00:00 2001 From: ATawzer <34928044+ATawzer@users.noreply.github.com> Date: Tue, 20 Apr 2021 10:25:31 -0600 Subject: [PATCH 10/29] album collection skeleton added --- src/storm_client.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/storm_client.py b/src/storm_client.py index 93d13be..2aeb4f0 100644 --- a/src/storm_client.py +++ b/src/storm_client.py @@ -165,6 +165,9 @@ def update_artists(self, artist_info): self.artists.update_one(q, {"$set":artist}, upsert=True) + def update_albums(self, albums): + + class StormClient: @@ -402,6 +405,26 @@ def collect_artist_info(self): print("Artist Info Collection Done.\n") + def collect_album_info(self): + """ + Get and update all albums associated with the artists + """ + + # Get a list of all artists in need of album collection + collected = self.sdb.get_artists_for_album_collection(max_date) + to_collect = [x for x in self.run_record['input_artists'] if x not in collected] + + # Get their albums + if len(to_collect) == 0: + print("Artist Albums already acquired today.") + else: + print(f"New albums to collect for {len(to_collect)} artists.") + new_albums = self.sc.get_artist_albums(to_collect) + self.sdb.update_artist_album_collected_date(run_record['input_artists']) + + # Update them in DB + self.sdb.update_albums(new_albums) + # Low Level orchestration def load_playlist(self, playlist_id): """ From 92fdcf977d5d8b60fddeabac2538c07334bd3315 Mon Sep 17 00:00:00 2001 From: ATawzer <34928044+ATawzer@users.noreply.github.com> Date: Tue, 20 Apr 2021 16:22:24 -0600 Subject: [PATCH 11/29] Albums fully working --- scratch.py | 25 +++++++++- src/storm_client.py | 118 ++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 131 insertions(+), 12 deletions(-) diff --git a/scratch.py b/scratch.py index f046702..a95d20c 100644 --- a/scratch.py +++ b/scratch.py @@ -1,16 +1,18 @@ storm = Storm(['film_vg_instrumental']) sr = StormRunner('film_vg_instrumental') +sr.load_last_run() sr.collect_playlist_info() sr.collect_artist_info() +sr.collect_album_info() sdb = StormDB() -sdb.get_loaded_playlist_tracks('0R1gw1JbcOFD0r8IzrbtYP') +sdb.get_albums_by_release_date('2021-04-01', '2021-04-05') sc = StormClient('1241528689') -test = sc.get_artist_info(["0360rTDeUjEyBXaz2Ki00a", +test = sc.get_artist_albums(["0360rTDeUjEyBXaz2Ki00a", "07vycW8ICLf5hKb22PFWXw", "0HDxlFsXwyrpufs4YgTNMm", "0InzETPzx4u2fVgldqQOcd", @@ -20,3 +22,22 @@ "0YC192cP3KPCRWx8zr8MfZ", "0Z6bE6kOVhh2DHZPMUz2Sr", "0bdJp8l3a1uJRKe2YaAcE9"]) + +sdb = StormDB() +sdb.update_artist_album_collected_date(["0360rTDeUjEyBXaz2Ki00a", +"07vycW8ICLf5hKb22PFWXw", +"0HDxlFsXwyrpufs4YgTNMm", +"0InzETPzx4u2fVgldqQOcd", +"0QxmfaZ2M3gLqL3f7Tap8r", +"0UM4gJJKawZSZuJxYcIwJS", +"0UncJfL7Vqvm9WFuWQSVBC", +"0YC192cP3KPCRWx8zr8MfZ", +"0Z6bE6kOVhh2DHZPMUz2Sr", +"0bdJp8l3a1uJRKe2YaAcE9"]) + +sdb.update_albums(test) + +from_date = dt.datetime.strptime('2021-04-01', '%Y-%m-%d') +to_date = dt.datetime.strptime('2021-04-05', '%Y-%m-%d') + +list(sdb.albums.find({"release_date": {"$gte": '2021-04-01', "$lt": '2021-04-05'}})) \ No newline at end of file diff --git a/src/storm_client.py b/src/storm_client.py index 2aeb4f0..e9a1017 100644 --- a/src/storm_client.py +++ b/src/storm_client.py @@ -165,7 +165,60 @@ def update_artists(self, artist_info): self.artists.update_one(q, {"$set":artist}, upsert=True) - def update_albums(self, albums): + def get_artists_for_album_collection(self, max_date): + """ + returns all artists with album collection dates before max_date. + """ + q = {} + cols = {"_id":1, "albums_last_collected":1} + r = list(self.artists.find(q, cols)) + + # Only append artists who need collection in result + result = [] + for artist in r: + if 'albums_last_collected' in artist.keys(): + if artist['albums_last_collected'] < max_date: + result.append(artist['_id']) + else: + result.append(artist['_id']) + return result + + def update_artist_album_collected_date(self, artist_ids): + """ + Updates a list of artists album_collected date to today. + """ + date = dt.datetime.now().strftime('%Y-%m-%d') + + for artist_id in tqdm(artist_ids): + q = {"_id":artist_id} + self.artists.update_one(q, {"$set":{"album_last_collected":date}}, upsert=True) + + # Albums + def update_albums(self, album_info): + """ + update album info if needed. + """ + + for album in tqdm(album_info): + q = {"_id":album['id']} + + # Writing updates (formatting changes) + album['last_updated'] = dt.datetime.now().strftime('%Y-%m-%d') + del album['id'] + + self.albums.update_one(q, {"$set":album}, upsert=True) + + def get_albums_by_release_date(self, start_date, end_date): + """ + Get all albums in date window + """ + q = {"release_date":{"$gte": start_date, "$lt": end_date}} + cols = {"_id":1} + r = list(sdb.albums.find(q, cols)) + + return [x['_id'] for x in r] + + @@ -284,6 +337,39 @@ def get_artist_info(self, artists): return result + def get_artist_albums(self, artists): + """ + Returns subset of album fields + """ + + # Call info + lim = 50 + offset = 0 + album_types = 'single,album' + country='US' + keys = ['album_type', 'album_group', 'id', 'name', 'release_date', "artists", 'total_tracks'] + + # Get All artist info + result = [] + for artist in tqdm(artists): + + # Initialize array for speed + self.refresh_connection() + total = int(self.sp.artist_albums(artist, country=country, album_type=album_types, limit=1)['total']) + + artist_result = ['' for x in range(total)] # List of album ids pre-initialized + for i in range(int(np.ceil(total/lim))): + self.refresh_connection() + response = self.sp.artist_albums(artist, country=country, album_type=album_types, limit=lim, offset=(i*lim)) + artist_result[i*lim:(i*lim)+len(response['items'])] = [{k: x[k] for k in keys} for x in response['items']] + + result.extend(artist_result) + + # Remove all other info about artists except ids + for i in range(len(result)): + result[i]['artists'] = [x['id'] for x in result[i]['artists']] + + return result class StormRunner: @@ -410,20 +496,20 @@ def collect_album_info(self): Get and update all albums associated with the artists """ - # Get a list of all artists in need of album collection - collected = self.sdb.get_artists_for_album_collection(max_date) - to_collect = [x for x in self.run_record['input_artists'] if x not in collected] + # Get a list of all artists in storm that need album collection + needs_collection = self.sdb.get_artists_for_album_collection(self.run_date) + to_collect = [x for x in self.run_record['input_artists'] if x in needs_collection] + new_albums = [] # Get their albums if len(to_collect) == 0: - print("Artist Albums already acquired today.") + print("Evey Input Artist's Albums already acquired today.") else: print(f"New albums to collect for {len(to_collect)} artists.") - new_albums = self.sc.get_artist_albums(to_collect) - self.sdb.update_artist_album_collected_date(run_record['input_artists']) + print("Collecting data in batches from API and Updating DB.") + self.load_artist_albums(to_collect) - # Update them in DB - self.sdb.update_albums(new_albums) + print("Album Collection Done. \n") # Low Level orchestration def load_playlist(self, playlist_id): @@ -457,7 +543,19 @@ def load_playlist(self, playlist_id): self.run_record['input_tracks'].extend([x for x in input_tracks if x not in self.run_record['input_tracks']]) self.run_record['input_artists'].extend([x for x in input_artists if x not in self.run_record['input_artists']]) - + def load_artist_albums(self, artists): + """ + Get many artists information in batches and write back to database incrementally. + """ + batch_size = 20 + batches = np.array_split(artists, int(np.ceil(len(artists)/batch_size))) + + print(f"Batch Size: {batch_size} | Number of Batches {len(batches)}") + for batch in tqdm(batches): + + batch_albums = self.sc.get_artist_albums(batch) + self.sdb.update_albums(batch_albums) + self.sdb.update_artist_album_collected_date(batch) class Storm: From 232fbefdc4320c02a19429511d81796cae560f1d Mon Sep 17 00:00:00 2001 From: ATawzer <34928044+ATawzer@users.noreply.github.com> Date: Tue, 20 Apr 2021 16:48:39 -0600 Subject: [PATCH 12/29] album tweaks, starting on tracks --- src/storm_client.py | 90 ++++++++++++++++++++++++++++++++++++++------- 1 file changed, 76 insertions(+), 14 deletions(-) diff --git a/src/storm_client.py b/src/storm_client.py index e9a1017..dd90de7 100644 --- a/src/storm_client.py +++ b/src/storm_client.py @@ -218,6 +218,20 @@ def get_albums_by_release_date(self, start_date, end_date): return [x['_id'] for x in r] + def get_albums_for_track_collection(self): + """ + Get all albums that need tracks added. + """ + q = {} + cols = {"_id":1, "tracks":1} + r = list(self.albums.find(q, cols)) + + # Only append artists who need collection in result + result = [] + for album in r: + if 'tracks' not in album.keys(): + result.append(album['_id']) + return result @@ -371,6 +385,36 @@ def get_artist_albums(self, artists): return result + def get_album_info(self, albums): + """ + Returns an albums info and tracks. + """ + # Call info + lim = 50 + country = 'US' + keys = ['genres', 'tracks', 'id', 'name', 'popularity'] + + # Get All artist info + result = [] + for album in tqdm(albums): + + # Initialize array for speed + self.refresh_connection() + total = int(self.sp.album_tracks(artist, country=country, limit=1)['total']) + + album_result = ['' for x in range(total)] # List of album ids pre-initialized + for i in range(int(np.ceil(total/lim))): + self.refresh_connection() + response = self.sp.album_tracks(artist, country=country, limit=lim, offset=(i*lim)) + album_result[i*lim:(i*lim)+len(response['items'])] = [{k: x[k] for k in keys} for x in response['tracks']] + + result.extend(artist_result) + + # Remove all other info about artists except ids + for i in range(len(result)): + result[i]['artists'] = [x['id'] for x in result[i]['artists']] + + return result class StormRunner: """ @@ -495,20 +539,13 @@ def collect_album_info(self): """ Get and update all albums associated with the artists """ - - # Get a list of all artists in storm that need album collection - needs_collection = self.sdb.get_artists_for_album_collection(self.run_date) - to_collect = [x for x in self.run_record['input_artists'] if x in needs_collection] - new_albums = [] - - # Get their albums - if len(to_collect) == 0: - print("Evey Input Artist's Albums already acquired today.") - else: - print(f"New albums to collect for {len(to_collect)} artists.") - print("Collecting data in batches from API and Updating DB.") - self.load_artist_albums(to_collect) - + + print("Getting the albums for Input Artists that haven't been acquired.") + self.collect_artist_albums() + + print("Getting tracks for albums that need it") + self.collect_album_tracks() + print("Album Collection Done. \n") # Low Level orchestration @@ -556,6 +593,31 @@ def load_artist_albums(self, artists): batch_albums = self.sc.get_artist_albums(batch) self.sdb.update_albums(batch_albums) self.sdb.update_artist_album_collected_date(batch) + + def collect_artist_albums(self): + """ + Get artist albums for input artists that need it. + """ + # Get a list of all artists in storm that need album collection + needs_collection = self.sdb.get_artists_for_album_collection(self.run_date) + to_collect = [x for x in self.run_record['input_artists'] if x in needs_collection] + + # Get their albums + if len(to_collect) == 0: + print("Evey Input Artist's Albums already acquired today.") + else: + print(f"New albums to collect for {len(to_collect)} artists.") + print("Collecting data in batches from API and Updating DB.") + self.load_artist_albums(to_collect) + + def collect_album_tracks(self): + """ + Gets tracks for every album that needs them, not just storm. + In the case of new storms this helps populate historical. + In the case of existing ones it will only be the storm albums that need collection. + """ + needs_collection = self.sdb.get_artists_for_album_collection(self.run_date) + to_collect = [x for x in self.run_record['input_artists'] if x in needs_collection] class Storm: From 8d4888278246248c1ccb5c02f895bfd4dc282775 Mon Sep 17 00:00:00 2001 From: ATawzer <34928044+ATawzer@users.noreply.github.com> Date: Wed, 21 Apr 2021 13:29:53 -0600 Subject: [PATCH 13/29] track features started --- scratch.py | 20 +++++- src/storm_client.py | 154 ++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 159 insertions(+), 15 deletions(-) diff --git a/scratch.py b/scratch.py index a95d20c..f934359 100644 --- a/scratch.py +++ b/scratch.py @@ -4,14 +4,21 @@ sr.load_last_run() sr.collect_playlist_info() sr.collect_artist_info() + +sr.run_date = '2021-04-20' sr.collect_album_info() sdb = StormDB() +test = sdb.get_albums_for_track_collection() sdb.get_albums_by_release_date('2021-04-01', '2021-04-05') sc = StormClient('1241528689') +test = sc.get_album_tracks(['0SD8viWtxULmEuPEHkaYQg', '1B2QrHbMox8vPXUY7rXAFp']) +sdb.update_tracks(test) + + test = sc.get_artist_albums(["0360rTDeUjEyBXaz2Ki00a", "07vycW8ICLf5hKb22PFWXw", "0HDxlFsXwyrpufs4YgTNMm", @@ -40,4 +47,15 @@ from_date = dt.datetime.strptime('2021-04-01', '%Y-%m-%d') to_date = dt.datetime.strptime('2021-04-05', '%Y-%m-%d') -list(sdb.albums.find({"release_date": {"$gte": '2021-04-01', "$lt": '2021-04-05'}})) \ No newline at end of file +list(sdb.albums.find({"release_date": {"$gte": '2021-04-01', "$lt": '2021-04-05'}})) + +# Putting scraped tracks on to their albums +sdb = StormDB() +q = {} +cols = {"last_updated":0} +r = list(sdb.tracks.find(q, cols)) + +for x in r: + x["id"] = x.pop("_id") + +sdb.update_tracks(r) diff --git a/src/storm_client.py b/src/storm_client.py index dd90de7..78a032f 100644 --- a/src/storm_client.py +++ b/src/storm_client.py @@ -176,8 +176,8 @@ def get_artists_for_album_collection(self, max_date): # Only append artists who need collection in result result = [] for artist in r: - if 'albums_last_collected' in artist.keys(): - if artist['albums_last_collected'] < max_date: + if 'album_last_collected' in artist.keys(): + if artist['album_last_collected'] < max_date: result.append(artist['_id']) else: result.append(artist['_id']) @@ -233,6 +233,57 @@ def get_albums_for_track_collection(self): result.append(album['_id']) return result + # Tracks + def update_tracks(self, track_info): + """ + update track and its album info if needed. + """ + + for track in tqdm(track_info): + + # Add track to album record + q = {'_id':track['album_id']} + self.albums.update_one(q, {"$push":{"tracks":track['id']}}, upsert=True) + + # Add track data to tracks + q = {"_id":track['id']} + track['last_updated'] = dt.datetime.now().strftime('%Y-%m-%d') + del track['id'] + self.tracks.update_one(q, {"$set":track}, upsert=True) + + def update_track_features(self, tracks): + """ + Updates a track's record with audio features + """ + for track in tqdm(tracks): + q = {"_id":track['id']} + + # Writing updates (formatting changes) + track['audio_features'] = True + track['last_updated'] = dt.datetime.now().strftime('%Y-%m-%d') + del track['id'] + + self.tracks.update_one(q, {"$set":track}, upsert=True) + + def get_tracks_for_feature_collection(self): + """ + Get all tracks that need audio features added. + """ + q = {} + cols = {"_id":1, "audio_features":1} + r = list(self.tracks.find(q, cols)) + + # Only append artists who need collection in result + result = [] + for track in r: + if 'audio_features' not in track.keys(): + result.append(track['_id']) + else: + if not track['audio_features']: + result.append(track['_id']) + return result + + @@ -385,30 +436,32 @@ def get_artist_albums(self, artists): return result - def get_album_info(self, albums): + def get_album_tracks(self, albums): """ Returns an albums info and tracks. """ # Call info lim = 50 country = 'US' - keys = ['genres', 'tracks', 'id', 'name', 'popularity'] + keys = ['artists', 'duration_ms', 'id', 'name', 'explicit', 'track_number'] - # Get All artist info + # Get All album tracks info result = [] for album in tqdm(albums): # Initialize array for speed self.refresh_connection() - total = int(self.sp.album_tracks(artist, country=country, limit=1)['total']) + total = int(self.sp.album_tracks(album, market=country, limit=1)['total']) album_result = ['' for x in range(total)] # List of album ids pre-initialized for i in range(int(np.ceil(total/lim))): self.refresh_connection() - response = self.sp.album_tracks(artist, country=country, limit=lim, offset=(i*lim)) - album_result[i*lim:(i*lim)+len(response['items'])] = [{k: x[k] for k in keys} for x in response['tracks']] + response = self.sp.album_tracks(album, market=country, limit=lim, offset=(i*lim)) + album_result[i*lim:(i*lim)+len(response['items'])] = [{k: x[k] for k in keys} for x in response['items']] - result.extend(artist_result) + # Add the album_id back in + [x.update({'album_id':album}) for x in album_result] + result.extend(album_result) # Remove all other info about artists except ids for i in range(len(result)): @@ -455,11 +508,11 @@ def Run(self): print(f"{self.name} - Step 2 / 8 - Collecting Artist info. . .") self.collect_artist_info() - print(f"{self.name} - Step 3 / 8 - Collecting Albums . . .") + print(f"{self.name} - Step 3 / 8 - Collecting Albums and their Tracks. . .") self.collect_album_info() - print(f"{self.name} - Step 4 / 8 - Collecting Eligible Tracks . . .") - self.collect_storm_tracks() + print(f"{self.name} - Step 4 / 8 - Collecting Track Features . . .") + self.collect_track_features() print(f"{self.name} - Step 5 / 8 - Filtering Track List . . .") self.filter_storm_tracks() @@ -548,6 +601,45 @@ def collect_album_info(self): print("Album Collection Done. \n") + def collect_track_features(self): + """ + Gets all track features needed + Also in a while try except loop to get through all tracks in the case of bad batches. + """ + + to_collect = self.sdb.get_tracks_for_feature_collection(self) + batch_size = 100 + batches = np.array_split(to_collect, int(np.ceil(len(to_collect)/batch_size))) + + # Attempt to go get the batches + bad_batch_retries = 0 + consecutive_bad_batches_limit = 10 + retry_limit = 5 + while (bad_batch_retries < retry_limit) & (len(batches) > 0): + + consecutive_bad_batches = 0 + print(f"Batch Size: {batch_size} | Number of Batches {len(batches)}") + for i, batch in enumerate(tqdm(batches)): + + if consecutive_bad_batches > consecutive_bad_batches_limit: + raise Exception(f"{consecutive_bad_batches_limit} consecutive bad batches. . . Terminating Process.") + try: + batch_tracks = self.sc.get_track_features(batch) + self.sdb.update_track_features(batch_tracks) + + # Successful, does not need collection + consecutive_bad_batches = 0 + del batches[i] + + except: + print("Bad Batch, will try again after.") + consecutive_bad_batches += 1 + + bad_batch_retries += 1 + + print("All Track batches collected!") + print("Eligible Track Collection Done! \n") + # Low Level orchestration def load_playlist(self, playlist_id): """ @@ -615,10 +707,44 @@ def collect_album_tracks(self): Gets tracks for every album that needs them, not just storm. In the case of new storms this helps populate historical. In the case of existing ones it will only be the storm albums that need collection. + Given the intensity, try except implemented to retry bad batches """ - needs_collection = self.sdb.get_artists_for_album_collection(self.run_date) - to_collect = [x for x in self.run_record['input_artists'] if x in needs_collection] + needs_collection = self.sdb.get_albums_for_track_collection() + batch_size = 20 + batches = np.array_split(needs_collection, int(np.ceil(len(needs_collection)/batch_size))) + + # Attempt to go get the batches + bad_batch_retries = 0 + consecutive_bad_batches_limit = 10 + retry_limit = 5 + while (bad_batch_retries < retry_limit) & (len(batches) > 0): + + consecutive_bad_batches = 0 + print(f"Batch Size: {batch_size} | Number of Batches {len(batches)}") + for i, batch in enumerate(tqdm(batches)): + + if consecutive_bad_batches > consecutive_bad_batches_limit: + raise Exception(f"{consecutive_bad_batches_limit} consecutive bad batches. . . Terminating Process.") + try: + batch_tracks = self.sc.get_album_tracks(batch) + self.sdb.update_tracks(batch_tracks) + + # Successful, does not need collection + consecutive_bad_batches = 0 + del batches[i] + + except: + print("Bad Batch, will try again after.") + consecutive_bad_batches += 1 + + bad_batch_retries += 1 + print("All album batches collected!") + + + + + class Storm: """ From 880bf1ffb299cee5358ab7d44f24cca7b23b70c3 Mon Sep 17 00:00:00 2001 From: ATawzer <34928044+ATawzer@users.noreply.github.com> Date: Wed, 21 Apr 2021 15:29:47 -0600 Subject: [PATCH 14/29] track_features done, needs error handling --- src/storm_client.py | 59 +++++++++++++++++++++++++++++++++++---------- 1 file changed, 46 insertions(+), 13 deletions(-) diff --git a/src/storm_client.py b/src/storm_client.py index 78a032f..2cefc22 100644 --- a/src/storm_client.py +++ b/src/storm_client.py @@ -170,7 +170,7 @@ def get_artists_for_album_collection(self, max_date): returns all artists with album collection dates before max_date. """ q = {} - cols = {"_id":1, "albums_last_collected":1} + cols = {"_id":1, "album_last_collected":1} r = list(self.artists.find(q, cols)) # Only append artists who need collection in result @@ -469,6 +469,27 @@ def get_album_tracks(self, albums): return result + def get_track_features(self, tracks): + """ + Returns a tracks info and audio features + """ + # Call info + id_lim = 50 + keys = ["id", "danceability", "energy", "key", "loudness", "mode", "speechiness", "acousticness", + "instrumentalness", "liveness", "valence", "tempo", "time_signature"] + batches = np.array_split(tracks, int(np.ceil(len(tracks)/id_lim))) + + # Get track features in batches + result = [] + for batch in tqdm(batches): + self.refresh_connection() + response = self.sp.audio_features(batch) + result.extend([{k: x[k] for k in keys} for x in response]) + + # Filter to just ids + return result + + class StormRunner: """ Orchestrates a storm run @@ -606,9 +627,13 @@ def collect_track_features(self): Gets all track features needed Also in a while try except loop to get through all tracks in the case of bad batches. """ + + to_collect = self.sdb.get_tracks_for_feature_collection() + if len(to_collect) == 0: + print("No Track Features to collect.") + return True - to_collect = self.sdb.get_tracks_for_feature_collection(self) - batch_size = 100 + batch_size = 1000 batches = np.array_split(to_collect, int(np.ceil(len(to_collect)/batch_size))) # Attempt to go get the batches @@ -617,9 +642,10 @@ def collect_track_features(self): retry_limit = 5 while (bad_batch_retries < retry_limit) & (len(batches) > 0): + bad_batches = [] consecutive_bad_batches = 0 print(f"Batch Size: {batch_size} | Number of Batches {len(batches)}") - for i, batch in enumerate(tqdm(batches)): + for batch in tqdm(batches): if consecutive_bad_batches > consecutive_bad_batches_limit: raise Exception(f"{consecutive_bad_batches_limit} consecutive bad batches. . . Terminating Process.") @@ -629,16 +655,20 @@ def collect_track_features(self): # Successful, does not need collection consecutive_bad_batches = 0 - del batches[i] except: print("Bad Batch, will try again after.") + bad_batches.append(batch) consecutive_bad_batches += 1 bad_batch_retries += 1 + batches = bad_batches + + bad_batch_retries += 1 print("All Track batches collected!") - print("Eligible Track Collection Done! \n") + print("Track Collection Done! \n") + return True # Low Level orchestration def load_playlist(self, playlist_id): @@ -711,6 +741,10 @@ def collect_album_tracks(self): """ needs_collection = self.sdb.get_albums_for_track_collection() batch_size = 20 + if len(needs_collection) == 0: + print("No Albums needed to collect.") + return True + batches = np.array_split(needs_collection, int(np.ceil(len(needs_collection)/batch_size))) # Attempt to go get the batches @@ -719,9 +753,10 @@ def collect_album_tracks(self): retry_limit = 5 while (bad_batch_retries < retry_limit) & (len(batches) > 0): + bad_batches = [] consecutive_bad_batches = 0 print(f"Batch Size: {batch_size} | Number of Batches {len(batches)}") - for i, batch in enumerate(tqdm(batches)): + for batch in tqdm(batches): if consecutive_bad_batches > consecutive_bad_batches_limit: raise Exception(f"{consecutive_bad_batches_limit} consecutive bad batches. . . Terminating Process.") @@ -731,20 +766,19 @@ def collect_album_tracks(self): # Successful, does not need collection consecutive_bad_batches = 0 - del batches[i] except: print("Bad Batch, will try again after.") + bad_batches.append(batch) consecutive_bad_batches += 1 bad_batch_retries += 1 + batches = bad_batches print("All album batches collected!") + return True - - - class Storm: """ @@ -769,8 +803,7 @@ def Run(self): for storm_name in self.storm_names: self.runners[storm_name] = StormRunner(storm_name) -# A class to manage all of the storm functions and authentication -class StormOld: + """ Single object for running and saving data frm the storm run. Call Storm.Run() to generate a playlist from saved artists. From 2b9db53652c4746570ba72b375c21c171c7e0b60 Mon Sep 17 00:00:00 2001 From: ATawzer <34928044+ATawzer@users.noreply.github.com> Date: Wed, 21 Apr 2021 16:56:17 -0600 Subject: [PATCH 15/29] track_features done --- src/storm_client.py | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/src/storm_client.py b/src/storm_client.py index 2cefc22..c181c94 100644 --- a/src/storm_client.py +++ b/src/storm_client.py @@ -282,7 +282,21 @@ def get_tracks_for_feature_collection(self): if not track['audio_features']: result.append(track['_id']) return result - + + def update_bad_track_features(self, bad_tracks): + """ + If tracks that can't get features are identified, mark them here + """ + for track in tqdm(bad_tracks): + q = {"_id":track['id']} + + # Writing updates (formatting changes) + track['audio_features'] = False + track['last_updated'] = dt.datetime.now().strftime('%Y-%m-%d') + del track['id'] + + self.tracks.update_one(q, {"$set":track}, upsert=True) + @@ -484,7 +498,7 @@ def get_track_features(self, tracks): for batch in tqdm(batches): self.refresh_connection() response = self.sp.audio_features(batch) - result.extend([{k: x[k] for k in keys} for x in response]) + result.extend([{k: x[k] for k in keys} for x in response if x is not None]) # Filter to just ids return result From d0e3f1f46cb8e48515777397742cd2b5cf76c016 Mon Sep 17 00:00:00 2001 From: ATawzer <34928044+ATawzer@users.noreply.github.com> Date: Thu, 22 Apr 2021 11:58:35 -0600 Subject: [PATCH 16/29] Slowly working through filtering --- scratch.py | 19 +++++--- src/storm_client.py | 107 +++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 115 insertions(+), 11 deletions(-) diff --git a/scratch.py b/scratch.py index f934359..547f873 100644 --- a/scratch.py +++ b/scratch.py @@ -1,22 +1,29 @@ storm = Storm(['film_vg_instrumental']) sr = StormRunner('film_vg_instrumental') +sr.run_date = '2021-04-20' sr.load_last_run() sr.collect_playlist_info() sr.collect_artist_info() - -sr.run_date = '2021-04-20' sr.collect_album_info() +#sr.collect_track_features() +sr.filter_storm_tracks() sdb = StormDB() -test = sdb.get_albums_for_track_collection() -sdb.get_albums_by_release_date('2021-04-01', '2021-04-05') +sdb.get_blacklist('instrumental_blacklist') +test = sdb.get_tracks_for_feature_collection() +#sdb.get_albums_by_release_date('2021-04-01', '2021-04-05') +sdb = StormDB() +sdb.update_artist_albums() + +sdb.artists.update_many({}, {"$unset":{"albums":1}}) +test = sdb.get_tracks_for_feature_collection()[:5] sc = StormClient('1241528689') -test = sc.get_album_tracks(['0SD8viWtxULmEuPEHkaYQg', '1B2QrHbMox8vPXUY7rXAFp']) -sdb.update_tracks(test) +test_response = sc.get_track_features(test) +sdb.update_track_features(test_response) test = sc.get_artist_albums(["0360rTDeUjEyBXaz2Ki00a", diff --git a/src/storm_client.py b/src/storm_client.py index c181c94..d2c6ad4 100644 --- a/src/storm_client.py +++ b/src/storm_client.py @@ -35,6 +35,7 @@ def __init__(self): self.tracks = self.db['tracks'] self.playlists = self.db['playlists'] self.runs = self.db['runs'] + self.blacklists = self.db['blacklists'] def get_config(self, storm_name): """ @@ -193,6 +194,24 @@ def update_artist_album_collected_date(self, artist_ids): q = {"_id":artist_id} self.artists.update_one(q, {"$set":{"album_last_collected":date}}, upsert=True) + def get_blacklist(self, name): + """ + Returns a full blacklist record by name (id) + """ + q = {"_id":name} + cols = {"_id":1, "blacklist":1, "type":1} + return list(self.blacklists.find(q, cols)) + + def get_artists_by_genres(self, genres): + """ + Gets a list artists in DB that have one or more of the genres + """ + q = {"genres":{"$all":genres}} + cols = {"_id":1} + r = list(self.artists.find(q, cols)) + + return [x["_id"] for x in r] + # Albums def update_albums(self, album_info): """ @@ -297,7 +316,27 @@ def update_bad_track_features(self, bad_tracks): self.tracks.update_one(q, {"$set":track}, upsert=True) + # DB Cleanup and Prep + def update_artist_albums(self): + """ + Adds a track list to each artist or appends if not there + """ + q = {} + cols = {"_id":1, "added_to_artists":1, 'artists':1} + r = list(self.albums.find(q, cols)) + + for album in tqdm(r): + + if 'added_to_artists' not in album.keys(): + for artist in album['artists']: + self.artists.update_one({"_id":artist}, {"$addToSet":{"albums":album["_id"]}}, upsert=True) + self.albums.update_one({"_id":album["_id"]}, {"$set":{"added_to_artists":True}}) + else: + if not album['added_to_artists']: + for artist in album['artists']: + self.artists.update_one({"_id":artist}, {"$addToSet":{"albums":album["_id"]}}, upsert=True) + self.albums.update_one({"_id":album["_id"]}, {"$set":{"added_to_artists":True}}) @@ -522,8 +561,13 @@ def __init__(self, storm_name, start_date=None): 'storm_name':self.name, 'run_date':self.run_date, 'playlists':[], - 'input_tracks':[], - 'input_artists':[]} + 'input_tracks':[], # Determines what gets collected + 'input_artists':[], # Determines what gets collected, also 'egligible' artists + 'eligible_tracks':[], # Tracks that could be delivered before track filters + 'storm_tracks':[], # Tracks actually written out + 'storm_artists':[], # Used for track filtering + 'storm_albums':[] # Release Date Filter + } self.last_run = self.sdb.get_last_run(self.name) print(f"{self.name} Started Successfully!\n") @@ -684,6 +728,26 @@ def collect_track_features(self): print("Track Collection Done! \n") return True + def filter_storm_tracks(self): + """ + Get a List of tracks to deliver. + """ + + print("Filtering out bad artists.") + self.apply_artist_filters() + + print("Obtaining all albums from storm artists.") + self.run_record['storm_albums'] = self.sdb.get_albums_from_artists_by_date(self.run_record['storm_artists'], self.start_date) + + print("Getting tracks from albums.") + self.sdb.get_tracks_from_albums() + + print("Filtering Tracks.") + self.apply_track_filters() + + print("Storm Tracks Generated! \n") + + # Low Level orchestration def load_playlist(self, playlist_id): """ @@ -691,7 +755,7 @@ def load_playlist(self, playlist_id): """ # Determine if playlists need examining - if self.run_date != self.sdb.get_playlist_collection_date(playlist_id): + if self.run_date > self.sdb.get_playlist_collection_date(playlist_id): # Acquire data playlist_record = {'_id':playlist_id, @@ -746,6 +810,9 @@ def collect_artist_albums(self): print("Collecting data in batches from API and Updating DB.") self.load_artist_albums(to_collect) + print("Updating artist album association in DB.") + self.sdb.update_artist_albums() + def collect_album_tracks(self): """ Gets tracks for every album that needs them, not just storm. @@ -792,8 +859,38 @@ def collect_album_tracks(self): print("All album batches collected!") return True - - + def apply_artist_filters(self): + """ + read in filters from configurations + """ + filters = self.config['filters']['artist'] + supported = ['genre', 'blacklist'] + bad_artists = [] + + # Filters + print(f"{len(filters)} valid filters to apply") + for filter_name, filter_value in filters.items(): + + print(f"Applying {filter_name}") + if filter_name == 'genre': + genre_artists = self.sdb.get_artists_by_genres(filter_value) + bad_artists.extend(genre_artists) + + elif filter_name == 'blacklist': + blacklist = self.sdb.get_blacklist(filter_value) + if len(blacklist) == 0: + print(f"{filter_value} not found, no filtering will be done.'") + else: + print(f"{filter_value} found, removing.'") + bad_artists.extend(blacklist[0]['blacklist']) + else: + print(f"{filter_name} not supported or misspelled. ") + + self.run_record['storm_artists'] = [x for x in self.run_record['input_artists'] if x not in bad_artists] + print(f"Starting Artist Amount: {len(self.run_record['input_artists'])}") + print(f"Ending Artist Amount: {len(self.run_record['storm_artists'])}") + time.sleep(.5) + class Storm: """ Main callable that initiates and saves storm data From b2ad6a1359772cdcc5c9a7926768b508274b4cb0 Mon Sep 17 00:00:00 2001 From: ATawzer <34928044+ATawzer@users.noreply.github.com> Date: Fri, 23 Apr 2021 13:52:04 -0600 Subject: [PATCH 17/29] full storm run successful --- .cache-1241528689 | 2 +- src/helper.py | 2 +- src/storm_client.py | 757 ++++++++++++++------------------------------ 3 files changed, 234 insertions(+), 527 deletions(-) diff --git a/.cache-1241528689 b/.cache-1241528689 index a8ab3d1..b781609 100644 --- a/.cache-1241528689 +++ b/.cache-1241528689 @@ -1 +1 @@ -{"access_token": "BQBReHv45W35FBAfsdGR9ANKBqvM51tflI20xD-jmMj0Ii8nQOcZHPBDG7RHLHyBSxkUp_MZjUKl3u1-sLR8WKdG3UOImlC-_0WUB5sOwn7Z4beWDrZBjUb9TveHmC7ufrjwD1IGzwsGK1N0Uj4cDlNWSxxikyJSo3mNIBvyEGk8oBp-9Yp6MzrrxnmJddR1VfFeSALIDS4U5NyMSdDrOEI", "token_type": "Bearer", "expires_in": 3600, "scope": "user-follow-read playlist-modify-private playlist-modify-public user-follow-modify", "expires_at": 1618616030, "refresh_token": "AQAsxkWjXR0Iw8q65vbKmXUR0cOGEM8liRshm9vhsJbDenCcjijwBgyKF91oCqQ8NjdD8fwk3uO-NKGUVWYtWRF0E2f5ydGSyFlJRi29TR1Zyw71OKdaIs89XzUBfCOOO0M"} \ No newline at end of file +{"access_token": "BQAcvHRV2TLsQx9RHJtew_5di4zMfDU7_nNxZXV5HTXH1V8s0fm6AEpgcej8MoDKp2rr9iwuGWnXt7DVPCHaiEA86TIwumCMFS14rVZgX9sVrUmN8j4qKyrVF5DAYfdfv675_6IO15UroBRTf6ZiTG0jORm7j6xfvGWfGz0i5Syy7TdJ6b_vUzRReeP1Fbpgyttjm3XQjdPozsYrb9KMHck", "token_type": "Bearer", "expires_in": 3600, "scope": "playlist-modify-private playlist-modify-public", "expires_at": 1619208799, "refresh_token": "AQAsxkWjXR0Iw8q65vbKmXUR0cOGEM8liRshm9vhsJbDenCcjijwBgyKF91oCqQ8NjdD8fwk3uO-NKGUVWYtWRF0E2f5ydGSyFlJRi29TR1Zyw71OKdaIs89XzUBfCOOO0M"} \ No newline at end of file diff --git a/src/helper.py b/src/helper.py index 8cfdab9..1acc792 100644 --- a/src/helper.py +++ b/src/helper.py @@ -5,4 +5,4 @@ def slow_print(string='', t=.0001): for letter in string: sys.stdout.write(letter) time.sleep(t) - print() + sys.stdout.write('\n') diff --git a/src/storm_client.py b/src/storm_client.py index d2c6ad4..4c8c9e4 100644 --- a/src/storm_client.py +++ b/src/storm_client.py @@ -15,7 +15,7 @@ # Internal from helper import * -#print = slow_print # for fun +print = slow_print # for fun load_dotenv() class StormDB: @@ -74,6 +74,10 @@ def get_last_run(self, storm_name): max_run_idx = np.argmax(np.array([dt.datetime(x['run_date']) for x in r])) return r[max_run_idx] + def write_run_record(self, run_record): + + q = {} + self.runs.insert_one(run_record) # Playlist def get_playlist_collection_date(self, playlist_id): @@ -199,7 +203,7 @@ def get_blacklist(self, name): Returns a full blacklist record by name (id) """ q = {"_id":name} - cols = {"_id":1, "blacklist":1, "type":1} + cols = {"_id":1, "blacklist":1, "type":1, "input_playlist":1} return list(self.blacklists.find(q, cols)) def get_artists_by_genres(self, genres): @@ -212,6 +216,13 @@ def get_artists_by_genres(self, genres): return [x["_id"] for x in r] + def update_blacklist(self, blacklist_name, artists): + """ + updates a blacklists artists given its name + """ + q = {"_id":blacklist_name} + [self.blacklists.update_one(q, {"$addToSet":{"blacklist":x}}) for x in artists] + # Albums def update_albums(self, album_info): """ @@ -231,7 +242,7 @@ def get_albums_by_release_date(self, start_date, end_date): """ Get all albums in date window """ - q = {"release_date":{"$gte": start_date, "$lt": end_date}} + q = {"release_date":{"$gte": start_date, "$lte": end_date}} cols = {"_id":1} r = list(sdb.albums.find(q, cols)) @@ -252,6 +263,26 @@ def get_albums_for_track_collection(self): result.append(album['_id']) return result + def get_albums_from_artists_by_date(self, artists, start_date, end_date): + """ + Get all albums in date window + """ + + # Get starting list of albums with artists + q = {"_id":{"$in":artists}} + cols = {"albums":1} + r = list(self.artists.find(q, cols)) + + valid_albums = [] + [valid_albums.extend(x['albums']) for x in r if 'albums' in x] + + # Return the albums in this list that also meet date criteria + q = {"_id":{"$in":valid_albums}, "release_date":{"$gte": start_date, "$lte": end_date}} + cols = {"_id":1} + r = list(self.albums.find(q, cols)) + + return [x['_id'] for x in r] + # Tracks def update_tracks(self, track_info): """ @@ -316,6 +347,36 @@ def update_bad_track_features(self, bad_tracks): self.tracks.update_one(q, {"$set":track}, upsert=True) + def get_tracks_from_albums(self, albums): + """ + returns a track list based on an album list + """ + q = {"album_id":{"$in":albums}} + cols = {"_id":1} + r = list(self.tracks.find(q, cols)) + + return [x["_id"] for x in r] + + def filter_tracks_by_audio_feature(self, tracks, audio_filter): + """ + Takes in a specific audio_filter format to get tracks with a filter + """ + q = {"_id":{"$in":tracks}, **audio_filter} + cols = {"_id":1} + r = list(self.tracks.find(q, cols)) + + return [x["_id"] for x in r] + + def get_track_artists(self, track): + + q = {"_id":track} + cols = {"_id":1, "artists":1} + + try: + return list(self.tracks.find(q, cols))[0]['artists'] + except: + raise ValueError(f"Track {track} not found or doesn't have any artists.") + # DB Cleanup and Prep def update_artist_albums(self): """ @@ -338,14 +399,63 @@ def update_artist_albums(self): self.artists.update_one({"_id":artist}, {"$addToSet":{"albums":album["_id"]}}, upsert=True) self.albums.update_one({"_id":album["_id"]}, {"$set":{"added_to_artists":True}}) +class StormUserClient: + + def __init__(self, user_id): + """ + Client with authorization for modifying user information. + """ + + self.user_id = user_id # User to authorize, only needed for modify operations + self.scope = 'playlist-modify-private playlist-modify-public' # scope for permissions + self.client_id = os.getenv('storm_client_id') # API app id + self.client_secret = os.getenv('storm_client_secret') # API app secret + + self.token = None + + # Authenticate + self.authenticate() + print("Storm User Client successfully connected to Spotify.") + + # Authentication Functions + def authenticate(self): + """ + Connect to Spotify API, intialize spotipy object and generate access token. + """ + self.token = util.prompt_for_user_token(self.user_id, + scope=self.scope, + client_id=self.client_id, + client_secret=self.client_secret, + redirect_uri='http://localhost/') + self.sp = spotipy.Spotify(auth=self.token) + self.token_start = dt.datetime.now() + def write_playlist_tracks(self, playlist_id, tracks): + """ + Writes a list of track ids into a user's playlist + """ + + # Call info + id_lim = 50 + batches = np.array_split(tracks, int(np.ceil(len(tracks)/id_lim))) + + # First batch overwrite + self.authenticate() + self.sp.user_playlist_replace_tracks(self.user_id, playlist_id, batches[0]) + + for batch in tqdm(batches[1:]): + self.sp.user_playlist_add_tracks(self.user_id, playlist_id, batch) + + return True class StormClient: def __init__(self, user_id): + """ + Simple client, no user needed + """ - self.scope = 'user-follow-read playlist-modify-private playlist-modify-public user-follow-modify' # scope for permissions - self.user_id = user_id + self.user_id = user_id # User scope, no authorization needed, though self.client_id = os.getenv('storm_client_id') # API app id self.client_secret = os.getenv('storm_client_secret') # API app secret @@ -357,7 +467,7 @@ def __init__(self, user_id): self.refresh_connection() # Good - print("Storm Client successfully connected to Spotify.\n") + print("Storm Client successfully connected to Spotify.") # Authentication @@ -366,17 +476,8 @@ def refresh_connection(self): Get a cached token (again) or try to get a new one. Call this before any api call to make sure it won't get credential error. """ - try: - self.token = self.sp_cc.get_access_token(as_dict=False) - self.sp = spotipy.Spotify(auth=self.token) - except: - print("Looks like a new User, couldn't get access token. Trying authenticating.") - self.token = util.prompt_for_user_token(self.user_id, - scope=self.scope, - client_id=self.client_id, - client_secret=self.client_secret, - redirect_uri='http://localhost/') - self.sp = spotipy.Spotify(auth=self.token) + self.token = self.sp_cc.get_access_token(as_dict=False) + self.sp = spotipy.Spotify(auth=self.token) def get_playlist_info(self, playlist_id): """ Returns subset of playlist metadata """ @@ -542,7 +643,6 @@ def get_track_features(self, tracks): # Filter to just ids return result - class StormRunner: """ Orchestrates a storm run @@ -553,22 +653,28 @@ def __init__(self, storm_name, start_date=None): self.sdb = StormDB() self.config = self.sdb.get_config(storm_name) self.sc = StormClient(self.config['user_id']) + self.suc = StormUserClient(self.config['user_id']) self.name = storm_name + self.start_date = start_date # metadata self.run_date = dt.datetime.now().strftime('%Y-%m-%d') self.run_record = {'config':self.config, 'storm_name':self.name, 'run_date':self.run_date, + 'start_date':self.start_date, 'playlists':[], 'input_tracks':[], # Determines what gets collected 'input_artists':[], # Determines what gets collected, also 'egligible' artists 'eligible_tracks':[], # Tracks that could be delivered before track filters 'storm_tracks':[], # Tracks actually written out 'storm_artists':[], # Used for track filtering - 'storm_albums':[] # Release Date Filter + 'storm_albums':[], # Release Date Filter + 'storm_sample_tracks':[], # subset of storm tracks delivered to sample + 'removed_artists':[] # Artists filtered out } self.last_run = self.sdb.get_last_run(self.name) + self.gen_dates() print(f"{self.name} Started Successfully!\n") #self.Run() @@ -733,22 +839,55 @@ def filter_storm_tracks(self): Get a List of tracks to deliver. """ - print("Filtering out bad artists.") + print("Filtering artists.") self.apply_artist_filters() print("Obtaining all albums from storm artists.") - self.run_record['storm_albums'] = self.sdb.get_albums_from_artists_by_date(self.run_record['storm_artists'], self.start_date) - + self.run_record['storm_albums'] = self.sdb.get_albums_from_artists_by_date(self.run_record['storm_artists'], + self.run_record['start_date'], + self.run_date) print("Getting tracks from albums.") - self.sdb.get_tracks_from_albums() + self.run_record['eligible_tracks'] = self.sdb.get_tracks_from_albums(self.run_record['storm_albums']) print("Filtering Tracks.") self.apply_track_filters() print("Storm Tracks Generated! \n") + def call_weatherboy(self): + """ + Run Modeling process + """ + return None + + def write_storm_tracks(self): + """ + Output the tracks in storm_tracks + """ + self.suc.write_playlist_tracks(self.config['full_storm_delivery']['playlist'], self.run_record['storm_tracks']) + + def save_run_record(self): + """ + Update Metadata and save run_record + """ + self.sdb.write_run_record(self.run_record) + # Low Level orchestration + def gen_dates(self): + """ + If there was a last run, do all tracks in between. Otherwise do a week since run + """ + + if self.last_run is not None: + if 'run_date' in self.last_run.keys(): + self.start_date = self.last_run['run_date'] + self.run_record['start_date'] = self.start_date + + if self.start_date is None: + self.start_date = (dt.datetime.now() - dt.timedelta(days=7)).strftime("%Y-%m-%d") + self.run_record['start_date'] = self.start_date + def load_playlist(self, playlist_id): """ Pulls down playlist info and writes it back to db @@ -871,8 +1010,9 @@ def apply_artist_filters(self): print(f"{len(filters)} valid filters to apply") for filter_name, filter_value in filters.items(): - print(f"Applying {filter_name}") + print(f"Attemping filter {filter_name} - {filter_value}") if filter_name == 'genre': + # Add all known artists in sdb of a genre to remove in tracks later genre_artists = self.sdb.get_artists_by_genres(filter_value) bad_artists.extend(genre_artists) @@ -881,15 +1021,79 @@ def apply_artist_filters(self): if len(blacklist) == 0: print(f"{filter_value} not found, no filtering will be done.'") else: - print(f"{filter_value} found, removing.'") + print(f"{filter_value} found!'") + if 'input_playlist' in blacklist[0].keys(): + print("Updating Blacklist . . .") + self.update_blacklist_from_playlist(blacklist[0]['_id'], blacklist[0]['input_playlist']) + + # Reload + blacklist = self.sdb.get_blacklist(filter_value) bad_artists.extend(blacklist[0]['blacklist']) else: print(f"{filter_name} not supported or misspelled. ") self.run_record['storm_artists'] = [x for x in self.run_record['input_artists'] if x not in bad_artists] + self.run_record['removed_artists'] = bad_artists print(f"Starting Artist Amount: {len(self.run_record['input_artists'])}") print(f"Ending Artist Amount: {len(self.run_record['storm_artists'])}") - time.sleep(.5) + + def update_blacklist_from_playlist(self, blacklist_name, playlist_id): + """ + Updates a blacklist from a playlist (reads the artists) + """ + bl_tracks = self.sc.get_playlist_tracks(playlist_id) + bl_artists = self.sc.get_artists_from_tracks(bl_tracks) + self.sdb.update_blacklist(blacklist_name, bl_artists) + + def apply_track_filters(self): + """ + read in filters from configurations + """ + filters = self.config['filters']['track'] + supported = ['audio_features', 'artist_filter'] + bad_tracks = [] + + # Filters + print(f"{len(filters)} valid filters to apply") + for filter_name, filter_value in filters.items(): + + print(f"Attemping filter {filter_name} - {filter_value}") + if filter_name == 'audio_features': + for feature, feature_value in filter_value.items(): + op = f"${feature_value.split('&&')[0]}" + val = float(feature_value.split('&&')[1]) + print(f"Removing tracks with {feature} - {op}:{val}") + valid = self.sdb.filter_tracks_by_audio_feature(self.run_record['eligible_tracks'], {feature:{op:val}}) + bad_tracks.extend([x for x in self.run_record['eligible_tracks'] if x not in valid]) + print(f"Cumulative Bad Tracks found {len(np.unique(bad_tracks))}") + + + elif filter_name == "artist_filter": + if filter_value == 'hard': + # Limits output to tracks that contain only storm artists + for track in tqdm(self.run_record['eligible_tracks']): + + track_artists = set(self.sdb.get_track_artists(track)) + if not track_artists.issubset(set(self.run_record['storm_artists'])): + bad_tracks.append(track) + + elif filter_value == 'soft': + # Removes tracks that contain known filtered out artists + # Other 'bad' artists could sneak in if not tracked by storm + for track in tqdm(self.run_record['eligible_tracks']): + track_artists = set(self.sdb.get_track_artists(track)) + if not set(self.run_record['removed_artists']).isdisjoint(track_artists): + bad_tracks.append(track) + + else: + print(f"{filter_name} not supported or misspelled. ") + + bad_tracks = np.unique(bad_tracks).tolist() + print("Removing bad tracks . . .") + self.run_record['storm_tracks'] = [x for x in self.run_record['eligible_tracks'] if x not in bad_tracks] + self.run_record['removed_tracks'] = bad_tracks + print(f"Starting Track Amount: {len(self.run_record['eligible_tracks'])}") + print(f"Ending Track Amount: {len(self.run_record['storm_tracks'])}") class Storm: """ @@ -900,7 +1104,6 @@ def __init__(self, storm_names, start_date=None): self.print_initial_screen() self.sdb = StormDB() self.storm_names = storm_names - self.start_date = start_date self.runners = {} def print_initial_screen(self): @@ -912,502 +1115,6 @@ def Run(self): print("Spinning up Storm Runners. . . ") for storm_name in self.storm_names: - self.runners[storm_name] = StormRunner(storm_name) + StormRunner(storm_name).Run() - - """ - Single object for running and saving data frm the storm run. Call Storm.Run() to generate a playlist from - saved artists. - """ - def __init__(self, user_id, inputs, output, archive, name, start_date=None, filter_unseen=True, instrumental=True): - """ - params: - user_id - spotify user account number - inputs - Dictionary of playlists 'name':'playlist_id' that will feed new releases - output - Playlist id to save new releases to - archive - Playlist id to archive current songs in the storm to - name - A name for this storm setup (for saving metadata and allowing for multiple storm configurations) - start_date - defaults to a 2-day window frm current date, but could be wider if desired (format: 'yyyy-mm-dd') - """ - # Variables - self.scope = 'user-follow-read playlist-modify-private playlist-modify-public user-follow-modify' # scope for permissions - self.user_id = user_id - self.client_id = os.getenv('client_id') # API app id - self.client_secret = os.getenv('client_secret') # API app secret - self.token = None - self.token_start = None - self.sp = None - self.inputs = inputs - self.output = output - self.archive = archive - self.name = name - self.start_date = start_date - self.window_date = None - self.filter_unseen = filter_unseen - self.instrumental = instrumental - - # Initialization - self.authenticate() - self.gen_dates() - - # I/O Params for file saving - self.artist_id_csv = './data/storm_artists_'+self.name+'.csv' - self.album_id_csv = './data/storm_albums_'+self.name+'.csv' - self.md_name = './data/storm_run_metadata_'+self.name+'.csv' - - # Dataframe initialization - self.blacklist = [] - self.artist_ids = [] - self.album_ids = [] - self.albums = pd.DataFrame(columns = ['album_group', 'album_type', 'artists', 'available_markets', - 'external_urls', 'href', 'id', 'images', 'name', 'release_date', - 'release_date_precision', 'total_tracks', 'type', 'uri']) - self.new_ablums = pd.DataFrame() - self.new_tracks = pd.DataFrame(columns = ['artists', 'available_markets', 'disc_number', 'duration_ms', - 'explicit', 'external_urls', 'href', 'id', 'is_local', 'name', - 'preview_url', 'track_number', 'type', 'uri']) - self.storm_track_ids = [] - - - # Metadata for post-run reports - self.mdf = pd.read_csv(self.md_name).set_index('run_date') - self.rd = dt.datetime.now().strftime("%Y/%m/%d") - self.mdf.loc[self.rd, 'start_date'] = self.start_date - - - # Authentication Functions - def authenticate(self): - """ - Connect to Spotify API, intialize spotipy object and generate access token. - """ - print("Generating Token and Authenticating. . .") - self.token = util.prompt_for_user_token(self.user_id, - scope=self.scope, - client_id=self.client_id, - client_secret=self.client_secret, - redirect_uri='http://localhost/') - self.sp = spotipy.Spotify(auth=self.token) - self.token_start = dt.datetime.now() - print("Authentication Complete.") - print() - - def check_token(self): - """ - Determine if token is still valid. This is called in many methods to avoid timeout - """ - - if abs((self.token_start - dt.datetime.now()).total_seconds()) < 3580: - return True - else: - print("Awaiting Expiration and Refreshing.") - time.sleep(25) - self.authenticate() - - def gen_dates(self): - """ - Generates a window-date to filter album release dates based on start-date - """ - - # Start Dates - if self.start_date == None: - self.start_date = (dt.datetime.now() - dt.timedelta(days=1)).strftime("%Y-%m-%d") - - # Playlist Cycling dates - self.window_date = (dt.datetime.now() - dt.timedelta(days=14)).strftime("%Y-%m-%d") - - - # Ochestration Function - def Run(self): - """ - The function that a user must run to generate their playlist of new releases. - Call this function after building a storm object - - Example Usage: - storm = Storm(params) - storm.Run() # Use parameters to generate releases - """ - # Read-in existing data from past runs - self.read_in() - - # Augment artist list before track collection - self.augment_artist_list() - self.clean_artists() - self.save_artists() - - # Get Album lists - self.get_artist_albums() - self.filter_albums() - - # Tracks - self.get_album_tracks() - self.clean_tracks() - - # if track list to large apply date filter - if len(self.storm_track_ids)>9999: - self.filter_unseen = True - self.filter_albums() - self.get_album_tracks() - self.clean_tracks() - - # Playlist Writing - self.archive_current() - self.add_tracks_to_playlist(self.output, self.storm_track_ids) - - # Metadata save - self.save_md() - self.save_albums() - - - # I/O - # methods in this section are straightforward and mostly used for metadata - # tracking and simplifying the number of API calls using information fr0m - # past runs - def read_in(self): - """ - Storm init function to gather - """ - print("Reading in existing Data.") - - if path.exists(self.artist_id_csv): - print("Storm Arists Found! Reading in now.") - self.artist_ids = pd.read_csv(self.artist_id_csv)['artists'].values.tolist() - self.mdf.loc[self.rd, 'artists_tracked'] = len(self.artist_ids) - print(f"Done! {len(self.artist_ids)} Unique Artists found.") - - else: - self.mdf.loc[self.rd, 'artists_tracked'] = 0 - print() - - if path.exists('storm_blacklist_'+self.name+'.csv'): - print("Blacklisted Arists Found! Reading in now.") - self.blacklist = pd.read_csv('storm_blacklist_'+self.name+'.csv')['artists'].tolist() - self.mdf.loc[self.rd, 'blacklisted_artists'] = len(self.blacklist) - print(f"Done! {len(self.blacklist)} Blacklisted Artists found.") - print() - - if path.exists(self.album_id_csv): - print("Previously Discovered Albums Found! Reading in now.") - self.album_ids = pd.read_csv(self.album_id_csv)['albums'].values.tolist() - self.mdf.loc[self.rd, 'albums_tracked'] = len(self.album_ids) - print(f"Done! {len(self.album_ids)} Albums found.") - - else: - self.mdf.loc[self.rd, 'albums_tracked'] = 0 - print() - - def save_artists(self): - - print("Saving Artist Ids.") - pd.DataFrame(self.artist_ids, columns=['artists']).to_csv(self.artist_id_csv, index=False) - - def save_albums(self): - print("Saving Albums from run.") - self.album_ids = self.albums.id.tolist() - pd.DataFrame(self.album_ids, columns=['albums']).to_csv(self.album_id_csv, index=False) - - def save_md(self): - - print("Writing metadata from run.") - self.mdf.to_csv(self.md_name) - - # Storm Aggregate Functions - # These methods do the bulk of the API interfacing - # Most functions take in the previous step and work with the API - # to obtain all the data needed to progress the Run method forward - def augment_artist_list(self): - """ - Use playlist inputs to get a list of artists to track releases from - output: - Arists from playlists added to artist_ids - """ - # Comb through playlists and get the artist ids - print("Augmenting new Artists from playlist input dictionary.") - for pl in self.inputs.keys(): - print("Obtaining a list of Tracks from Playlist . . ." + pl) - playlist_df = self.get_playlist_tracks(self.inputs[pl]) - - print("Finding Artists . . .") - self.extend_artists(playlist_df['track']) - - print("Done! All Input Playlists Scanned.") - - def get_playlist_tracks(self, playlist_id): - """ - Obtain all tracks from a playlist id - input: - playlist_id - input playlist that tracks will be collected for - output: - All tracks from playlist saved - """ - lim = 50 - more_tracks = True - offset=0 - - self.check_token() - playlist_results = self.sp.user_playlist_tracks(self.user_id, playlist_id, limit=lim, offset=offset) - - if len(playlist_results['items']) < lim: - more_tracks = False - - while more_tracks: - - self.check_token() - offset += lim - batch = self.sp.user_playlist_tracks(self.user_id, playlist_id, limit=lim, offset=offset) - playlist_results['items'].extend(batch['items']) - - if len(batch['items']) < lim: - more_tracks = False - - response_df = pd.DataFrame(playlist_results['items']) - return response_df - - def extend_artists(self, track_df): - """ - Take a list of artists, get information and decide whether to include - input: - Dataframe of Tracks - output: - Cleaned set of artist ids to augment - """ - for track in track_df: - try: - artists = dict(track)['artists'] - except: - continue - - for artist in artists: - if artist['id'] not in self.artist_ids: - self.check_token() - artist_info = self.sp.artist(artist['id']) - if 'classical' not in artist_info['genres']: - self.artist_ids.append(artist['id']) - - def clean_artists(self): - """ - Remove any artists saved in the Storm's blacklist metadata file - """ - print("Removing Blacklist Artists.") - self.filter_blacklist() - - def clean_tracks(self): - """ - Perform clean-up on list of newly released tracks - """ - self.storm_track_ids = np.unique(self.storm_track_ids) - self.new_tracks = self.new_tracks.drop_duplicates('id').reset_index(drop=True) - newids = [] - - print("Checking Tracks for bad features.") - print("Starting track amount: "+str(len(self.new_tracks))) - for index in tqdm(self.new_tracks.index): - - artists = self.new_tracks.loc[index, 'artists'] - check=True - - # Check artists - for artist in artists: - if artist['id'] in self.blacklist: - check = False - - # If still a valid track, check a few features - if check: - - # Get track features - af = self.sp.audio_features(self.new_tracks.loc[index, 'id'])[0] - - try: - if af['instrumentalness'] < .7: - check = False - elif af['speechiness'] > .32: - check = False - elif af['duration_ms'] < 60001: - check = False - except: - continue - - # Remove if certain features don't clear - if not self.instrumental: - check = True - - if check: - newids.append(self.new_tracks.loc[index, 'id']) - print("Ending Track Amount: " + str(len(newids))) - self.storm_track_ids = newids - self.mdf.loc[self.rd, 'tracks_added'] = len(self.storm_track_ids) - self.mdf.loc[self.rd, 'tracks_removed'] = self.mdf.loc[self.rd, 'tracks_eligible'] - self.mdf.loc[self.rd, 'tracks_added'] - - def filter_classical(self): - """ - Classical music filters on artist - """ - output_list = [] - for artist in tqdm(self.artist_ids): - self.check_token() - artist_info = self.sp.artist(artist) - - if 'classical' not in artist_info['genres']: - output_list.append(artist) - - self.artist_ids = output_list - - def filter_blacklist(self): - """ - Blacklist metadata file filter - """ - output_list = [] - for artist in tqdm(self.artist_ids): - if artist not in self.blacklist: - output_list.append(artist) - - self.artist_ids = output_list - self.mdf.loc[self.rd, 'artists_augmented'] = len(self.artist_ids)-self.mdf.loc[self.rd, 'artists_tracked'] - - def get_artist_albums(self): - """ - Get a list of all albums an artist has released - """ - - print("Obtaining all albums from the list of artists. (Albums)") - lim = 50 - for artist_id in tqdm(self.artist_ids): - - self.check_token() - response = self.sp.artist_albums(artist_id, limit=lim, album_type='album', country='US') - offset = 0 - more_albums = True - - while more_albums: - - self.check_token() - batch = self.sp.artist_albums(artist_id, limit=lim, offset=offset, album_type='album', country='US') - response['items'].extend(batch['items']) - offset += lim - - if len(batch['items']) < lim: - more_albums = False - - response_df = pd.DataFrame(response['items']) - self.albums = pd.concat([self.albums, response_df], axis=0) - - print(f"Albums being tracked: {len(self.albums)}") - print("Obtaining all albums from the list of artists. (Singles)") - for artist_id in tqdm(self.artist_ids): - - self.check_token() - response = self.sp.artist_albums(artist_id, limit=lim, album_type='single', country='US') - offset = 0 - more_albums = True - - while more_albums: - - self.check_token() - batch = self.sp.artist_albums(artist_id, limit=lim, offset=offset, album_type='single', country='US') - response['items'].extend(batch['items']) - offset += lim - - if len(batch['items']) < lim: - more_albums = False - - response_df = pd.DataFrame(response['items']) - response_df = response_df - self.albums = pd.concat([self.albums, response_df], axis=0) - - print(f"Albums being tracked: {len(self.albums)}") - - def filter_albums(self): - """ - If filter_unseen is True, only releases in the window are tracked. Otherwise - any new piece will be added. - """ - # Or Condition, either its new or hasn't been viewed - print("Filtering Album list for new content.") - if self.filter_unseen: - self.new_albums = self.albums[self.albums.release_date >= self.start_date] - else: - self.new_albums = self.albums[(~self.albums.id.isin(self.album_ids)) | (self.albums.release_date >= self.start_date)] - - self.mdf.loc[self.rd, 'albums_augmented'] = len(self.new_albums) - - def get_album_tracks(self): - """ - Get all tracks off an album. - """ - lim = 50 - print("Using Filtered albums to obtain a track list.") - for album_id in tqdm(self.new_albums.id): - self.check_token() - response = self.sp.album_tracks(album_id, limit=lim) - offset = 0 - more_tracks = True - if len(response['items']) < lim: - more_tracks = False - - while more_tracks: - - self.check_token() - batch = self.sp.album_tracks(album_id, limit=lim, offset=offset) - response['items'].extend(batch['items']) - offset += lim - - if len(batch['items']) < lim: - more_tracks = False - - response_df = pd.DataFrame(response['items']) - self.new_tracks = pd.concat([self.new_tracks, response_df], axis=0) - self.mdf.loc[self.rd, 'tracks_eligible'] = len(self.new_tracks) - - def archive_current(self): - """ - Stash files still in output playlist to new playlist - """ - # Read-in current tracks - print("Archiving Current Storm Listening.") - current_listening = self.get_playlist_tracks(self.output) - current_archive = self.get_playlist_tracks(self.archive) - - try: - track_ids_cur = [dict(track)['id'] for track in current_listening.track] - track_ids_arc = [dict(track)['id'] for track in current_archive.track] - track_ids_writing = [] - - for track in track_ids_cur: - if track not in track_ids_arc: - track_ids_writing.append(track) - - # Write them to the archive playlist - if len(track_ids_writing) == 0: - print("No Unique tracks to Archive.") - else: - self.add_tracks_to_playlist(self.archive, track_ids_writing, replace=False) - except: - print("No Tracks to Archive.") - - def add_tracks_to_playlist(self, playlist_id, track_ids, replace=True): - """ - Write new releases to output playlist. - """ - print("Preparing Tracks for Writing") - lim = 50 - if len(self.storm_track_ids) > lim: - split_tracks = np.array_split(track_ids, np.ceil(len(track_ids)/lim)) - - print("Writing Tracks") - if replace: - self.check_token() - self.sp.user_playlist_replace_tracks(self.user_id, playlist_id, split_tracks[0]) - for track_list in tqdm(split_tracks[1:]): - self.check_token() - self.sp.user_playlist_add_tracks(self.user_id, playlist_id, track_list) - else: - for track_list in tqdm(split_tracks): - self.check_token() - self.sp.user_playlist_add_tracks(self.user_id, playlist_id, track_list) - else: - print("Writing Tracks") - if replace: - self.check_token() - self.sp.user_playlist_replace_tracks(self.user_id, playlist_id, self.storm_track_ids) - else: - self.check_token() - self.sp.user_playlist_add_tracks(self.user_id, playlist_id, self.storm_track_ids) \ No newline at end of file +Storm(['film_vg_instrumental', 'contemporary_lyrical']) From e8025c15d7f4371772b8bf5970a372df3fff9cc3 Mon Sep 17 00:00:00 2001 From: ATawzer <34928044+ATawzer@users.noreply.github.com> Date: Fri, 23 Apr 2021 15:33:50 -0600 Subject: [PATCH 18/29] Full Runs working, needs polish --- .cache-1241528689 | 2 +- run_storm.py | 11 ++++++++ run_storm_shell.sh | 2 ++ scratch.py | 68 --------------------------------------------- src/helper.py | 2 +- src/storm_client.py | 42 ++++++++++++++++++---------- 6 files changed, 43 insertions(+), 84 deletions(-) create mode 100644 run_storm.py create mode 100644 run_storm_shell.sh diff --git a/.cache-1241528689 b/.cache-1241528689 index b781609..971bee0 100644 --- a/.cache-1241528689 +++ b/.cache-1241528689 @@ -1 +1 @@ -{"access_token": "BQAcvHRV2TLsQx9RHJtew_5di4zMfDU7_nNxZXV5HTXH1V8s0fm6AEpgcej8MoDKp2rr9iwuGWnXt7DVPCHaiEA86TIwumCMFS14rVZgX9sVrUmN8j4qKyrVF5DAYfdfv675_6IO15UroBRTf6ZiTG0jORm7j6xfvGWfGz0i5Syy7TdJ6b_vUzRReeP1Fbpgyttjm3XQjdPozsYrb9KMHck", "token_type": "Bearer", "expires_in": 3600, "scope": "playlist-modify-private playlist-modify-public", "expires_at": 1619208799, "refresh_token": "AQAsxkWjXR0Iw8q65vbKmXUR0cOGEM8liRshm9vhsJbDenCcjijwBgyKF91oCqQ8NjdD8fwk3uO-NKGUVWYtWRF0E2f5ydGSyFlJRi29TR1Zyw71OKdaIs89XzUBfCOOO0M"} \ No newline at end of file +{"access_token": "BQCVicHxzaFXtqJnCtg5Hfp8hphi6PzxL6Y-v5-V3OzKo6fdNMbbKhck8nvQD0gCN6tct4YqIVSm_nZBC_D2LrxdHBB2uuMnfRC-3KpCHZw8oy5Pa-0MdrazOgephUeDKYi9yAKrNAJ1vxXGePxDNDLQOYCOWqq_sIQxmOiZLze0RL-7GBLUJN786T6IDapKpwspHiumF_RRh6CC5ruf9Ks", "token_type": "Bearer", "expires_in": 3600, "scope": "playlist-modify-private playlist-modify-public", "expires_at": 1619213323, "refresh_token": "AQAsxkWjXR0Iw8q65vbKmXUR0cOGEM8liRshm9vhsJbDenCcjijwBgyKF91oCqQ8NjdD8fwk3uO-NKGUVWYtWRF0E2f5ydGSyFlJRi29TR1Zyw71OKdaIs89XzUBfCOOO0M"} \ No newline at end of file diff --git a/run_storm.py b/run_storm.py new file mode 100644 index 0000000..31b4d04 --- /dev/null +++ b/run_storm.py @@ -0,0 +1,11 @@ +# Internal +from src.helper import * +from src.storm_client import Storm +print = slow_print # for fun + +# ENV +from dotenv import load_dotenv +load_dotenv() + + +Storm(['film_vg_instrumental', 'contemporary_lyrical']).Run() \ No newline at end of file diff --git a/run_storm_shell.sh b/run_storm_shell.sh new file mode 100644 index 0000000..daaa4ec --- /dev/null +++ b/run_storm_shell.sh @@ -0,0 +1,2 @@ +pipenv shell +python run_storm.py \ No newline at end of file diff --git a/scratch.py b/scratch.py index 547f873..e69de29 100644 --- a/scratch.py +++ b/scratch.py @@ -1,68 +0,0 @@ -storm = Storm(['film_vg_instrumental']) - -sr = StormRunner('film_vg_instrumental') -sr.run_date = '2021-04-20' -sr.load_last_run() -sr.collect_playlist_info() -sr.collect_artist_info() -sr.collect_album_info() -#sr.collect_track_features() -sr.filter_storm_tracks() - -sdb = StormDB() -sdb.get_blacklist('instrumental_blacklist') -test = sdb.get_tracks_for_feature_collection() -#sdb.get_albums_by_release_date('2021-04-01', '2021-04-05') - - -sdb = StormDB() -sdb.update_artist_albums() - -sdb.artists.update_many({}, {"$unset":{"albums":1}}) - -test = sdb.get_tracks_for_feature_collection()[:5] -sc = StormClient('1241528689') -test_response = sc.get_track_features(test) -sdb.update_track_features(test_response) - - -test = sc.get_artist_albums(["0360rTDeUjEyBXaz2Ki00a", -"07vycW8ICLf5hKb22PFWXw", -"0HDxlFsXwyrpufs4YgTNMm", -"0InzETPzx4u2fVgldqQOcd", -"0QxmfaZ2M3gLqL3f7Tap8r", -"0UM4gJJKawZSZuJxYcIwJS", -"0UncJfL7Vqvm9WFuWQSVBC", -"0YC192cP3KPCRWx8zr8MfZ", -"0Z6bE6kOVhh2DHZPMUz2Sr", -"0bdJp8l3a1uJRKe2YaAcE9"]) - -sdb = StormDB() -sdb.update_artist_album_collected_date(["0360rTDeUjEyBXaz2Ki00a", -"07vycW8ICLf5hKb22PFWXw", -"0HDxlFsXwyrpufs4YgTNMm", -"0InzETPzx4u2fVgldqQOcd", -"0QxmfaZ2M3gLqL3f7Tap8r", -"0UM4gJJKawZSZuJxYcIwJS", -"0UncJfL7Vqvm9WFuWQSVBC", -"0YC192cP3KPCRWx8zr8MfZ", -"0Z6bE6kOVhh2DHZPMUz2Sr", -"0bdJp8l3a1uJRKe2YaAcE9"]) - -sdb.update_albums(test) - -from_date = dt.datetime.strptime('2021-04-01', '%Y-%m-%d') -to_date = dt.datetime.strptime('2021-04-05', '%Y-%m-%d') - -list(sdb.albums.find({"release_date": {"$gte": '2021-04-01', "$lt": '2021-04-05'}})) - -# Putting scraped tracks on to their albums -sdb = StormDB() -q = {} -cols = {"last_updated":0} -r = list(sdb.tracks.find(q, cols)) - -for x in r: - x["id"] = x.pop("_id") - -sdb.update_tracks(r) diff --git a/src/helper.py b/src/helper.py index 1acc792..6f278c8 100644 --- a/src/helper.py +++ b/src/helper.py @@ -1,7 +1,7 @@ import time import sys -def slow_print(string='', t=.0001): +def slow_print(string='', t=.01): for letter in string: sys.stdout.write(letter) time.sleep(t) diff --git a/src/storm_client.py b/src/storm_client.py index 4c8c9e4..e45d948 100644 --- a/src/storm_client.py +++ b/src/storm_client.py @@ -11,12 +11,6 @@ # DB from pymongo import MongoClient -from dotenv import load_dotenv - -# Internal -from helper import * -print = slow_print # for fun -load_dotenv() class StormDB: """ @@ -736,7 +730,7 @@ def collect_playlist_info(self): self.load_playlist(self.config['great_targets']) print("Loading Good Targets . . .") - self.load_playlist(self.config['great_targets']) + self.load_playlist(self.config['good_targets']) # Check for additional playlists if 'additional_input_playlists' in self.config.keys(): @@ -745,10 +739,12 @@ def collect_playlist_info(self): print(f"Loading Additional Playlist: {ap}") self.load_playlist(ap_id) - ## ---- Future Version ---- - # Check if we need to move rolling - # Check what songs remain in sample and full delivery + self.load_output_playlist(self.config['full_storm_delivery']['playlist']) + + ## ---- Future Version ---- + self.load_output_playlist(self.config['rolling_good']['playlist']) + # Check if we need to move rolling print("Playlists Prepared. \n") @@ -919,6 +915,28 @@ def load_playlist(self, playlist_id): self.run_record['input_tracks'].extend([x for x in input_tracks if x not in self.run_record['input_tracks']]) self.run_record['input_artists'].extend([x for x in input_artists if x not in self.run_record['input_artists']]) + def load_output_playlist(self, playlist_id): + """ + Pulls down playlist info and writes it back to db + """ + + # Determine if playlists need examining + if self.run_date > self.sdb.get_playlist_collection_date(playlist_id): + + # Acquire data + playlist_record = {'_id':playlist_id, + 'last_collected':self.run_date} + + playlist_record['info'] = self.sc.get_playlist_info(playlist_id) + playlist_record['tracks'] = self.sc.get_playlist_tracks(playlist_id) + playlist_record['artists'] = self.sc.get_artists_from_tracks(playlist_record['tracks']) + + print("Writing changes to DB") + self.sdb.update_playlist(playlist_record) + + else: + print("Skipping API Load, already collected today.") + def load_artist_albums(self, artists): """ Get many artists information in batches and write back to database incrementally. @@ -1102,9 +1120,7 @@ class Storm: def __init__(self, storm_names, start_date=None): self.print_initial_screen() - self.sdb = StormDB() self.storm_names = storm_names - self.runners = {} def print_initial_screen(self): @@ -1116,5 +1132,3 @@ def Run(self): print("Spinning up Storm Runners. . . ") for storm_name in self.storm_names: StormRunner(storm_name).Run() - -Storm(['film_vg_instrumental', 'contemporary_lyrical']) From bce52d38a6c4ba962fd0ba7b99f696d1c3d209fa Mon Sep 17 00:00:00 2001 From: ATawzer <34928044+ATawzer@users.noreply.github.com> Date: Mon, 26 Apr 2021 10:35:24 -0600 Subject: [PATCH 19/29] Update .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 93636f2..8670efd 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ storm/Storm/Storm.mdproj storm/config/config_secret.json *.env +*.cache .idea .vscode From 7cc71adb961a31191509af4e28a32675c3d2bef4 Mon Sep 17 00:00:00 2001 From: ATawzer <34928044+ATawzer@users.noreply.github.com> Date: Mon, 26 Apr 2021 10:35:40 -0600 Subject: [PATCH 20/29] Update run_storm.py --- run_storm.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/run_storm.py b/run_storm.py index 31b4d04..b1caca3 100644 --- a/run_storm.py +++ b/run_storm.py @@ -8,4 +8,4 @@ load_dotenv() -Storm(['film_vg_instrumental', 'contemporary_lyrical']).Run() \ No newline at end of file +Storm(['contemporary_lyrical']).Run() \ No newline at end of file From 14925f9d66d923c120780694c89f9cfbeb81d39e Mon Sep 17 00:00:00 2001 From: ATawzer <34928044+ATawzer@users.noreply.github.com> Date: Mon, 26 Apr 2021 13:11:51 -0600 Subject: [PATCH 21/29] update to secure remote env, dist files, last run --- run_storm.py | 5 +- src/db.py | 376 +++++++++++++++++++++++++++++++++++++++++- src/storm_client.py | 390 +------------------------------------------- 3 files changed, 383 insertions(+), 388 deletions(-) diff --git a/run_storm.py b/run_storm.py index b1caca3..0dd4ba3 100644 --- a/run_storm.py +++ b/run_storm.py @@ -8,4 +8,7 @@ load_dotenv() -Storm(['contemporary_lyrical']).Run() \ No newline at end of file +Storm(['contemporary_lyrical']).Run() + + +test = StormDB().get_last_run('film_vg_instrumental') \ No newline at end of file diff --git a/src/db.py b/src/db.py index d00daed..5f96d53 100644 --- a/src/db.py +++ b/src/db.py @@ -7,18 +7,388 @@ class StormDB: """ - Manages the Dynamodb connections, reading and writing. + Manages the MongoDB connections, reading and writing. """ def __init__(self): # Build mongo client and db - self.mc = MongoClient(os.getenv('mongo_uri')) - self.db = self.mc[os.getenv('storm_db')] + self.mc = MongoClient(os.getenv('mongo_host'), + username=os.getenv('mongo_user'), + password=os.getenv('mongo_pass'), + authSource=os.getenv('mongo_db'), + authMechanism='SCRAM-SHA-256') + self.db = self.mc[os.getenv('mongo_db')] # initialize collections self.artists = self.db['artists'] self.albums = self.db['albums'] self.storms = self.db['storm_metadata'] self.tracks = self.db['tracks'] + self.playlists = self.db['playlists'] + self.runs = self.db['runs'] + self.blacklists = self.db['blacklists'] + + def get_config(self, storm_name): + """ + returns a storm configuration given its name, assuming it exists. + """ + q = {'name':storm_name} + cols = {'config':1} + r = list(self.storms.find(q, cols)) + + if len(r) == 0: + raise KeyError(f"{storm_name} not found, no configuration to load.") + else: + return r[0]['config'] + + def get_all_configs(self): + """ + Returns all configurations in DB. + """ + q = {} + cols = {"name":1, "_id":0} + r = list(self.storms.find(q, cols)) + + return [x['name'] for x in r] + + def get_last_run(self, storm_name): + """ + returns the run_record from last storm run under a given name + """ + q = {"storm_name":storm_name} + cols = {"_id":0} + r = list(self.runs.find(q, cols)) + + if len(r) == 0: + return None + elif len(r) > 0: + max_run_idx = np.argmax(np.array([dt.datetime.strptime(x['run_date'], '%Y-%m-%d') for x in r])) + return r[max_run_idx] + + def write_run_record(self, run_record): + + q = {} + self.runs.insert_one(run_record) + + # Playlist + def get_playlist_collection_date(self, playlist_id): + """ + Gets a playlists last collection date. + """ + q = {"_id":playlist_id} + cols = {"last_collected":1} + r = list(self.playlists.find(q, cols)) + + # If not found print old date + if len(r) == 0: + return '2000-01-01' # Long ago + elif len(r) == 1: + return r[0]['last_collected'] + else: + raise Exception("Playlist Ambiguous, should be unique to table.") + + def update_playlist(self, pr): + + q = {'_id':pr['_id']} + + # Add new entry or update existing one + record = pr + changelog_update = { + 'snapshot':pr['info']['snapshot_id'], + 'tracks':pr['tracks'] + } + + # Update static fields + exclude_keys = ['changelog'] + update_dict = {k: pr[k] for k in set(list(pr.keys())) - set(exclude_keys)} + self.playlists.update_one(q, {"$set":record}, upsert=True) + + # Push to append fields (date as new key) + for key in exclude_keys: + self.playlists.update_one(q, {"$set":{f"{key}.{pr['last_collected']}":changelog_update}}, upsert=True) + + def get_loaded_playlist_tracks(self, playlist_id): + """ + Returns a playlists most recently collected tracks + """ + q = {"_id":playlist_id} + cols = {'tracks':1, "_id":0} + r = list(self.playlists.find(q, cols)) + + if len(r) == 0: + raise ValueError(f"Playlist {playlist_id} not found.") + else: + return r[0]['tracks'] + + def get_loaded_playlist_artists(self, playlist_id): + """ + Returns a playlists most recently collected artists + """ + q = {"_id":playlist_id} + cols = {'artists':1, "_id":0} + r = list(self.playlists.find(q, cols)) + + if len(r) == 0: + raise ValueError(f"Playlist {playlist_id} not found.") + else: + return r[0]['artists'] + + # Artists + def get_known_artist_ids(self): + """ + Returns all ids from the artists db. + """ + + q = {} + cols = {"_id":1} + r = list(self.artists.find(q, cols)) + + return [x['_id'] for x in r] + + def update_artists(self, artist_info): + """ + Updates the artist db with new info + """ + + for artist in tqdm(artist_info): + q = {"_id":artist['id']} + + # Writing updates (formatting changes) + artist['last_updated'] = dt.datetime.now().strftime('%Y-%m-%d') + artist['total_followers'] = artist['followers']['total'] + del artist['followers'] + del artist['id'] + + self.artists.update_one(q, {"$set":artist}, upsert=True) + + def get_artists_for_album_collection(self, max_date): + """ + returns all artists with album collection dates before max_date. + """ + q = {} + cols = {"_id":1, "album_last_collected":1} + r = list(self.artists.find(q, cols)) + + # Only append artists who need collection in result + result = [] + for artist in r: + if 'album_last_collected' in artist.keys(): + if artist['album_last_collected'] < max_date: + result.append(artist['_id']) + else: + result.append(artist['_id']) + return result + + def update_artist_album_collected_date(self, artist_ids): + """ + Updates a list of artists album_collected date to today. + """ + date = dt.datetime.now().strftime('%Y-%m-%d') + + for artist_id in tqdm(artist_ids): + q = {"_id":artist_id} + self.artists.update_one(q, {"$set":{"album_last_collected":date}}, upsert=True) + + def get_blacklist(self, name): + """ + Returns a full blacklist record by name (id) + """ + q = {"_id":name} + cols = {"_id":1, "blacklist":1, "type":1, "input_playlist":1} + return list(self.blacklists.find(q, cols)) + + def get_artists_by_genres(self, genres): + """ + Gets a list artists in DB that have one or more of the genres + """ + q = {"genres":{"$all":genres}} + cols = {"_id":1} + r = list(self.artists.find(q, cols)) + + return [x["_id"] for x in r] + + def update_blacklist(self, blacklist_name, artists): + """ + updates a blacklists artists given its name + """ + q = {"_id":blacklist_name} + [self.blacklists.update_one(q, {"$addToSet":{"blacklist":x}}) for x in artists] + + # Albums + def update_albums(self, album_info): + """ + update album info if needed. + """ + + for album in tqdm(album_info): + q = {"_id":album['id']} + + # Writing updates (formatting changes) + album['last_updated'] = dt.datetime.now().strftime('%Y-%m-%d') + del album['id'] + + self.albums.update_one(q, {"$set":album}, upsert=True) + + def get_albums_by_release_date(self, start_date, end_date): + """ + Get all albums in date window + """ + q = {"release_date":{"$gte": start_date, "$lte": end_date}} + cols = {"_id":1} + r = list(sdb.albums.find(q, cols)) + + return [x['_id'] for x in r] + + def get_albums_for_track_collection(self): + """ + Get all albums that need tracks added. + """ + q = {} + cols = {"_id":1, "tracks":1} + r = list(self.albums.find(q, cols)) + + # Only append artists who need collection in result + result = [] + for album in r: + if 'tracks' not in album.keys(): + result.append(album['_id']) + return result + + def get_albums_from_artists_by_date(self, artists, start_date, end_date): + """ + Get all albums in date window + """ + + # Get starting list of albums with artists + q = {"_id":{"$in":artists}} + cols = {"albums":1} + r = list(self.artists.find(q, cols)) + + valid_albums = [] + [valid_albums.extend(x['albums']) for x in r if 'albums' in x] + + # Return the albums in this list that also meet date criteria + q = {"_id":{"$in":valid_albums}, "release_date":{"$gte": start_date, "$lte": end_date}} + cols = {"_id":1} + r = list(self.albums.find(q, cols)) + + return [x['_id'] for x in r] + + # Tracks + def update_tracks(self, track_info): + """ + update track and its album info if needed. + """ + + for track in tqdm(track_info): + + # Add track to album record + q = {'_id':track['album_id']} + self.albums.update_one(q, {"$push":{"tracks":track['id']}}, upsert=True) + + # Add track data to tracks + q = {"_id":track['id']} + track['last_updated'] = dt.datetime.now().strftime('%Y-%m-%d') + del track['id'] + self.tracks.update_one(q, {"$set":track}, upsert=True) + + def update_track_features(self, tracks): + """ + Updates a track's record with audio features + """ + for track in tqdm(tracks): + q = {"_id":track['id']} + + # Writing updates (formatting changes) + track['audio_features'] = True + track['last_updated'] = dt.datetime.now().strftime('%Y-%m-%d') + del track['id'] + + self.tracks.update_one(q, {"$set":track}, upsert=True) + + def get_tracks_for_feature_collection(self): + """ + Get all tracks that need audio features added. + """ + q = {} + cols = {"_id":1, "audio_features":1} + r = list(self.tracks.find(q, cols)) + + # Only append artists who need collection in result + result = [] + for track in r: + if 'audio_features' not in track.keys(): + result.append(track['_id']) + else: + if not track['audio_features']: + result.append(track['_id']) + return result + + def update_bad_track_features(self, bad_tracks): + """ + If tracks that can't get features are identified, mark them here + """ + for track in tqdm(bad_tracks): + q = {"_id":track['id']} + + # Writing updates (formatting changes) + track['audio_features'] = False + track['last_updated'] = dt.datetime.now().strftime('%Y-%m-%d') + del track['id'] + + self.tracks.update_one(q, {"$set":track}, upsert=True) + + def get_tracks_from_albums(self, albums): + """ + returns a track list based on an album list + """ + q = {"album_id":{"$in":albums}} + cols = {"_id":1} + r = list(self.tracks.find(q, cols)) + + return [x["_id"] for x in r] + + def filter_tracks_by_audio_feature(self, tracks, audio_filter): + """ + Takes in a specific audio_filter format to get tracks with a filter + """ + q = {"_id":{"$in":tracks}, **audio_filter} + cols = {"_id":1} + r = list(self.tracks.find(q, cols)) + + return [x["_id"] for x in r] + + def get_track_artists(self, track): + + q = {"_id":track} + cols = {"_id":1, "artists":1} + + try: + return list(self.tracks.find(q, cols))[0]['artists'] + except: + raise ValueError(f"Track {track} not found or doesn't have any artists.") + + # DB Cleanup and Prep + def update_artist_albums(self): + """ + Adds a track list to each artist or appends if not there + """ + + q = {} + cols = {"_id":1, "added_to_artists":1, 'artists':1} + r = list(self.albums.find(q, cols)) + + for album in tqdm(r): + + if 'added_to_artists' not in album.keys(): + for artist in album['artists']: + self.artists.update_one({"_id":artist}, {"$addToSet":{"albums":album["_id"]}}, upsert=True) + self.albums.update_one({"_id":album["_id"]}, {"$set":{"added_to_artists":True}}) + else: + if not album['added_to_artists']: + for artist in album['artists']: + self.artists.update_one({"_id":artist}, {"$addToSet":{"albums":album["_id"]}}, upsert=True) + self.albums.update_one({"_id":album["_id"]}, {"$set":{"added_to_artists":True}}) + \ No newline at end of file diff --git a/src/storm_client.py b/src/storm_client.py index e45d948..87da40b 100644 --- a/src/storm_client.py +++ b/src/storm_client.py @@ -12,387 +12,6 @@ # DB from pymongo import MongoClient -class StormDB: - """ - Manages the MongoDB connections, reading and writing. - """ - def __init__(self): - - # Build mongo client and db - self.mc = MongoClient(os.getenv('mongo_uri')) - self.db = self.mc[os.getenv('db_name')] - - # initialize collections - self.artists = self.db['artists'] - self.albums = self.db['albums'] - self.storms = self.db['storm_metadata'] - self.tracks = self.db['tracks'] - self.playlists = self.db['playlists'] - self.runs = self.db['runs'] - self.blacklists = self.db['blacklists'] - - def get_config(self, storm_name): - """ - returns a storm configuration given its name, assuming it exists. - """ - q = {'name':storm_name} - cols = {'config':1} - r = list(self.storms.find(q, cols)) - - if len(r) == 0: - raise KeyError(f"{storm_name} not found, no configuration to load.") - else: - return r[0]['config'] - - def get_all_configs(self): - """ - Returns all configurations in DB. - """ - q = {} - cols = {"name":1, "_id":0} - r = list(self.storms.find(q, cols)) - - return [x['name'] for x in r] - - def get_last_run(self, storm_name): - """ - returns the run_record from last storm run under a given name - """ - q = {"name":storm_name} - cols = {} - r = list(self.runs.find(q, cols)) - - if len(r) == 0: - return None - elif len(r) > 0: - max_run_idx = np.argmax(np.array([dt.datetime(x['run_date']) for x in r])) - return r[max_run_idx] - - def write_run_record(self, run_record): - - q = {} - self.runs.insert_one(run_record) - - # Playlist - def get_playlist_collection_date(self, playlist_id): - """ - Gets a playlists last collection date. - """ - q = {"_id":playlist_id} - cols = {"last_collected":1} - r = list(self.playlists.find(q, cols)) - - # If not found print old date - if len(r) == 0: - return '2000-01-01' # Long ago - elif len(r) == 1: - return r[0]['last_collected'] - else: - raise Exception("Playlist Ambiguous, should be unique to table.") - - def update_playlist(self, pr): - - q = {'_id':pr['_id']} - - # Add new entry or update existing one - record = pr - changelog_update = { - 'snapshot':pr['info']['snapshot_id'], - 'tracks':pr['tracks'] - } - - # Update static fields - exclude_keys = ['changelog'] - update_dict = {k: pr[k] for k in set(list(pr.keys())) - set(exclude_keys)} - self.playlists.update_one(q, {"$set":record}, upsert=True) - - # Push to append fields (date as new key) - for key in exclude_keys: - self.playlists.update_one(q, {"$set":{f"{key}.{pr['last_collected']}":changelog_update}}, upsert=True) - - def get_loaded_playlist_tracks(self, playlist_id): - """ - Returns a playlists most recently collected tracks - """ - q = {"_id":playlist_id} - cols = {'tracks':1, "_id":0} - r = list(self.playlists.find(q, cols)) - - if len(r) == 0: - raise ValueError(f"Playlist {playlist_id} not found.") - else: - return r[0]['tracks'] - - def get_loaded_playlist_artists(self, playlist_id): - """ - Returns a playlists most recently collected artists - """ - q = {"_id":playlist_id} - cols = {'artists':1, "_id":0} - r = list(self.playlists.find(q, cols)) - - if len(r) == 0: - raise ValueError(f"Playlist {playlist_id} not found.") - else: - return r[0]['artists'] - - # Artists - def get_known_artist_ids(self): - """ - Returns all ids from the artists db. - """ - - q = {} - cols = {"_id":1} - r = list(self.artists.find(q, cols)) - - return [x['_id'] for x in r] - - def update_artists(self, artist_info): - """ - Updates the artist db with new info - """ - - for artist in tqdm(artist_info): - q = {"_id":artist['id']} - - # Writing updates (formatting changes) - artist['last_updated'] = dt.datetime.now().strftime('%Y-%m-%d') - artist['total_followers'] = artist['followers']['total'] - del artist['followers'] - del artist['id'] - - self.artists.update_one(q, {"$set":artist}, upsert=True) - - def get_artists_for_album_collection(self, max_date): - """ - returns all artists with album collection dates before max_date. - """ - q = {} - cols = {"_id":1, "album_last_collected":1} - r = list(self.artists.find(q, cols)) - - # Only append artists who need collection in result - result = [] - for artist in r: - if 'album_last_collected' in artist.keys(): - if artist['album_last_collected'] < max_date: - result.append(artist['_id']) - else: - result.append(artist['_id']) - return result - - def update_artist_album_collected_date(self, artist_ids): - """ - Updates a list of artists album_collected date to today. - """ - date = dt.datetime.now().strftime('%Y-%m-%d') - - for artist_id in tqdm(artist_ids): - q = {"_id":artist_id} - self.artists.update_one(q, {"$set":{"album_last_collected":date}}, upsert=True) - - def get_blacklist(self, name): - """ - Returns a full blacklist record by name (id) - """ - q = {"_id":name} - cols = {"_id":1, "blacklist":1, "type":1, "input_playlist":1} - return list(self.blacklists.find(q, cols)) - - def get_artists_by_genres(self, genres): - """ - Gets a list artists in DB that have one or more of the genres - """ - q = {"genres":{"$all":genres}} - cols = {"_id":1} - r = list(self.artists.find(q, cols)) - - return [x["_id"] for x in r] - - def update_blacklist(self, blacklist_name, artists): - """ - updates a blacklists artists given its name - """ - q = {"_id":blacklist_name} - [self.blacklists.update_one(q, {"$addToSet":{"blacklist":x}}) for x in artists] - - # Albums - def update_albums(self, album_info): - """ - update album info if needed. - """ - - for album in tqdm(album_info): - q = {"_id":album['id']} - - # Writing updates (formatting changes) - album['last_updated'] = dt.datetime.now().strftime('%Y-%m-%d') - del album['id'] - - self.albums.update_one(q, {"$set":album}, upsert=True) - - def get_albums_by_release_date(self, start_date, end_date): - """ - Get all albums in date window - """ - q = {"release_date":{"$gte": start_date, "$lte": end_date}} - cols = {"_id":1} - r = list(sdb.albums.find(q, cols)) - - return [x['_id'] for x in r] - - def get_albums_for_track_collection(self): - """ - Get all albums that need tracks added. - """ - q = {} - cols = {"_id":1, "tracks":1} - r = list(self.albums.find(q, cols)) - - # Only append artists who need collection in result - result = [] - for album in r: - if 'tracks' not in album.keys(): - result.append(album['_id']) - return result - - def get_albums_from_artists_by_date(self, artists, start_date, end_date): - """ - Get all albums in date window - """ - - # Get starting list of albums with artists - q = {"_id":{"$in":artists}} - cols = {"albums":1} - r = list(self.artists.find(q, cols)) - - valid_albums = [] - [valid_albums.extend(x['albums']) for x in r if 'albums' in x] - - # Return the albums in this list that also meet date criteria - q = {"_id":{"$in":valid_albums}, "release_date":{"$gte": start_date, "$lte": end_date}} - cols = {"_id":1} - r = list(self.albums.find(q, cols)) - - return [x['_id'] for x in r] - - # Tracks - def update_tracks(self, track_info): - """ - update track and its album info if needed. - """ - - for track in tqdm(track_info): - - # Add track to album record - q = {'_id':track['album_id']} - self.albums.update_one(q, {"$push":{"tracks":track['id']}}, upsert=True) - - # Add track data to tracks - q = {"_id":track['id']} - track['last_updated'] = dt.datetime.now().strftime('%Y-%m-%d') - del track['id'] - self.tracks.update_one(q, {"$set":track}, upsert=True) - - def update_track_features(self, tracks): - """ - Updates a track's record with audio features - """ - for track in tqdm(tracks): - q = {"_id":track['id']} - - # Writing updates (formatting changes) - track['audio_features'] = True - track['last_updated'] = dt.datetime.now().strftime('%Y-%m-%d') - del track['id'] - - self.tracks.update_one(q, {"$set":track}, upsert=True) - - def get_tracks_for_feature_collection(self): - """ - Get all tracks that need audio features added. - """ - q = {} - cols = {"_id":1, "audio_features":1} - r = list(self.tracks.find(q, cols)) - - # Only append artists who need collection in result - result = [] - for track in r: - if 'audio_features' not in track.keys(): - result.append(track['_id']) - else: - if not track['audio_features']: - result.append(track['_id']) - return result - - def update_bad_track_features(self, bad_tracks): - """ - If tracks that can't get features are identified, mark them here - """ - for track in tqdm(bad_tracks): - q = {"_id":track['id']} - - # Writing updates (formatting changes) - track['audio_features'] = False - track['last_updated'] = dt.datetime.now().strftime('%Y-%m-%d') - del track['id'] - - self.tracks.update_one(q, {"$set":track}, upsert=True) - - def get_tracks_from_albums(self, albums): - """ - returns a track list based on an album list - """ - q = {"album_id":{"$in":albums}} - cols = {"_id":1} - r = list(self.tracks.find(q, cols)) - - return [x["_id"] for x in r] - - def filter_tracks_by_audio_feature(self, tracks, audio_filter): - """ - Takes in a specific audio_filter format to get tracks with a filter - """ - q = {"_id":{"$in":tracks}, **audio_filter} - cols = {"_id":1} - r = list(self.tracks.find(q, cols)) - - return [x["_id"] for x in r] - - def get_track_artists(self, track): - - q = {"_id":track} - cols = {"_id":1, "artists":1} - - try: - return list(self.tracks.find(q, cols))[0]['artists'] - except: - raise ValueError(f"Track {track} not found or doesn't have any artists.") - - # DB Cleanup and Prep - def update_artist_albums(self): - """ - Adds a track list to each artist or appends if not there - """ - - q = {} - cols = {"_id":1, "added_to_artists":1, 'artists':1} - r = list(self.albums.find(q, cols)) - - for album in tqdm(r): - - if 'added_to_artists' not in album.keys(): - for artist in album['artists']: - self.artists.update_one({"_id":artist}, {"$addToSet":{"albums":album["_id"]}}, upsert=True) - self.albums.update_one({"_id":album["_id"]}, {"$set":{"added_to_artists":True}}) - else: - if not album['added_to_artists']: - for artist in album['artists']: - self.artists.update_one({"_id":artist}, {"$addToSet":{"albums":album["_id"]}}, upsert=True) - self.albums.update_one({"_id":album["_id"]}, {"$set":{"added_to_artists":True}}) - class StormUserClient: def __init__(self, user_id): @@ -929,10 +548,13 @@ def load_output_playlist(self, playlist_id): playlist_record['info'] = self.sc.get_playlist_info(playlist_id) playlist_record['tracks'] = self.sc.get_playlist_tracks(playlist_id) - playlist_record['artists'] = self.sc.get_artists_from_tracks(playlist_record['tracks']) + if len(playlist_record['tracks']) > 0: + playlist_record['artists'] = self.sc.get_artists_from_tracks(playlist_record['tracks']) - print("Writing changes to DB") - self.sdb.update_playlist(playlist_record) + print("Writing changes to DB") + self.sdb.update_playlist(playlist_record) + else: + print("No tracks, must be new storm or something odd is happening.") else: print("Skipping API Load, already collected today.") From c98480a420342dd99a299a27bbd2bb5147fb6f31 Mon Sep 17 00:00:00 2001 From: ATawzer <34928044+ATawzer@users.noreply.github.com> Date: Mon, 26 Apr 2021 13:12:15 -0600 Subject: [PATCH 22/29] added new files to store classes --- src/runner.py | 494 ++++++++++++++++++++++++++++++++++++++++++++++++++ src/storm.py | 44 +++++ 2 files changed, 538 insertions(+) create mode 100644 src/runner.py create mode 100644 src/storm.py diff --git a/src/runner.py b/src/runner.py new file mode 100644 index 0000000..1735696 --- /dev/null +++ b/src/runner.py @@ -0,0 +1,494 @@ +import spotipy +from spotipy import util +from spotipy import oauth2 +import numpy as np +import pandas as pd +from tqdm import tqdm +import os +import datetime as dt +import time +import json + +# DB +from .db import * +from .storm_client import * +from pymongo import MongoClient + +class StormRunner: + """ + Orchestrates a storm run + """ + def __init__(self, storm_name, start_date=None): + + print(f"Initializing Runner for {storm_name}") + self.sdb = StormDB() + self.config = self.sdb.get_config(storm_name) + self.sc = StormClient(self.config['user_id']) + self.suc = StormUserClient(self.config['user_id']) + self.name = storm_name + self.start_date = start_date + + # metadata + self.run_date = dt.datetime.now().strftime('%Y-%m-%d') + self.run_record = {'config':self.config, + 'storm_name':self.name, + 'run_date':self.run_date, + 'start_date':self.start_date, + 'playlists':[], + 'input_tracks':[], # Determines what gets collected + 'input_artists':[], # Determines what gets collected, also 'egligible' artists + 'eligible_tracks':[], # Tracks that could be delivered before track filters + 'storm_tracks':[], # Tracks actually written out + 'storm_artists':[], # Used for track filtering + 'storm_albums':[], # Release Date Filter + 'storm_sample_tracks':[], # subset of storm tracks delivered to sample + 'removed_artists':[] # Artists filtered out + } + self.last_run = self.sdb.get_last_run(self.name) + self.gen_dates() + + print(f"{self.name} Started Successfully!\n") + #self.Run() + + def Run(self): + """ + Storm Orchestration based on a configuration. + """ + + print(f"{self.name} - Step 0 / 8 - Initializing using last run.") + self.load_last_run() + + print(f"{self.name} - Step 1 / 8 - Collecting Playlist Tracks and Artists. . .") + self.collect_playlist_info() + + print(f"{self.name} - Step 2 / 8 - Collecting Artist info. . .") + self.collect_artist_info() + + print(f"{self.name} - Step 3 / 8 - Collecting Albums and their Tracks. . .") + self.collect_album_info() + + print(f"{self.name} - Step 4 / 8 - Collecting Track Features . . .") + self.collect_track_features() + + print(f"{self.name} - Step 5 / 8 - Filtering Track List . . .") + self.filter_storm_tracks() + + print(f"{self.name} - Step 6 / 8 - Handing off to Weatherboy . . . ") + self.call_weatherboy() + + print(f"{self.name} - Step 7 / 8 - Writing to Spotify . . .") + self.write_storm_tracks() + + print(f"{self.name} - Step 8 / 8 - Saving Storm Run . . .") + self.save_run_record() + + print(f"{self.name} - Complete!\n") + + # Object Based orchestration + def load_last_run(self): + """ + Loads in relevant information from last run. + """ + + if self.last_run is None: + print("Storm is new, nothing to load") + + else: + print("Appending last runs tracks and artists.") + self.run_record['input_tracks'].extend(self.last_run['input_tracks']) + self.run_record['input_artists'].extend(self.last_run['input_artists']) + + def collect_playlist_info(self): + """ + Initial Playlist setup orchestration + """ + + print("Loading Great Targets . . .") + self.load_playlist(self.config['great_targets']) + + print("Loading Good Targets . . .") + self.load_playlist(self.config['good_targets']) + + # Check for additional playlists + if 'additional_input_playlists' in self.config.keys(): + if self.config['additional_input_playlists']['is_active']: + for ap, ap_id in self.config['additional_input_playlists']['playlists'].items(): + print(f"Loading Additional Playlist: {ap}") + self.load_playlist(ap_id) + + # Check what songs remain in sample and full delivery + self.load_output_playlist(self.config['full_storm_delivery']['playlist']) + + ## ---- Future Version ---- + self.load_output_playlist(self.config['rolling_good']['playlist']) + # Check if we need to move rolling + + print("Playlists Prepared. \n") + + def collect_artist_info(self): + """ + Loads in the data from the run_records artists + """ + + # get data for artists we don't know + known_artists = self.sdb.get_known_artist_ids() + new_artists = [x for x in self.run_record['input_artists'] if x not in known_artists] + + if len(new_artists) > 0: + print(f"{len(new_artists)} New Artists Found! Getting their info now.") + new_artist_info = self.sc.get_artist_info(new_artists) + + print("Writing their info to DB . . .") + self.sdb.update_artists(new_artist_info) + + else: + print("No new Artists found.") + + print("Artist Info Collection Done.\n") + + def collect_album_info(self): + """ + Get and update all albums associated with the artists + """ + + print("Getting the albums for Input Artists that haven't been acquired.") + self.collect_artist_albums() + + print("Getting tracks for albums that need it") + self.collect_album_tracks() + + print("Album Collection Done. \n") + + def collect_track_features(self): + """ + Gets all track features needed + Also in a while try except loop to get through all tracks in the case of bad batches. + """ + + to_collect = self.sdb.get_tracks_for_feature_collection() + if len(to_collect) == 0: + print("No Track Features to collect.") + return True + + batch_size = 1000 + batches = np.array_split(to_collect, int(np.ceil(len(to_collect)/batch_size))) + + # Attempt to go get the batches + bad_batch_retries = 0 + consecutive_bad_batches_limit = 10 + retry_limit = 5 + while (bad_batch_retries < retry_limit) & (len(batches) > 0): + + bad_batches = [] + consecutive_bad_batches = 0 + print(f"Batch Size: {batch_size} | Number of Batches {len(batches)}") + for batch in tqdm(batches): + + if consecutive_bad_batches > consecutive_bad_batches_limit: + raise Exception(f"{consecutive_bad_batches_limit} consecutive bad batches. . . Terminating Process.") + try: + batch_tracks = self.sc.get_track_features(batch) + self.sdb.update_track_features(batch_tracks) + + # Successful, does not need collection + consecutive_bad_batches = 0 + + except: + print("Bad Batch, will try again after.") + bad_batches.append(batch) + consecutive_bad_batches += 1 + + bad_batch_retries += 1 + batches = bad_batches + + bad_batch_retries += 1 + + print("All Track batches collected!") + print("Track Collection Done! \n") + return True + + def filter_storm_tracks(self): + """ + Get a List of tracks to deliver. + """ + + print("Filtering artists.") + self.apply_artist_filters() + + print("Obtaining all albums from storm artists.") + self.run_record['storm_albums'] = self.sdb.get_albums_from_artists_by_date(self.run_record['storm_artists'], + self.run_record['start_date'], + self.run_date) + print("Getting tracks from albums.") + self.run_record['eligible_tracks'] = self.sdb.get_tracks_from_albums(self.run_record['storm_albums']) + + print("Filtering Tracks.") + self.apply_track_filters() + + print("Storm Tracks Generated! \n") + + def call_weatherboy(self): + """ + Run Modeling process + """ + return None + + def write_storm_tracks(self): + """ + Output the tracks in storm_tracks + """ + self.suc.write_playlist_tracks(self.config['full_storm_delivery']['playlist'], self.run_record['storm_tracks']) + + def save_run_record(self): + """ + Update Metadata and save run_record + """ + self.sdb.write_run_record(self.run_record) + + + # Low Level orchestration + def gen_dates(self): + """ + If there was a last run, do all tracks in between. Otherwise do a week since run + """ + + if self.last_run is not None: + if 'run_date' in self.last_run.keys(): + self.start_date = self.last_run['run_date'] + self.run_record['start_date'] = self.start_date + + if self.start_date is None: + self.start_date = (dt.datetime.now() - dt.timedelta(days=7)).strftime("%Y-%m-%d") + self.run_record['start_date'] = self.start_date + + def load_playlist(self, playlist_id): + """ + Pulls down playlist info and writes it back to db + """ + + # Determine if playlists need examining + if self.run_date > self.sdb.get_playlist_collection_date(playlist_id): + + # Acquire data + playlist_record = {'_id':playlist_id, + 'last_collected':self.run_date} + + playlist_record['info'] = self.sc.get_playlist_info(playlist_id) + playlist_record['tracks'] = self.sc.get_playlist_tracks(playlist_id) + playlist_record['artists'] = self.sc.get_artists_from_tracks(playlist_record['tracks']) + + print("Writing changes to DB") + self.sdb.update_playlist(playlist_record) + + else: + print("Skipping API Load, already collected today.") + + # Get the playlists tracks from DB + input_tracks = self.sdb.get_loaded_playlist_tracks(playlist_id) + input_artists = self.sdb.get_loaded_playlist_artists(playlist_id) + + # Update run record + self.run_record['playlists'].append(playlist_id) + self.run_record['input_tracks'].extend([x for x in input_tracks if x not in self.run_record['input_tracks']]) + self.run_record['input_artists'].extend([x for x in input_artists if x not in self.run_record['input_artists']]) + + def load_output_playlist(self, playlist_id): + """ + Pulls down playlist info and writes it back to db + """ + + # Determine if playlists need examining + if self.run_date > self.sdb.get_playlist_collection_date(playlist_id): + + # Acquire data + playlist_record = {'_id':playlist_id, + 'last_collected':self.run_date} + + playlist_record['info'] = self.sc.get_playlist_info(playlist_id) + playlist_record['tracks'] = self.sc.get_playlist_tracks(playlist_id) + if len(playlist_record['tracks']) > 0: + playlist_record['artists'] = self.sc.get_artists_from_tracks(playlist_record['tracks']) + + print("Writing changes to DB") + self.sdb.update_playlist(playlist_record) + else: + print("No tracks, must be new storm or something odd is happening.") + + else: + print("Skipping API Load, already collected today.") + + def load_artist_albums(self, artists): + """ + Get many artists information in batches and write back to database incrementally. + """ + batch_size = 20 + batches = np.array_split(artists, int(np.ceil(len(artists)/batch_size))) + + print(f"Batch Size: {batch_size} | Number of Batches {len(batches)}") + for batch in tqdm(batches): + + batch_albums = self.sc.get_artist_albums(batch) + self.sdb.update_albums(batch_albums) + self.sdb.update_artist_album_collected_date(batch) + + def collect_artist_albums(self): + """ + Get artist albums for input artists that need it. + """ + # Get a list of all artists in storm that need album collection + needs_collection = self.sdb.get_artists_for_album_collection(self.run_date) + to_collect = [x for x in self.run_record['input_artists'] if x in needs_collection] + + # Get their albums + if len(to_collect) == 0: + print("Evey Input Artist's Albums already acquired today.") + else: + print(f"New albums to collect for {len(to_collect)} artists.") + print("Collecting data in batches from API and Updating DB.") + self.load_artist_albums(to_collect) + + print("Updating artist album association in DB.") + self.sdb.update_artist_albums() + + def collect_album_tracks(self): + """ + Gets tracks for every album that needs them, not just storm. + In the case of new storms this helps populate historical. + In the case of existing ones it will only be the storm albums that need collection. + Given the intensity, try except implemented to retry bad batches + """ + needs_collection = self.sdb.get_albums_for_track_collection() + batch_size = 20 + if len(needs_collection) == 0: + print("No Albums needed to collect.") + return True + + batches = np.array_split(needs_collection, int(np.ceil(len(needs_collection)/batch_size))) + + # Attempt to go get the batches + bad_batch_retries = 0 + consecutive_bad_batches_limit = 10 + retry_limit = 5 + while (bad_batch_retries < retry_limit) & (len(batches) > 0): + + bad_batches = [] + consecutive_bad_batches = 0 + print(f"Batch Size: {batch_size} | Number of Batches {len(batches)}") + for batch in tqdm(batches): + + if consecutive_bad_batches > consecutive_bad_batches_limit: + raise Exception(f"{consecutive_bad_batches_limit} consecutive bad batches. . . Terminating Process.") + try: + batch_tracks = self.sc.get_album_tracks(batch) + self.sdb.update_tracks(batch_tracks) + + # Successful, does not need collection + consecutive_bad_batches = 0 + + except: + print("Bad Batch, will try again after.") + bad_batches.append(batch) + consecutive_bad_batches += 1 + + bad_batch_retries += 1 + batches = bad_batches + + print("All album batches collected!") + return True + + def apply_artist_filters(self): + """ + read in filters from configurations + """ + filters = self.config['filters']['artist'] + supported = ['genre', 'blacklist'] + bad_artists = [] + + # Filters + print(f"{len(filters)} valid filters to apply") + for filter_name, filter_value in filters.items(): + + print(f"Attemping filter {filter_name} - {filter_value}") + if filter_name == 'genre': + # Add all known artists in sdb of a genre to remove in tracks later + genre_artists = self.sdb.get_artists_by_genres(filter_value) + bad_artists.extend(genre_artists) + + elif filter_name == 'blacklist': + blacklist = self.sdb.get_blacklist(filter_value) + if len(blacklist) == 0: + print(f"{filter_value} not found, no filtering will be done.'") + else: + print(f"{filter_value} found!'") + if 'input_playlist' in blacklist[0].keys(): + print("Updating Blacklist . . .") + self.update_blacklist_from_playlist(blacklist[0]['_id'], blacklist[0]['input_playlist']) + + # Reload + blacklist = self.sdb.get_blacklist(filter_value) + bad_artists.extend(blacklist[0]['blacklist']) + else: + print(f"{filter_name} not supported or misspelled. ") + + self.run_record['storm_artists'] = [x for x in self.run_record['input_artists'] if x not in bad_artists] + self.run_record['removed_artists'] = bad_artists + print(f"Starting Artist Amount: {len(self.run_record['input_artists'])}") + print(f"Ending Artist Amount: {len(self.run_record['storm_artists'])}") + + def update_blacklist_from_playlist(self, blacklist_name, playlist_id): + """ + Updates a blacklist from a playlist (reads the artists) + """ + bl_tracks = self.sc.get_playlist_tracks(playlist_id) + bl_artists = self.sc.get_artists_from_tracks(bl_tracks) + self.sdb.update_blacklist(blacklist_name, bl_artists) + + def apply_track_filters(self): + """ + read in filters from configurations + """ + filters = self.config['filters']['track'] + supported = ['audio_features', 'artist_filter'] + bad_tracks = [] + + # Filters + print(f"{len(filters)} valid filters to apply") + for filter_name, filter_value in filters.items(): + + print(f"Attemping filter {filter_name} - {filter_value}") + if filter_name == 'audio_features': + for feature, feature_value in filter_value.items(): + op = f"${feature_value.split('&&')[0]}" + val = float(feature_value.split('&&')[1]) + print(f"Removing tracks with {feature} - {op}:{val}") + valid = self.sdb.filter_tracks_by_audio_feature(self.run_record['eligible_tracks'], {feature:{op:val}}) + bad_tracks.extend([x for x in self.run_record['eligible_tracks'] if x not in valid]) + print(f"Cumulative Bad Tracks found {len(np.unique(bad_tracks))}") + + + elif filter_name == "artist_filter": + if filter_value == 'hard': + # Limits output to tracks that contain only storm artists + for track in tqdm(self.run_record['eligible_tracks']): + + track_artists = set(self.sdb.get_track_artists(track)) + if not track_artists.issubset(set(self.run_record['storm_artists'])): + bad_tracks.append(track) + + elif filter_value == 'soft': + # Removes tracks that contain known filtered out artists + # Other 'bad' artists could sneak in if not tracked by storm + for track in tqdm(self.run_record['eligible_tracks']): + track_artists = set(self.sdb.get_track_artists(track)) + if not set(self.run_record['removed_artists']).isdisjoint(track_artists): + bad_tracks.append(track) + + else: + print(f"{filter_name} not supported or misspelled. ") + + bad_tracks = np.unique(bad_tracks).tolist() + print("Removing bad tracks . . .") + self.run_record['storm_tracks'] = [x for x in self.run_record['eligible_tracks'] if x not in bad_tracks] + self.run_record['removed_tracks'] = bad_tracks + print(f"Starting Track Amount: {len(self.run_record['eligible_tracks'])}") + print(f"Ending Track Amount: {len(self.run_record['storm_tracks'])}") diff --git a/src/storm.py b/src/storm.py new file mode 100644 index 0000000..5b69adc --- /dev/null +++ b/src/storm.py @@ -0,0 +1,44 @@ +import spotipy +from spotipy import util +from spotipy import oauth2 +import numpy as np +import pandas as pd +from tqdm import tqdm +import os +import datetime as dt +import time +import json + +# DB +from pymongo import MongoClient + +# ENV +from dotenv import load_dotenv +load_dotenv() + +# INTERNAL +from .db import * +from .storm_client import * +from .runner import * + +class Storm: + """ + Main callable that initiates and saves storm data + """ + def __init__(self, storm_names, start_date=None): + + self.print_initial_screen() + self.storm_names = storm_names + + def print_initial_screen(self): + + print("A Storm is Brewing. . .\n") + time.sleep(.5) + + def Run(self): + + print("Spinning up Storm Runners. . . ") + for storm_name in self.storm_names: + StormRunner(storm_name).Run() + +Storm(['film_vg_instrumental', 'contemporary_lyrical']).Run() \ No newline at end of file From db64fa45df070d8d43569a01115cc8858855544e Mon Sep 17 00:00:00 2001 From: ATawzer <34928044+ATawzer@users.noreply.github.com> Date: Mon, 26 Apr 2021 13:45:59 -0600 Subject: [PATCH 23/29] tightened release date --- src/db.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/db.py b/src/db.py index 5f96d53..deb45d3 100644 --- a/src/db.py +++ b/src/db.py @@ -233,7 +233,7 @@ def get_albums_by_release_date(self, start_date, end_date): """ Get all albums in date window """ - q = {"release_date":{"$gte": start_date, "$lte": end_date}} + q = {"release_date":{"$gt": start_date, "$lte": end_date}} cols = {"_id":1} r = list(sdb.albums.find(q, cols)) From 6cf47e86c80abe5ea5307bcde5f553aff78449bb Mon Sep 17 00:00:00 2001 From: ATawzer <34928044+ATawzer@users.noreply.github.com> Date: Mon, 26 Apr 2021 14:19:15 -0600 Subject: [PATCH 24/29] tweaked last run --- src/runner.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/runner.py b/src/runner.py index 1735696..bc58586 100644 --- a/src/runner.py +++ b/src/runner.py @@ -96,7 +96,7 @@ def load_last_run(self): else: print("Appending last runs tracks and artists.") self.run_record['input_tracks'].extend(self.last_run['input_tracks']) - self.run_record['input_artists'].extend(self.last_run['input_artists']) + self.run_record['input_artists'].extend(self.last_run['storm_artists']) # Post-filter def collect_playlist_info(self): """ From 882c1bf7fef1a0071c4ea7f62c39f63799006042 Mon Sep 17 00:00:00 2001 From: ATawzer <34928044+ATawzer@users.noreply.github.com> Date: Mon, 26 Apr 2021 14:46:23 -0600 Subject: [PATCH 25/29] cleaned run_storm back up --- run_storm.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/run_storm.py b/run_storm.py index 0dd4ba3..e4f662f 100644 --- a/run_storm.py +++ b/run_storm.py @@ -7,8 +7,4 @@ from dotenv import load_dotenv load_dotenv() - -Storm(['contemporary_lyrical']).Run() - - -test = StormDB().get_last_run('film_vg_instrumental') \ No newline at end of file +Storm(['film_vg_instrumental', 'contemporary_lyrical']).Run() From 7a73238d5169fa072d87000b8e4d3bbc36b44f51 Mon Sep 17 00:00:00 2001 From: ATawzer <34928044+ATawzer@users.noreply.github.com> Date: Tue, 27 Apr 2021 11:57:30 -0600 Subject: [PATCH 26/29] cleanup and SADB started --- src/db.py | 12 ++++++++++++ src/weatherboy.py | 2 ++ 2 files changed, 14 insertions(+) diff --git a/src/db.py b/src/db.py index deb45d3..1b8c27c 100644 --- a/src/db.py +++ b/src/db.py @@ -390,5 +390,17 @@ def update_artist_albums(self): self.artists.update_one({"_id":artist}, {"$addToSet":{"albums":album["_id"]}}, upsert=True) self.albums.update_one({"_id":album["_id"]}, {"$set":{"added_to_artists":True}}) +class StormAnalyticsDB: + """ + A StormDB wrapper dedicated to machine learning and general database analytics + """ + + def __init__(self): + self.sdb = StormDB() + #self.sql_db + + def gen_playlist_health(self, playlist_id): + + \ No newline at end of file diff --git a/src/weatherboy.py b/src/weatherboy.py index 6507f38..4452d2a 100644 --- a/src/weatherboy.py +++ b/src/weatherboy.py @@ -1,5 +1,7 @@ # Modeling + + class WeatherBoy: def __init__(self, tracks): From 278d7e85b744f359e4544e6d0b2076b0eb4b3a0d Mon Sep 17 00:00:00 2001 From: ATawzer <34928044+ATawzer@users.noreply.github.com> Date: Tue, 27 Apr 2021 11:57:44 -0600 Subject: [PATCH 27/29] deletes --- .cache-1241528689 | 2 +- Storm.ipynb | 410 --------------------------------------------- run_storm.py | 10 -- run_storm_shell.sh | 2 - 4 files changed, 1 insertion(+), 423 deletions(-) delete mode 100644 Storm.ipynb delete mode 100644 run_storm.py delete mode 100644 run_storm_shell.sh diff --git a/.cache-1241528689 b/.cache-1241528689 index 971bee0..b72c30b 100644 --- a/.cache-1241528689 +++ b/.cache-1241528689 @@ -1 +1 @@ -{"access_token": "BQCVicHxzaFXtqJnCtg5Hfp8hphi6PzxL6Y-v5-V3OzKo6fdNMbbKhck8nvQD0gCN6tct4YqIVSm_nZBC_D2LrxdHBB2uuMnfRC-3KpCHZw8oy5Pa-0MdrazOgephUeDKYi9yAKrNAJ1vxXGePxDNDLQOYCOWqq_sIQxmOiZLze0RL-7GBLUJN786T6IDapKpwspHiumF_RRh6CC5ruf9Ks", "token_type": "Bearer", "expires_in": 3600, "scope": "playlist-modify-private playlist-modify-public", "expires_at": 1619213323, "refresh_token": "AQAsxkWjXR0Iw8q65vbKmXUR0cOGEM8liRshm9vhsJbDenCcjijwBgyKF91oCqQ8NjdD8fwk3uO-NKGUVWYtWRF0E2f5ydGSyFlJRi29TR1Zyw71OKdaIs89XzUBfCOOO0M"} \ No newline at end of file +{"access_token": "BQDEW_X1QUACQIUptws4nQkpzMw_9xGpqPDoWtE2JLfMMjuXC_aS8cG_v9igpKNN5Wl37IQOk0Fe0LjK4g-GPATYPacGQKlO19jbOaS4Ey9heYvHaBJnNx92kwsnhf0WjqitLNrStbI9ITLYBPpumdf0hanX2O3i6A1HczgzaNZ4Qx6mc80YsOCukJo41tmyH0u1_FxhtLyTCt42Bm3eQRA", "token_type": "Bearer", "expires_in": 3600, "scope": "playlist-modify-private playlist-modify-public", "expires_at": 1619468878, "refresh_token": "AQAsxkWjXR0Iw8q65vbKmXUR0cOGEM8liRshm9vhsJbDenCcjijwBgyKF91oCqQ8NjdD8fwk3uO-NKGUVWYtWRF0E2f5ydGSyFlJRi29TR1Zyw71OKdaIs89XzUBfCOOO0M"} \ No newline at end of file diff --git a/Storm.ipynb b/Storm.ipynb deleted file mode 100644 index 7737a7f..0000000 --- a/Storm.ipynb +++ /dev/null @@ -1,410 +0,0 @@ -{ - "cells": [ - { - "cell_type": "code", - "execution_count": 1, - "metadata": { - "ExecuteTime": { - "end_time": "2020-05-06T17:45:33.961856Z", - "start_time": "2020-05-06T17:45:33.958404Z" - }, - "code_folding": [] - }, - "outputs": [], - "source": [ - "# Imports\n", - "from src.utils import Storm\n", - "import numpy as np\n", - "import pandas as pd\n", - "#import matplotlib.pyplot as plt\n", - "import datetime as dt\n", - "import time" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Storm Run" - ] - }, - { - "cell_type": "code", - "execution_count": 3, - "metadata": { - "ExecuteTime": { - "end_time": "2020-05-06T17:56:06.578188Z", - "start_time": "2020-05-06T17:49:10.645686Z" - }, - "code_folding": [] - }, - "outputs": [], - "source": [ - "# Shared Variables and Functions\n", - "user = '1241528689'\n", - "\n", - "# Playlist Inputs\n", - "output_playlist = {'daily':'7fnvajjUoWBQDo8iFNMH3s',\n", - " 'archive':'1Q8WS7Xj51WCHZctXGDsrp'}\n", - "\n", - "# Inputs\n", - "inputs = {'Much Needed':'7N3pwZE1N38wcdiuLxiPvq',\n", - " 'Room on the Boat':'1SZS16UcW0XOzgh6UWXA9S',\n", - " 'Refuge':'3K9no6AflSDYiiMzignAm7',\n", - " 'Safety':'0R1gw1JbcOFD0r8IzrbtYP',\n", - " 'Shelter from the Storm':'2yueH0i9C2daBRawYIc9P8',\n", - " 'Soundtracked':'37i9dQZF1DWW7gj0FcGEx6',\n", - " 'Soundtrack for Study':'0hZNf3tcMT4x03FyjKYJ3M',\n", - " 'Film Music - Movie Scores':'5GhatXsZVNYxrhqEAfZPLR',\n", - " 'Video Game Soundtracks':'3Iwd2RiXCzmm1AMUpRAaHO',\n", - " 'Video Game Music Unofficial':'3aI7ztMmDhMHhYe1KOPFLG'}" - ] - }, - { - "cell_type": "code", - "execution_count": 2, - "metadata": {}, - "outputs": [ - { - "output_type": "stream", - "name": "stdout", - "text": [ - "Generating Token and Authenticating. . .\n", - "Authentication Complete.\n", - "\n", - "Reading in existing Data.\n", - "Storm Arists Found! Reading in now.\n", - "Done! 346 Unique Artists found.\n", - "\n", - "\n", - "Previously Discovered Albums Found! Reading in now.\n", - "Done! 29198 Albums found.\n", - "\n", - "Augmenting new Artists from playlist input dictionary.\n", - "Obtaining a list of Tracks from Playlist . . .TIAPTP Archive\n", - "100%|██████████| 346/346 [00:00<00:00, 696367.17it/s]\n", - " 0%| | 0/346 [00:00", - "image/svg+xml": "\n\n\n\n \n \n \n \n 2021-03-30T11:44:52.459310\n image/svg+xml\n \n \n Matplotlib v3.3.4, https://matplotlib.org/\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n\n", - "image/png": "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\n" - }, - "metadata": { - "needs_background": "light" - } - } - ], - "source": [ - "fig, ax = plt.subplots(2, 2, figsize = (15, 10));\n", - "df.artists_tracked.plot(ax=ax[0][0]).set_title(\"Artists Tracked\");\n", - "df.blacklisted_artists.plot(ax=ax[1][0]).set_title(\"Blacklisted Artists\");\n", - "df.albums_augmented.plot(ax=ax[0][1]).set_title(\"Albums Augmented\");\n", - "df.albums_tracked.plot(ax=ax[1][1]).set_title(\"Albums Tracked\");" - ] - }, - { - "cell_type": "code", - "execution_count": 7, - "metadata": { - "scrolled": false - }, - "outputs": [ - { - "output_type": "display_data", - "data": { - "text/plain": "
", - "image/svg+xml": "\n\n\n\n \n \n \n \n 2021-03-30T11:44:52.981546\n image/svg+xml\n \n \n Matplotlib v3.3.4, https://matplotlib.org/\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n\n", - "image/png": "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l+/ptddeY+zYsYwdO5Zx48ZRVlbGvHnzGtX5zne+w3333QfAoEGD+MlPfsKUKVMoLCzkvffeY9q0aRx00EHcddddu/8QRURERKTr+uRFuHMKvPRzGHQkXLUAvvxHJX97qev1AO6mp25/ueWWW1iyZAmLFy9m3rx5nHrqqSxZsoTBgwcDcO+995Kbm0tVVRUTJ07k7LPPJhaL8Y1vfIPXX3+dwYMHU1zceA6cO+64gxdffJHZs2eTkpLS7OteccUV3HXXXQwdOpQFCxbw7W9/m1deeaXZc6urq/nmN79Z/3rnn9/c7Z8Nbr31Vv785z8zdepUysvLSU1N3e3n0L9/f+bPn8+1117LpZdeyptvvkl1dTUjR47kW9/61m7ri4iIiEgXs20VvHADrHwR8obChU/A0BMSHVWH1fUSwHZi0qRJ9ckfwO23386TTz4JwPr161m5ciVbt27lqKOOqj8vNze3/vwHH3yQgoICZs+eTSgUavY1ysvLeeuttzj33IY5dmpqanYZ0/LlyxkyZEj9651//vmNeiObmjp1Kt///ve58MILOeussygoKNjluXVOP/10AEaPHk15eTndunWjW7dupKamUlpaSnZ29m6vISIiIiJdQE2ZN9Rz/p2QlAon/RomfROSkhMdWYemBDBBMjIy6rfnzZvHSy+9xPz580lPT+eYY46huroa5xz+Yza+YNSoUSxevJiioqJGiWS8WCxGdnY2ixcv3qOYnHN79R6uv/56Tj31VJ577jkmT57MSy+9RFJSErFYrP6c6urqRnXqeioDgUCjXstAIEAkEtmr1xcRERGRTigWgw8f9YZ6lm+GsRfB8TfpIe5tRPcAHiDdunWjrKys2WM7duwgJyeH9PR0li9fzttvvw3AlClTeO2111i7di1AoyGg48aN469//Sunn346GzZsaPa6WVlZDB48mH/961+Al+B98MEHu4xx2LBhrFmzhnXr1gHw6KOPtvieVq9ezejRo7nuuusoLCxk+fLlDBw4kGXLllFTU8OOHTt4+eWXW7yGiIiIiEi9zxfBPSfC7G9B9/7w9VfgjD8r+WtD6gE8QPLy8pg6dSqjRo0iLS2NXr0aGvH06dO56667OOywwzj00EOZPHkyAPn5+cyaNYuzzjqLWCxGz549mTt3bn29I444gltvvZVTTz2VuXPn0qNHjy+87kMPPcSV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\n" - }, - "metadata": { - "needs_background": "light" - } - } - ], - "source": [ - "fig, ax = plt.subplots(2, 1, figsize=(15, 10));\n", - "df[['tracks_added', 'tracks_eligible']].plot(ax=ax[0]).set_title('Tracks Added by Day');\n", - "df[['track_added_sum', 'track_elig_sum']].plot(ax=ax[1]).set_title('Tracks Added Cumulatively');" - ] - } - ], - "metadata": { - "kernelspec": { - "name": "python394jvsc74a57bd0c0c0f186f792db3a37ba7c51f0ce49c4b45c8511f10270060f342a8364fd0546", - "display_name": "Python 3.9.4 64-bit ('Storm': pipenv)" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.9.4-final" - } - }, - "nbformat": 4, - "nbformat_minor": 2 -} \ No newline at end of file diff --git a/run_storm.py b/run_storm.py deleted file mode 100644 index e4f662f..0000000 --- a/run_storm.py +++ /dev/null @@ -1,10 +0,0 @@ -# Internal -from src.helper import * -from src.storm_client import Storm -print = slow_print # for fun - -# ENV -from dotenv import load_dotenv -load_dotenv() - -Storm(['film_vg_instrumental', 'contemporary_lyrical']).Run() diff --git a/run_storm_shell.sh b/run_storm_shell.sh deleted file mode 100644 index daaa4ec..0000000 --- a/run_storm_shell.sh +++ /dev/null @@ -1,2 +0,0 @@ -pipenv shell -python run_storm.py \ No newline at end of file From e286d416c8f012d846edd882a32252c159de72f2 Mon Sep 17 00:00:00 2001 From: ATawzer <34928044+ATawzer@users.noreply.github.com> Date: Tue, 27 Apr 2021 12:57:58 -0600 Subject: [PATCH 28/29] working on playlist views --- src/db.py | 111 +++++++++- src/storm_client.py | 498 -------------------------------------------- 2 files changed, 107 insertions(+), 502 deletions(-) diff --git a/src/db.py b/src/db.py index 1b8c27c..b9cee07 100644 --- a/src/db.py +++ b/src/db.py @@ -1,6 +1,10 @@ import os +from sys import getsizeof import json from pymongo import MongoClient +import pandas as pd +import numpy as np +from timeit import default_timer as timer from dotenv import load_dotenv load_dotenv() @@ -71,6 +75,48 @@ def write_run_record(self, run_record): self.runs.insert_one(run_record) # Playlist + def get_playlists(self, name=False): + """ + Returns all playlist ids in stormdb as a list, or as their names if you'd rather + """ + q = {} + cols = {"_id":1, "info":1} + r = list(self.playlists.find(q, cols)) + + if name: + return [x["info"]["name"] for x in r] + else: + return [x["_id"] for x in r] + + def get_playlist_current_info(self, playlist_id): + """ + Returns a playlists full record excluding changelog + """ + q = {"_id":playlist_id} + cols = {"changelog":0} + r = list(self.playlists.find(q, cols)) + + if len(r) == 0: + raise Exception(f"{playlist_id} not found.") + else: + return r[0] + + def get_playlist_changelog(self, playlist_id): + """ + Returns a playlists changelog, a dictionary where each entry is a date. + """ + q = {"_id":playlist_id} + cols = {"changelog":1} + r = list(self.playlists.find(q, cols)) + + if len(r) == 0: + raise Exception(f"{playlist_id} not found.") + else: + if 'changelog' in r[0].keys(): + return r[0]['changelog'] + else: + raise Exception(f"No changelog found for {playlist_id}, has it been collected more than once?") + def get_playlist_collection_date(self, playlist_id): """ Gets a playlists last collection date. @@ -392,15 +438,72 @@ def update_artist_albums(self): class StormAnalyticsDB: """ - A StormDB wrapper dedicated to machine learning and general database analytics + A StormDB wrapper dedicated to machine learning and general database analytics. + Most data will get converted into plot friendly functions, like pandas dataframes. """ - def __init__(self): + def __init__(self, verbose=True): self.sdb = StormDB() #self.sql_db - def gen_playlist_health(self, playlist_id): + self.map = {'playlist_track_changes':self.gen_v_playlist_track_changes, + 'many_playlist_track_changes':self.gen_v_many_playlist_track_changes} + self.print = print if verbose else lambda x: None + + # Get views from StormDB + def gen_view(self, name, view_params={}): + """ + Caller function for views (prints and other nice additions) + """ + if name in self.map.keys(): + self.print(f"Generating View: {name}") + self.print(f"Supplied Parameters: {view_params}") + + start = timer() + r = self.map[name](**view_params) + end = timer() + + self.print("View Complete!") + self.print(f"Elapsed Time to Build: {round(end-start, 4)} ms. | File Size: {getsizeof(r)} bytes") + + return r + + else: + raise Exception(f"View {name} not in map.") + + def gen_v_many_playlist_track_changes(self, playlist_ids=[], metric='Number of Tracks'): + """ + Cross-Compares many playlist track changes + """ + df = pd.DataFrame() + + if len(playlist_ids) == 0: + self.print("No playlists specified, returning all.") + + + #for playlist_id in playlist_ids: + + + + # Single object views - low-level + def gen_v_playlist_track_changes(self, playlist_id): + """ + Generates a view of a playlists timely health + """ + + #playlist_info = self.sdb.get_playlist_current_info() + playlist_changelog = self.sdb.get_playlist_changelog(playlist_id) + + # Create Dataframe + df = pd.DataFrame(index=list(playlist_changelog.keys())) + + # Compute Metrics + for change in playlist_changelog: + df.loc[change, 'Number of tracks'] = len(playlist_changelog[change]['tracks']) + + return df + + - \ No newline at end of file diff --git a/src/storm_client.py b/src/storm_client.py index 87da40b..3a56e51 100644 --- a/src/storm_client.py +++ b/src/storm_client.py @@ -256,501 +256,3 @@ def get_track_features(self, tracks): # Filter to just ids return result -class StormRunner: - """ - Orchestrates a storm run - """ - def __init__(self, storm_name, start_date=None): - - print(f"Initializing Runner for {storm_name}") - self.sdb = StormDB() - self.config = self.sdb.get_config(storm_name) - self.sc = StormClient(self.config['user_id']) - self.suc = StormUserClient(self.config['user_id']) - self.name = storm_name - self.start_date = start_date - - # metadata - self.run_date = dt.datetime.now().strftime('%Y-%m-%d') - self.run_record = {'config':self.config, - 'storm_name':self.name, - 'run_date':self.run_date, - 'start_date':self.start_date, - 'playlists':[], - 'input_tracks':[], # Determines what gets collected - 'input_artists':[], # Determines what gets collected, also 'egligible' artists - 'eligible_tracks':[], # Tracks that could be delivered before track filters - 'storm_tracks':[], # Tracks actually written out - 'storm_artists':[], # Used for track filtering - 'storm_albums':[], # Release Date Filter - 'storm_sample_tracks':[], # subset of storm tracks delivered to sample - 'removed_artists':[] # Artists filtered out - } - self.last_run = self.sdb.get_last_run(self.name) - self.gen_dates() - - print(f"{self.name} Started Successfully!\n") - #self.Run() - - def Run(self): - """ - Storm Orchestration based on a configuration. - """ - - print(f"{self.name} - Step 0 / 8 - Initializing using last run.") - self.load_last_run() - - print(f"{self.name} - Step 1 / 8 - Collecting Playlist Tracks and Artists. . .") - self.collect_playlist_info() - - print(f"{self.name} - Step 2 / 8 - Collecting Artist info. . .") - self.collect_artist_info() - - print(f"{self.name} - Step 3 / 8 - Collecting Albums and their Tracks. . .") - self.collect_album_info() - - print(f"{self.name} - Step 4 / 8 - Collecting Track Features . . .") - self.collect_track_features() - - print(f"{self.name} - Step 5 / 8 - Filtering Track List . . .") - self.filter_storm_tracks() - - print(f"{self.name} - Step 6 / 8 - Handing off to Weatherboy . . . ") - self.call_weatherboy() - - print(f"{self.name} - Step 7 / 8 - Writing to Spotify . . .") - self.write_storm_tracks() - - print(f"{self.name} - Step 8 / 8 - Saving Storm Run . . .") - self.save_run_record() - - print(f"{self.name} - Complete!\n") - - # Object Based orchestration - def load_last_run(self): - """ - Loads in relevant information from last run. - """ - - if self.last_run is None: - print("Storm is new, nothing to load") - - else: - print("Appending last runs tracks and artists.") - self.run_record['input_tracks'].extend(self.last_run['input_tracks']) - self.run_record['input_artists'].extend(self.last_run['input_artists']) - - def collect_playlist_info(self): - """ - Initial Playlist setup orchestration - """ - - print("Loading Great Targets . . .") - self.load_playlist(self.config['great_targets']) - - print("Loading Good Targets . . .") - self.load_playlist(self.config['good_targets']) - - # Check for additional playlists - if 'additional_input_playlists' in self.config.keys(): - if self.config['additional_input_playlists']['is_active']: - for ap, ap_id in self.config['additional_input_playlists']['playlists'].items(): - print(f"Loading Additional Playlist: {ap}") - self.load_playlist(ap_id) - - # Check what songs remain in sample and full delivery - self.load_output_playlist(self.config['full_storm_delivery']['playlist']) - - ## ---- Future Version ---- - self.load_output_playlist(self.config['rolling_good']['playlist']) - # Check if we need to move rolling - - print("Playlists Prepared. \n") - - def collect_artist_info(self): - """ - Loads in the data from the run_records artists - """ - - # get data for artists we don't know - known_artists = self.sdb.get_known_artist_ids() - new_artists = [x for x in self.run_record['input_artists'] if x not in known_artists] - - if len(new_artists) > 0: - print(f"{len(new_artists)} New Artists Found! Getting their info now.") - new_artist_info = self.sc.get_artist_info(new_artists) - - print("Writing their info to DB . . .") - self.sdb.update_artists(new_artist_info) - - else: - print("No new Artists found.") - - print("Artist Info Collection Done.\n") - - def collect_album_info(self): - """ - Get and update all albums associated with the artists - """ - - print("Getting the albums for Input Artists that haven't been acquired.") - self.collect_artist_albums() - - print("Getting tracks for albums that need it") - self.collect_album_tracks() - - print("Album Collection Done. \n") - - def collect_track_features(self): - """ - Gets all track features needed - Also in a while try except loop to get through all tracks in the case of bad batches. - """ - - to_collect = self.sdb.get_tracks_for_feature_collection() - if len(to_collect) == 0: - print("No Track Features to collect.") - return True - - batch_size = 1000 - batches = np.array_split(to_collect, int(np.ceil(len(to_collect)/batch_size))) - - # Attempt to go get the batches - bad_batch_retries = 0 - consecutive_bad_batches_limit = 10 - retry_limit = 5 - while (bad_batch_retries < retry_limit) & (len(batches) > 0): - - bad_batches = [] - consecutive_bad_batches = 0 - print(f"Batch Size: {batch_size} | Number of Batches {len(batches)}") - for batch in tqdm(batches): - - if consecutive_bad_batches > consecutive_bad_batches_limit: - raise Exception(f"{consecutive_bad_batches_limit} consecutive bad batches. . . Terminating Process.") - try: - batch_tracks = self.sc.get_track_features(batch) - self.sdb.update_track_features(batch_tracks) - - # Successful, does not need collection - consecutive_bad_batches = 0 - - except: - print("Bad Batch, will try again after.") - bad_batches.append(batch) - consecutive_bad_batches += 1 - - bad_batch_retries += 1 - batches = bad_batches - - bad_batch_retries += 1 - - print("All Track batches collected!") - print("Track Collection Done! \n") - return True - - def filter_storm_tracks(self): - """ - Get a List of tracks to deliver. - """ - - print("Filtering artists.") - self.apply_artist_filters() - - print("Obtaining all albums from storm artists.") - self.run_record['storm_albums'] = self.sdb.get_albums_from_artists_by_date(self.run_record['storm_artists'], - self.run_record['start_date'], - self.run_date) - print("Getting tracks from albums.") - self.run_record['eligible_tracks'] = self.sdb.get_tracks_from_albums(self.run_record['storm_albums']) - - print("Filtering Tracks.") - self.apply_track_filters() - - print("Storm Tracks Generated! \n") - - def call_weatherboy(self): - """ - Run Modeling process - """ - return None - - def write_storm_tracks(self): - """ - Output the tracks in storm_tracks - """ - self.suc.write_playlist_tracks(self.config['full_storm_delivery']['playlist'], self.run_record['storm_tracks']) - - def save_run_record(self): - """ - Update Metadata and save run_record - """ - self.sdb.write_run_record(self.run_record) - - - # Low Level orchestration - def gen_dates(self): - """ - If there was a last run, do all tracks in between. Otherwise do a week since run - """ - - if self.last_run is not None: - if 'run_date' in self.last_run.keys(): - self.start_date = self.last_run['run_date'] - self.run_record['start_date'] = self.start_date - - if self.start_date is None: - self.start_date = (dt.datetime.now() - dt.timedelta(days=7)).strftime("%Y-%m-%d") - self.run_record['start_date'] = self.start_date - - def load_playlist(self, playlist_id): - """ - Pulls down playlist info and writes it back to db - """ - - # Determine if playlists need examining - if self.run_date > self.sdb.get_playlist_collection_date(playlist_id): - - # Acquire data - playlist_record = {'_id':playlist_id, - 'last_collected':self.run_date} - - playlist_record['info'] = self.sc.get_playlist_info(playlist_id) - playlist_record['tracks'] = self.sc.get_playlist_tracks(playlist_id) - playlist_record['artists'] = self.sc.get_artists_from_tracks(playlist_record['tracks']) - - print("Writing changes to DB") - self.sdb.update_playlist(playlist_record) - - else: - print("Skipping API Load, already collected today.") - - # Get the playlists tracks from DB - input_tracks = self.sdb.get_loaded_playlist_tracks(playlist_id) - input_artists = self.sdb.get_loaded_playlist_artists(playlist_id) - - # Update run record - self.run_record['playlists'].append(playlist_id) - self.run_record['input_tracks'].extend([x for x in input_tracks if x not in self.run_record['input_tracks']]) - self.run_record['input_artists'].extend([x for x in input_artists if x not in self.run_record['input_artists']]) - - def load_output_playlist(self, playlist_id): - """ - Pulls down playlist info and writes it back to db - """ - - # Determine if playlists need examining - if self.run_date > self.sdb.get_playlist_collection_date(playlist_id): - - # Acquire data - playlist_record = {'_id':playlist_id, - 'last_collected':self.run_date} - - playlist_record['info'] = self.sc.get_playlist_info(playlist_id) - playlist_record['tracks'] = self.sc.get_playlist_tracks(playlist_id) - if len(playlist_record['tracks']) > 0: - playlist_record['artists'] = self.sc.get_artists_from_tracks(playlist_record['tracks']) - - print("Writing changes to DB") - self.sdb.update_playlist(playlist_record) - else: - print("No tracks, must be new storm or something odd is happening.") - - else: - print("Skipping API Load, already collected today.") - - def load_artist_albums(self, artists): - """ - Get many artists information in batches and write back to database incrementally. - """ - batch_size = 20 - batches = np.array_split(artists, int(np.ceil(len(artists)/batch_size))) - - print(f"Batch Size: {batch_size} | Number of Batches {len(batches)}") - for batch in tqdm(batches): - - batch_albums = self.sc.get_artist_albums(batch) - self.sdb.update_albums(batch_albums) - self.sdb.update_artist_album_collected_date(batch) - - def collect_artist_albums(self): - """ - Get artist albums for input artists that need it. - """ - # Get a list of all artists in storm that need album collection - needs_collection = self.sdb.get_artists_for_album_collection(self.run_date) - to_collect = [x for x in self.run_record['input_artists'] if x in needs_collection] - - # Get their albums - if len(to_collect) == 0: - print("Evey Input Artist's Albums already acquired today.") - else: - print(f"New albums to collect for {len(to_collect)} artists.") - print("Collecting data in batches from API and Updating DB.") - self.load_artist_albums(to_collect) - - print("Updating artist album association in DB.") - self.sdb.update_artist_albums() - - def collect_album_tracks(self): - """ - Gets tracks for every album that needs them, not just storm. - In the case of new storms this helps populate historical. - In the case of existing ones it will only be the storm albums that need collection. - Given the intensity, try except implemented to retry bad batches - """ - needs_collection = self.sdb.get_albums_for_track_collection() - batch_size = 20 - if len(needs_collection) == 0: - print("No Albums needed to collect.") - return True - - batches = np.array_split(needs_collection, int(np.ceil(len(needs_collection)/batch_size))) - - # Attempt to go get the batches - bad_batch_retries = 0 - consecutive_bad_batches_limit = 10 - retry_limit = 5 - while (bad_batch_retries < retry_limit) & (len(batches) > 0): - - bad_batches = [] - consecutive_bad_batches = 0 - print(f"Batch Size: {batch_size} | Number of Batches {len(batches)}") - for batch in tqdm(batches): - - if consecutive_bad_batches > consecutive_bad_batches_limit: - raise Exception(f"{consecutive_bad_batches_limit} consecutive bad batches. . . Terminating Process.") - try: - batch_tracks = self.sc.get_album_tracks(batch) - self.sdb.update_tracks(batch_tracks) - - # Successful, does not need collection - consecutive_bad_batches = 0 - - except: - print("Bad Batch, will try again after.") - bad_batches.append(batch) - consecutive_bad_batches += 1 - - bad_batch_retries += 1 - batches = bad_batches - - print("All album batches collected!") - return True - - def apply_artist_filters(self): - """ - read in filters from configurations - """ - filters = self.config['filters']['artist'] - supported = ['genre', 'blacklist'] - bad_artists = [] - - # Filters - print(f"{len(filters)} valid filters to apply") - for filter_name, filter_value in filters.items(): - - print(f"Attemping filter {filter_name} - {filter_value}") - if filter_name == 'genre': - # Add all known artists in sdb of a genre to remove in tracks later - genre_artists = self.sdb.get_artists_by_genres(filter_value) - bad_artists.extend(genre_artists) - - elif filter_name == 'blacklist': - blacklist = self.sdb.get_blacklist(filter_value) - if len(blacklist) == 0: - print(f"{filter_value} not found, no filtering will be done.'") - else: - print(f"{filter_value} found!'") - if 'input_playlist' in blacklist[0].keys(): - print("Updating Blacklist . . .") - self.update_blacklist_from_playlist(blacklist[0]['_id'], blacklist[0]['input_playlist']) - - # Reload - blacklist = self.sdb.get_blacklist(filter_value) - bad_artists.extend(blacklist[0]['blacklist']) - else: - print(f"{filter_name} not supported or misspelled. ") - - self.run_record['storm_artists'] = [x for x in self.run_record['input_artists'] if x not in bad_artists] - self.run_record['removed_artists'] = bad_artists - print(f"Starting Artist Amount: {len(self.run_record['input_artists'])}") - print(f"Ending Artist Amount: {len(self.run_record['storm_artists'])}") - - def update_blacklist_from_playlist(self, blacklist_name, playlist_id): - """ - Updates a blacklist from a playlist (reads the artists) - """ - bl_tracks = self.sc.get_playlist_tracks(playlist_id) - bl_artists = self.sc.get_artists_from_tracks(bl_tracks) - self.sdb.update_blacklist(blacklist_name, bl_artists) - - def apply_track_filters(self): - """ - read in filters from configurations - """ - filters = self.config['filters']['track'] - supported = ['audio_features', 'artist_filter'] - bad_tracks = [] - - # Filters - print(f"{len(filters)} valid filters to apply") - for filter_name, filter_value in filters.items(): - - print(f"Attemping filter {filter_name} - {filter_value}") - if filter_name == 'audio_features': - for feature, feature_value in filter_value.items(): - op = f"${feature_value.split('&&')[0]}" - val = float(feature_value.split('&&')[1]) - print(f"Removing tracks with {feature} - {op}:{val}") - valid = self.sdb.filter_tracks_by_audio_feature(self.run_record['eligible_tracks'], {feature:{op:val}}) - bad_tracks.extend([x for x in self.run_record['eligible_tracks'] if x not in valid]) - print(f"Cumulative Bad Tracks found {len(np.unique(bad_tracks))}") - - - elif filter_name == "artist_filter": - if filter_value == 'hard': - # Limits output to tracks that contain only storm artists - for track in tqdm(self.run_record['eligible_tracks']): - - track_artists = set(self.sdb.get_track_artists(track)) - if not track_artists.issubset(set(self.run_record['storm_artists'])): - bad_tracks.append(track) - - elif filter_value == 'soft': - # Removes tracks that contain known filtered out artists - # Other 'bad' artists could sneak in if not tracked by storm - for track in tqdm(self.run_record['eligible_tracks']): - track_artists = set(self.sdb.get_track_artists(track)) - if not set(self.run_record['removed_artists']).isdisjoint(track_artists): - bad_tracks.append(track) - - else: - print(f"{filter_name} not supported or misspelled. ") - - bad_tracks = np.unique(bad_tracks).tolist() - print("Removing bad tracks . . .") - self.run_record['storm_tracks'] = [x for x in self.run_record['eligible_tracks'] if x not in bad_tracks] - self.run_record['removed_tracks'] = bad_tracks - print(f"Starting Track Amount: {len(self.run_record['eligible_tracks'])}") - print(f"Ending Track Amount: {len(self.run_record['storm_tracks'])}") - -class Storm: - """ - Main callable that initiates and saves storm data - """ - def __init__(self, storm_names, start_date=None): - - self.print_initial_screen() - self.storm_names = storm_names - - def print_initial_screen(self): - - print("A Storm is Brewing. . .\n") - time.sleep(.5) - - def Run(self): - - print("Spinning up Storm Runners. . . ") - for storm_name in self.storm_names: - StormRunner(storm_name).Run() From 5c1d922c9173f5c4f1df465ee6ffbef8a5cbdbeb Mon Sep 17 00:00:00 2001 From: ATawzer <34928044+ATawzer@users.noreply.github.com> Date: Tue, 27 Apr 2021 14:16:40 -0600 Subject: [PATCH 29/29] more palylist views --- scratch.py | 27 +++++++++++++++++++++++++++ src/db.py | 34 ++++++++++++++++++++++++++-------- 2 files changed, 53 insertions(+), 8 deletions(-) diff --git a/scratch.py b/scratch.py index e69de29..7879b7b 100644 --- a/scratch.py +++ b/scratch.py @@ -0,0 +1,27 @@ +import spotipy +from spotipy import util +from spotipy import oauth2 +import numpy as np +import pandas as pd +from tqdm import tqdm +import os +import datetime as dt +import time +import json + +# Internal +from src.db import * + + +sdb = StormDB() +sdb.get_playlists(name=True) + +sadb = StormAnalyticsDB() +params = {'playlist_id':'0R1gw1JbcOFD0r8IzrbtYP', 'index':True} +name = 'playlist_track_changes' +test = sadb.gen_view(name, params) + + +params = {'playlist_ids':[], 'index':True} +name = 'many_playlist_track_changes' +test = sadb.gen_view(name, params) \ No newline at end of file diff --git a/src/db.py b/src/db.py index b9cee07..2f5d09c 100644 --- a/src/db.py +++ b/src/db.py @@ -412,6 +412,7 @@ def get_track_artists(self, track): try: return list(self.tracks.find(q, cols))[0]['artists'] except: + return [] raise ValueError(f"Track {track} not found or doesn't have any artists.") # DB Cleanup and Prep @@ -472,22 +473,33 @@ def gen_view(self, name, view_params={}): else: raise Exception(f"View {name} not in map.") - def gen_v_many_playlist_track_changes(self, playlist_ids=[], metric='Number of Tracks'): + def gen_v_many_playlist_track_changes(self, playlist_ids=[], index=False): """ Cross-Compares many playlist track changes """ - df = pd.DataFrame() if len(playlist_ids) == 0: - self.print("No playlists specified, returning all.") + self.print("No playlists specified, defaulting to all in DB.") + playlist_ids = self.sdb.get_playlists() + elif len(playlist_ids) == 1: + self.print("Only one playlist specified, returning single view.") + return self.gen_v_playlist_track_changes(playlist_ids[0]) + # Generate the multiple view dataframe + df = pd.DataFrame() + self.print("Building and combining Playlist views") + for playlist_id in tqdm(playlist_ids): - #for playlist_id in playlist_ids: + playlist_df = self.gen_v_playlist_track_changes(playlist_id, index=False) + playlist_df['playlist'] = playlist_id + # Join it back in + df = pd.concat([df, playlist_df]) + return df.set_index(['date_collected', 'playlist']) if index else df # Single object views - low-level - def gen_v_playlist_track_changes(self, playlist_id): + def gen_v_playlist_track_changes(self, playlist_id, index=False): """ Generates a view of a playlists timely health """ @@ -500,10 +512,16 @@ def gen_v_playlist_track_changes(self, playlist_id): # Compute Metrics for change in playlist_changelog: + + # Tracks df.loc[change, 'Number of tracks'] = len(playlist_changelog[change]['tracks']) - return df + # Artists + artists = [] + [artists.extend(self.sdb.get_track_artists(x)) for x in playlist_changelog[change]['tracks']] + df.loc[change, 'Number of Artists'] = len(np.unique(artists)) - + # Metadata + df.index.rename('date_collected', inplace=True) - \ No newline at end of file + return df if index else df.reset_index() \ No newline at end of file