commit 2bdd8698111b3ef23e5f80260e340481600e61d3 Author: ZiCode0 Date: Sun Aug 24 16:12:33 2025 +1200 :tada: v0.0.1 initial commit :tada: diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c922f61 --- /dev/null +++ b/.gitignore @@ -0,0 +1,186 @@ +# Created by .ignore support plugin (hsz.mobi) +### Python template +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +env/ +.venv/ +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +*.egg-info/ +.installed.cfg +*.egg + +# 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 tests / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*,cover +.hypothesis/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# IPython Notebook +.ipynb_checkpoints + +# pyenv +.python-version + +# celery beat schedule file +celerybeat-schedule + +# dotenv +.env + +# virtualenv +venv/ +ENV/ + +# Spyder project settings +.spyderproject + +# Rope project settings +.ropeproject +### VirtualEnv template +# Virtualenv +# http://iamzed.com/2009/05/07/a-primer-on-virtualenv/ +[Bb]in +[Ii]nclude +[Ll]ib +[Ll]ib64 +[Ll]ocal +[Ss]cripts +pyvenv.cfg +.venv +pip-selfcheck.json + +### JetBrains template +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/**/usage.statistics.xml +.idea/**/dictionaries +.idea/**/shelf + +# AWS User-specific +.idea/**/aws.xml + +# Generated files +.idea/**/contentModel.xml + +# Sensitive or high-churn files +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml +.idea/**/dbnavigator.xml + +# Gradle +.idea/**/gradle.xml +.idea/**/libraries + +# Gradle and Maven with auto-import +# When using Gradle or Maven with auto-import, you should exclude module files, +# since they will be recreated, and may cause churn. Uncomment if using +# auto-import. +# .idea/artifacts +# .idea/compiler.xml +# .idea/jarRepositories.xml +# .idea/modules.xml +# .idea/*.iml +# .idea/modules +# *.iml +# *.ipr + +# CMake +cmake-build-*/ + +# Mongo Explorer plugin +.idea/**/mongoSettings.xml + +# File-based project format +*.iws + +# IntelliJ +out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Cursive Clojure plugin +.idea/replstate.xml + +# SonarLint plugin +.idea/sonarlint/ + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +# Editor-based Rest Client +.idea/httpRequests + +# Android studio 3.1+ serialized cache file +.idea/caches/build_file_checksums.ser + +# idea folder, uncomment if you don't need it +.idea diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..13566b8 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/dify_zcd_tool_mob_ya_music_control.iml b/.idea/dify_zcd_tool_mob_ya_music_control.iml new file mode 100644 index 0000000..ed41961 --- /dev/null +++ b/.idea/dify_zcd_tool_mob_ya_music_control.iml @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..86cc040 --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,292 @@ + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml new file mode 100644 index 0000000..105ce2d --- /dev/null +++ b/.idea/inspectionProfiles/profiles_settings.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..27012e3 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..fc26c95 --- /dev/null +++ b/main.py @@ -0,0 +1,397 @@ +import asyncio +import base64 +import json +import os +import random +import time +from datetime import datetime + +import requests +from dotenv import load_dotenv +from yandex_music import ClientAsync, Client, Track +from yandex_music.exceptions import NetworkError +# from yandex_music.exceptions import NetworkError +from yandex_music.playlist.playlist import Playlist + +load_dotenv(verbose=True) + + +SEARCH_TAG_PREFIX = 'req' +MESSAGE_CMD__PLAY_TRACK_RAW = '#play_track' +MESSAGE_CMD__PLAY_FAVORITES_RAW = '#play_favorites' + +POSTFIX_URL_PLAY = '' +#POSTFIX_URL_PLAY = '?play=true' +TARGET_CONTROL_PLAYLIST_NAME = '#play' +TARGET_CONTROL_PLAYLIST_URL_FORMAT = 'https://music.yandex.com/users/{user_id}/playlists/{playlist_id}' +TRACK_URL_FORMAT = 'https://music.yandex.com/album/{album_id}/track/{track_id}' +FAVORITE_PLAYLIST_URL_FORMAT = 'https://music.yandex.com/users/{user_id}/playlists/3' + +type_to_name = { + 'track': 'трек', 'artist': 'исполнитель', 'album': 'альбом', 'playlist': 'плейлист', + 'video': 'видео', 'user': 'пользователь', 'podcast': 'подкаст', 'podcast_episode': 'эпизод подкаста', +} + + +class YaMusicCustom: + def __init__(self, **kwargs): + # self.client = client + self.client = ClientAsync(token=kwargs['token']) + # await self.init() + + async def init(self): + await self.client.init() + + @staticmethod + def _fix_revision_bug(object_with_revision_param): + object_with_revision_param.revision += 1 + return object_with_revision_param + + async def send_search_request_and_print_result(self, query): + query = f'{query} трек' + + best_object = None + search_result = await self.client.search(query) + + text = [f'[*] Результаты по запросу "{query}":'] + + best_result_text = '' + if search_result.best: + type_ = search_result.best.type + best_object = search_result.best.result + + # text.append(f'[+] Лучший результат: <{}> {type_to_name.get(type_)}') + + if type_ in ['track', 'podcast_episode']: + artists = '' + if best_object.artists: + artists = ' - ' + ', '.join(artist.name for artist in best_object.artists) + best_result_text = best_object.title + artists + elif type_ == 'artist': + best_result_text = best_object.name + elif type_ in ['album', 'podcast']: + best_result_text = best_object.title + elif type_ == 'playlist': + best_result_text = best_object.title + elif type_ == 'video': + best_result_text = f'{best_object.title} {best_object.text}' + + text.append(f' [+] Лучший результат: <{type_to_name.get(type_)}> {best_result_text}') + + if search_result.artists: + text.append(f' [*] Исполнителей: {search_result.artists.total}') + if search_result.albums: + text.append(f' [*] Альбомов: {search_result.albums.total}') + if search_result.tracks: + text.append(f' [*] Треков: {search_result.tracks.total}') + if search_result.playlists: + text.append(f' [*] Плейлистов: {search_result.playlists.total}') + if search_result.videos: + text.append(f' [*] Видео: {search_result.videos.total}') + + text.append('') + print('\n'.join(text)) + + return best_result_text, best_object + + async def get_create_controller_playlist(self) -> Playlist: + all_user_playlist = await self.client.users_playlists_list() + # parse and search target controller playlist + for user_playlist in all_user_playlist: + if user_playlist.custom_wave.title == TARGET_CONTROL_PLAYLIST_NAME: + return user_playlist + print('[*] Playlist was not found, recreate..') + # create and return playlist + # # get user account object + user_account = await self.client.account_status() + # # get user uid + user_uid = user_account.account.uid + # # create playlist + playlist = await self.client.users_playlists_create(user_id=user_uid, title=TARGET_CONTROL_PLAYLIST_NAME) + # return created playlist + return playlist + + @staticmethod + async def clean_playlist(playlist: Playlist): + playlist_tracks = await playlist.fetch_tracks_async() + if len(playlist_tracks) != 0: + await playlist.delete_tracks_async(0, len(playlist_tracks)) + + async def add_track_to_playlist(self, playlist: Playlist, track: Track): + await self.clean_playlist(playlist) + track_id, album_id = track.track_id.split(':') + # + async def insert_track_async(i_track_id, i_album_id, i_playlist, fix_revision_bug=False): + if fix_revision_bug: + i_playlist = self._fix_revision_bug(object_with_revision_param=i_playlist) + await i_playlist.insert_track_async(track_id=int(i_track_id), + album_id=int(i_album_id)) + + # add track to playlist + while True: + try: + dt_start = datetime.now() + await insert_track_async(i_track_id=track_id, i_album_id=album_id, i_playlist=playlist) + print(f' [*] Insert track time (common) : {datetime.now() - dt_start}') + break + except NetworkError: + print(f' [*] Insert track time (with error) : {datetime.now() - dt_start}') + dt_start = datetime.now() + await insert_track_async(i_track_id=track_id, i_album_id=album_id, i_playlist=playlist, + fix_revision_bug=True) + print(f' [*] Insert track time (fix revision) : {datetime.now() - dt_start}') + break + except Exception as ex: + print(ex) + await asyncio.sleep(.1) + + dt_start = datetime.now() + # find track in playlist + while True: + playlist_tracks = await playlist.fetch_tracks_async() + for p_track in playlist_tracks: + if track.id == p_track.id: + print(f' [*] Check track in playlist exist time : {datetime.now() - dt_start}') + return + await asyncio.sleep(.1) + + @staticmethod + def get_controller_playlist_url(playlist: Playlist, turn_play=False): + url = TARGET_CONTROL_PLAYLIST_URL_FORMAT.format(user_id=playlist.owner.login, + playlist_id=playlist.kind) + return url if not turn_play else f'{url}{POSTFIX_URL_PLAY}' + + @staticmethod + def get_track_url(track: Track, turn_play=False): + track_id, album_id = track.track_id.split(':') + url = TRACK_URL_FORMAT.format(track_id=track_id, album_id=album_id) + return url if not turn_play else f'{url}{POSTFIX_URL_PLAY}' + + +class NtfyServerInterface: + def __init__(self, ntfy_server: str, + ntfy_send_topic: str, + ntfy_send_user: str, + ntfy_send_pass: str, + ntfy_read_topic: str, + ntfy_read_user: str, + ntfy_read_pass: str,): + self.NTFY_SERVER = ntfy_server + self.NTFY_SEND_TOPIC = ntfy_send_topic + self.NTFY_SEND_USER = ntfy_send_user + self.NTFY_SEND_PASS = ntfy_send_pass + self.NTFY_READ_TOPIC = ntfy_read_topic + self.NTFY_READ_USER = ntfy_read_user + self.NTFY_READ_PASS = ntfy_read_pass + + self.REPEAT_EVERY_IN_SECONDS = 1 + self.MAX_TRIES_COUNT = 300 + + def send_text_notification(self, text_to_send: str ): + url = f"{self.NTFY_SERVER}/{self.NTFY_SEND_TOPIC}" + # make request + # make auth + session_auth = (self.NTFY_SEND_USER, self.NTFY_SEND_PASS) + r_response = requests.post(url, data=text_to_send.encode('utf-8'), auth=session_auth) + r_response.raise_for_status() # Raises stored HTTPError if the request failed + return r_response.status_code + + def listen_and_get_media_message(self, search_text: str) -> (str, str): + try: + tries_count = 0 + while tries_count < self.MAX_TRIES_COUNT: + + url = f"{self.NTFY_SERVER}/{self.NTFY_READ_TOPIC}/json?poll=1" + + # make auth + session_auth = (self.NTFY_READ_USER, self.NTFY_READ_PASS) + headers = { + "Accept": "text/event-stream" + } + + resp = requests.get(url, auth=session_auth, headers=headers) + last_events = [json.loads(i) for i in resp.content.decode('utf-8').split('\n') if i != ''] + + # enable for #debug + # pprint.pprint(last_events) + + for event in last_events: + # pass + # print() + if search_text in event['tags']: + # success + # pprint.pprint(event) + return event, None + + tries_count += 1 + time.sleep(self.REPEAT_EVERY_IN_SECONDS) + except Exception as ex: + return None, ex + + return None, Exception( + f'Timeout, the maximum number of attempts (max time {self.MAX_TRIES_COUNT * self.REPEAT_EVERY_IN_SECONDS} in sec) was called..') + + @staticmethod + def download_photo_bin_and_base64_from_ntfy_message(message): + # get url from message object + url = message['attachment']['url'] + # fetch the content from the URL + r_response = requests.get(url) + r_response.raise_for_status() # raise error for bad status + + # encode the content as base64 + encoded = base64.b64encode(r_response.content) + + # Convert bytes to string and return + return r_response.content, encoded.decode('utf-8') + + +def prepare_and_return_result(request_type: str, response_data): + return {'result': { + request_type: response_data + }} + + +async def main( + i_INPUT_QUERY, + i_REQUEST_TYPE, + i_NTFY_SERVER, + i_NTFY_SEND_TOPIC, + i_NTFY_SEND_USER, + i_NTFY_SEND_PASS, + i_NTFY_READ_TOPIC, + i_NTFY_READ_USER, + i_NTFY_READ_PASS + ): + global SEARCH_TAG_PREFIX + global MESSAGE_CMD__PLAY_TRACK_RAW + + ntfy_server = NtfyServerInterface( + ntfy_server=i_NTFY_SERVER, + ntfy_send_topic=i_NTFY_SEND_TOPIC, + ntfy_send_user=i_NTFY_SEND_USER, + ntfy_send_pass=i_NTFY_SEND_PASS, + ntfy_read_topic=i_NTFY_READ_TOPIC, + ntfy_read_user=i_NTFY_READ_USER, + ntfy_read_pass=i_NTFY_READ_PASS + ) + client = YaMusicCustom(token=os.getenv('YA_TOKEN')) + await client.init() + + async def play_single_track_main(request_text: str): + # make search + search_result_text, search_result_track_object = await client.send_search_request_and_print_result( + query=request_text) + + if type(search_result_track_object) not in [Track]: + print('[!] No track found, skipping..') + return + + # get controller playlist + dt_start = datetime.now() + playlist_controller_object = await client.get_create_controller_playlist() + print(f' [*] Time to add track to playlist (action): {datetime.now() - dt_start}') + + # add searched track + dt_start = datetime.now() + await client.add_track_to_playlist(playlist=playlist_controller_object, track=search_result_track_object) + print(f'[*] Time to add track to playlist : {datetime.now() - dt_start}') + + # prepare url + playlist_url = client.get_controller_playlist_url(playlist=playlist_controller_object, turn_play=True) + track_url = client.get_track_url(track=search_result_track_object, turn_play=False) + + # ### ### + # + # gen current req id + current_req_id = random.randrange(111111, 999999) + # target full request text code + target_msg_request_id = f'{SEARCH_TAG_PREFIX}{current_req_id}' + # prepare send request message + message = f'{MESSAGE_CMD__PLAY_TRACK_RAW}:{target_msg_request_id}:{playlist_url}' + # + # send remote ntfy command + ntfy_server.send_text_notification(text_to_send=message) + # + # ### ### + # + print(f'[*] Result : "playlist_url" - {playlist_url}, "track_url" - {track_url}') + + async def play_favorites_track_main(): + favorite_url = FAVORITE_PLAYLIST_URL_FORMAT.format(user_id=client.client.me.account.login) + # + # ### ### + # + # gen current req id + current_req_id = random.randrange(111111, 999999) + # target full request text code + target_msg_request_id = f'{SEARCH_TAG_PREFIX}{current_req_id}' + # prepare send request message + message = f'{MESSAGE_CMD__PLAY_FAVORITES_RAW}:{target_msg_request_id}:{favorite_url}' + # + # send remote ntfy command + ntfy_server.send_text_notification(text_to_send=message) + # + # ### ### + # + print(f'[*] Result : "playlist_url" - {favorite_url}') + + result_data = None + if i_REQUEST_TYPE == 'play_favorites': + result_data = await play_favorites_track_main() + elif i_REQUEST_TYPE == 'play_track': + result_data = await play_single_track_main(request_text=i_INPUT_QUERY) + # manual track play challenge + elif i_REQUEST_TYPE == '': + # infinite loop for #debug + while True: + # input_query = 'The Salmon Dance' + input_query = input('Введите название трека для поиска: ') + # play track main function + await play_single_track_main(request_text=input_query) + + return prepare_and_return_result(request_type=i_REQUEST_TYPE, response_data=result_data) + + +if __name__ == '__main__': + from dotenv import load_dotenv + + # load env from .env + load_dotenv() + + # configure + NTFY_SERVER = os.getenv('NTFY_SERVER', '').rstrip('/') + + NTFY_SEND_TOPIC = os.getenv('NTFY_SEND_TOPIC', '').lstrip('/') + NTFY_SEND_USER = os.getenv('NTFY_SEND_USER') + NTFY_SEND_PASS = os.getenv('NTFY_SEND_PASS') + + NTFY_READ_TOPIC = os.getenv('NTFY_READ_TOPIC', '').lstrip('/') + NTFY_READ_USER = os.getenv('NTFY_READ_USER') + NTFY_READ_PASS = os.getenv('NTFY_READ_PASS') + + # #test example to run favorite playlist + asyncio.run(main( + i_REQUEST_TYPE='play_favorites', + i_INPUT_QUERY='', + i_NTFY_SERVER=NTFY_SERVER, + i_NTFY_SEND_TOPIC=NTFY_SEND_TOPIC, + i_NTFY_SEND_USER=NTFY_SEND_USER, + i_NTFY_SEND_PASS=NTFY_SEND_PASS, + i_NTFY_READ_TOPIC=NTFY_READ_TOPIC, + i_NTFY_READ_USER=NTFY_READ_USER, + i_NTFY_READ_PASS=NTFY_READ_PASS + )) + + # #test example to single run + # asyncio.run(main( + # i_REQUEST_TYPE='play_track', + # i_INPUT_QUERY='Не дано хайфай', + # i_NTFY_SERVER=NTFY_SERVER, + # i_NTFY_SEND_TOPIC=NTFY_SEND_TOPIC, + # i_NTFY_SEND_USER=NTFY_SEND_USER, + # i_NTFY_SEND_PASS=NTFY_SEND_PASS, + # i_NTFY_READ_TOPIC=NTFY_READ_TOPIC, + # i_NTFY_READ_USER=NTFY_READ_USER, + # i_NTFY_READ_PASS=NTFY_READ_PASS + # )) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..7f6eb11 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,8 @@ +[project] +name = "dify-zcd-tool-mob-ya-music-control" +version = "0.1.0" +description = "Add your description here" +requires-python = ">=3.12" +dependencies = [ + "python-dotenv>=1.1.1", +] diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..47b9226 --- /dev/null +++ b/uv.lock @@ -0,0 +1,23 @@ +version = 1 +revision = 2 +requires-python = ">=3.12" + +[[package]] +name = "dify-zcd-tool-mob-ya-music-control" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "python-dotenv" }, +] + +[package.metadata] +requires-dist = [{ name = "python-dotenv", specifier = ">=1.1.1" }] + +[[package]] +name = "python-dotenv" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/b0/4bc07ccd3572a2f9df7e6782f52b0c6c90dcbb803ac4a167702d7d0dfe1e/python_dotenv-1.1.1.tar.gz", hash = "sha256:a8a6399716257f45be6a007360200409fce5cda2661e3dec71d23dc15f6189ab", size = 41978, upload-time = "2025-06-24T04:21:07.341Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/ed/539768cf28c661b5b068d66d96a2f155c4971a5d55684a514c1a0e0dec2f/python_dotenv-1.1.1-py3-none-any.whl", hash = "sha256:31f23644fe2602f88ff55e1f5c79ba497e01224ee7737937930c448e4d0e24dc", size = 20556, upload-time = "2025-06-24T04:21:06.073Z" }, +]