├── Asyncmxm ├── __init__.py ├── exceptions.py └── client.py ├── static ├── assets │ └── favicon.ico ├── script.js ├── countries.json └── styles.css ├── .cache ├── Dockerfile ├── .gitignore ├── requirements.txt ├── vercel.json ├── .github └── workflows │ └── build.yaml ├── LICENSE ├── README.md ├── mxm_old.py ├── spotify.py ├── templates ├── mxm.html ├── split.html ├── api.html ├── isrc.html ├── abstrack.html └── index.html ├── mxm.py └── app.py /Asyncmxm/__init__.py: -------------------------------------------------------------------------------- 1 | from .client import Musixmatch 2 | from .exceptions import * -------------------------------------------------------------------------------- /static/assets/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AdelHashem/Spotify-Link-To-Musixmatch-Track-and-Album-webApp/HEAD/static/assets/favicon.ico -------------------------------------------------------------------------------- /.cache: -------------------------------------------------------------------------------- 1 | {"access_token": "BQC3LZaBZRFeqgNRVemDDK3MAnURcWapk8LiEwraFRQHY_lXrHgH4_qa7pq4qczUsdFiuQVtV2fF1qH9eMHj5O0-Ob1cRqYjFJY4n7myzNS61287dOc", "token_type": "Bearer", "expires_in": 3600, "expires_at": 1709849312} -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ubuntu:latest 2 | RUN apt-get update -y && \ 3 | apt-get install -y python3-pip python3-dev 4 | COPY . /app 5 | WORKDIR /app 6 | RUN pip3 install -r requirements.txt 7 | EXPOSE 8000 8 | ENTRYPOINT [ "hypercorn", "--bind", "0.0.0.0:8000" , "app:app" ] -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | *.pyc 3 | .flaskenv 4 | .vscode/ 5 | key.json 6 | .idea/inspectionProfiles/profiles_settings.xml 7 | .idea/misc.xml 8 | .idea/Spotify-Link-To-Musixmatch-Track-and-Album-webApp.iml 9 | .idea/vcs.xml 10 | .idea/modules.xml 11 | .env 12 | *.ipynb 13 | app.log 14 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | flask 2 | mxmapi 3 | requests[security] 4 | spotipy 5 | hypercorn 6 | aiohttp 7 | click==8.1.3 8 | gunicorn==23.0.0 9 | importlib-metadata==5.0.0 10 | itsdangerous==2.1.2 11 | Jinja2==3.1.3 12 | MarkupSafe==2.1.1 13 | Werkzeug 14 | zipp==3.10.0 15 | asgiref 16 | jellyfish 17 | redis[hiredis] 18 | -------------------------------------------------------------------------------- /vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 2, 3 | "builds": [ 4 | { 5 | "src": "./app.py", 6 | "use": "@vercel/python" 7 | } 8 | ], 9 | "routes": [ 10 | { 11 | "src": "/(.*)", 12 | "dest": "/app.py" 13 | } 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /.github/workflows/build.yaml: -------------------------------------------------------------------------------- 1 | name: Build The Docker image and push it to the Docker Hub 2 | on: 3 | push: 4 | branches: 5 | - master 6 | tags: 7 | - '*' 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout the repository 13 | uses: actions/checkout@v4 14 | - name: Login to Docker Hub 15 | uses: docker/login-action@v2 16 | with: 17 | username: ${{ secrets.DOCKER_USERNAME }} 18 | password: ${{ secrets.DOCKER_PASSWORD }} 19 | - name: Build the Docker image 20 | run: "docker build -t ${{ secrets.DOCKER_USERNAME }}/sp-to-mxm:${{ github.ref_name }} ." 21 | - name: Push the Docker image 22 | run: "docker push ${{ secrets.DOCKER_USERNAME }}/sp-to-mxm:${{ github.ref_name }}" -------------------------------------------------------------------------------- /Asyncmxm/exceptions.py: -------------------------------------------------------------------------------- 1 | class MXMException(Exception): 2 | 3 | codes = { 4 | 400: "The request had bad syntax or was inherently impossible to be satisfied.", 5 | 401: "We have hit the Musixmatch API limit for today. Sorry, I can't do much about it, but you can set up a private one.", 6 | 402: "The usage limit has been reached, either you exceeded per day requests limits or your balance is insufficient.", 7 | 403: "You are not authorized to perform this operation.", 8 | 404: "The requested resource was not found.", 9 | 405: "The requested method was not found.", 10 | 500: "Ops. Something were wrong.", 11 | 503: "Our system is a bit busy at the moment and your request can't be satisfied." 12 | } 13 | 14 | 15 | def __init__(self,status_code, message): 16 | self.status_code = status_code 17 | if message: 18 | self.message = message 19 | else: 20 | self.message = self.codes.get(status_code) or "Unknown Error" 21 | 22 | 23 | def __str__(self): 24 | return f"Error code: {self.status_code} - message: {self.message}" 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Adel Hashem 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Spotify-Link-To-Musixmatch-Track-link-and-Album-link-webApp 2 |

3 | A Web Application that connects Musixmatch with Spotify 4 |
5 | 6 | ## Domains 7 | - [Vercel](https://spotify-to-mxm.vercel.app/) 8 | - [Render](https://spotify-to-mxm.onrender.com/) 9 | - [PythonAnywhere](http://cifor55334.pythonanywhere.com/) 10 | 11 | ## Built With 12 | - ![Python](https://img.shields.io/badge/python-3670A0?style=for-the-badge&logo=python&logoColor=ffdd54) 13 | - ![Flask](https://img.shields.io/badge/flask-%23000.svg?style=for-the-badge&logo=flask&logoColor=white) 14 | - ![Jinja](https://img.shields.io/badge/jinja-white.svg?style=for-the-badge&logo=jinja&logoColor=black) 15 | - ![Redis](https://img.shields.io/badge/redis-%23DD0031.svg?style=for-the-badge&logo=redis&logoColor=white) 16 | 17 | ## Features 18 | - Get links to Musixmatch from Spotify Link 19 | - Get the Musixmatch track page from the ISRC code 20 | - Get ISRC codes and available markets for Spotify tracks or Album 21 | - Get the Spotify link from the ISRC code 22 | - Get Connected Sources for a Musixmatch Track or Album link 23 | - Check If 2 tracks share the same page in Musixmatch can be split or not 24 | - Ability to use private API for Musixmatch 25 | - Handle normal Spotify links and the short one 26 | -------------------------------------------------------------------------------- /mxm_old.py: -------------------------------------------------------------------------------- 1 | import time 2 | import os 3 | import mxmapi 4 | import re 5 | 6 | 7 | class MXM: 8 | DEFAULT_KEY = os.environ.get("MXM_API") 9 | 10 | def __init__(self, key=None): 11 | self.key = key or self.DEFAULT_KEY 12 | self.musixmatch = mxmapi.Musixmatch(self.key) 13 | 14 | def change_key(self, key): 15 | self.key = key 16 | 17 | def track_get(self, isrc=None, commontrack_id=None) -> dict: 18 | try: 19 | response = self.musixmatch.track_get( 20 | track_isrc=isrc, commontrack_id=commontrack_id 21 | ) 22 | return response 23 | except mxmapi.exceptions.MXMException as e: 24 | if re.search("404", str(e)): 25 | return 404 26 | else: 27 | return e 28 | 29 | def matcher_track(self, sp_id): 30 | try: 31 | response = self.musixmatch.matcher_track_get( 32 | q_track="null", track_spotify_id=sp_id 33 | ) 34 | return response 35 | except mxmapi.exceptions.MXMException as e: 36 | if re.search("404", str(e)): 37 | return 404 38 | else: 39 | return e 40 | 41 | def Track_links(self, isrc): 42 | track = self.track_get(isrc) 43 | try: 44 | id = track["message"]["body"]["track"]["commontrack_id"] 45 | except TypeError: 46 | return track 47 | track_url = track["message"]["body"]["track"]["track_she_url"] 48 | album_id = track["message"]["body"]["track"]["album_id"] 49 | 50 | album_url = f"https://www.musixmatch.com/album/id/{album_id}" 51 | return [id, track_url, album_url] 52 | 53 | def Tracks_Data(self, iscrcs): 54 | tracks = [] 55 | Limit = 5 56 | import_count = 0 57 | if "isrc" not in iscrcs[0]: 58 | return iscrcs 59 | 60 | if iscrcs[0].get("track"): 61 | matcher = self.matcher_track(iscrcs[0]["track"]["id"]) 62 | k = 0 63 | for i in iscrcs: 64 | track = self.track_get(i["isrc"]) 65 | 66 | # try to import the track 67 | if track == 404: 68 | if import_count < Limit: 69 | import_count += 1 70 | track = self.matcher_track(i["track"]["id"]) 71 | if isinstance(track, mxmapi.exceptions.MXMException): 72 | tracks.append(track) 73 | continue 74 | track = self.track_get(i["isrc"]) 75 | if track == 404: 76 | track = "The track hasn't been imported yet. Try one more time after a minute (tried to import it using matcher call)." 77 | tracks.append(track) 78 | continue 79 | 80 | try: 81 | track = track["message"]["body"]["track"] 82 | track["isrc"] = i["isrc"] 83 | track["image"] = i["image"] 84 | try: 85 | if ( 86 | k == 0 87 | and track["commontrack_id"] 88 | == matcher["message"]["body"]["track"]["commontrack_id"] 89 | ): 90 | track["matcher_album"] = [ 91 | matcher["message"]["body"]["track"]["album_id"], 92 | matcher["message"]["body"]["track"]["album_name"], 93 | ] 94 | k += 1 95 | except: 96 | pass 97 | 98 | tracks.append(track) 99 | except (TypeError, KeyError): 100 | tracks.append(track) 101 | time.sleep(0.1) 102 | 103 | print(tracks) 104 | return tracks 105 | -------------------------------------------------------------------------------- /static/script.js: -------------------------------------------------------------------------------- 1 | // Get the form and button elements 2 | const form = document.querySelector("form"); 3 | const button = document.querySelector("#process_button"); 4 | 5 | // Add event listener for form submit 6 | form.addEventListener("submit", (event) => { 7 | event.preventDefault(); 8 | 9 | button.disabled = true; 10 | 11 | const loadingSpinner = document.querySelector("#loading"); 12 | loadingSpinner.style.display = "block"; 13 | 14 | const inputLink = document.querySelector("#input_link").value.trim(); 15 | 16 | if (!inputLink) { 17 | loadingSpinner.style.display = "none"; 18 | button.disabled = false; 19 | return Promise.resolve(); // Resolve with a void value 20 | } 21 | 22 | if (window.location.pathname === "/abstrack") { 23 | window.location.href = 24 | window.location.href.split("?")[0] + 25 | "?id=" + 26 | encodeURIComponent(inputLink); 27 | }else{ 28 | window.location.href = 29 | window.location.href.split("?")[0] + 30 | "?link=" + 31 | encodeURIComponent(inputLink); 32 | } 33 | 34 | }); 35 | 36 | // Get the how-to-use link and modal elements 37 | const howToUseLink = document.querySelector("#how_to_use"); 38 | const modal = document.querySelector(".modal"); 39 | const closeBtn = document.querySelector(".close"); 40 | 41 | // Add click event listener for the how-to-use link 42 | howToUseLink.addEventListener("click", (event) => { 43 | event.preventDefault(); 44 | modal.style.display = "block"; 45 | }); 46 | 47 | // Add click event listener for the close button in the modal 48 | closeBtn.addEventListener("click", () => { 49 | modal.style.display = "none"; 50 | }); 51 | 52 | // Add click event listener for clicks outside the modal 53 | window.addEventListener("click", (event) => { 54 | if (event.target === modal) { 55 | modal.style.display = "none"; 56 | } 57 | }); 58 | 59 | // Get the close button element and note element 60 | const closeButton = document.getElementById("closenote"); 61 | const note = document.querySelector(".note"); 62 | 63 | // If the close button and note elements exist 64 | if (closeButton && note) { 65 | // Add an event listener to the close button 66 | closeButton.addEventListener("click", () => { 67 | // Hide the note element 68 | note.style.display = "none"; 69 | }); 70 | } 71 | 72 | window.addEventListener("load", () => { 73 | const offlineDiv = document.getElementById("offline-div"); 74 | 75 | function handleOnlineStatus() { 76 | const elements = document.querySelectorAll( 77 | "body > *:not(head):not(script):not(meta)" 78 | ); 79 | if (navigator.onLine) { 80 | for (const element of elements) { 81 | element.style.removeProperty("display"); 82 | } 83 | offlineDiv.style.display = "none"; 84 | } else { 85 | for (const element of elements) { 86 | element.style.display = "none"; 87 | } 88 | offlineDiv.style.display = "block"; 89 | } 90 | } 91 | 92 | handleOnlineStatus(); // Initial check 93 | 94 | // Listen for online/offline events 95 | window.addEventListener("online", () => { 96 | location.reload(); 97 | }); 98 | window.addEventListener("offline", handleOnlineStatus); 99 | }); 100 | 101 | // EOL notice 102 | function closeEOLNotice() { 103 | const eolNotice = document.getElementById("eol-notice"); 104 | if (eolNotice) { 105 | eolNotice.style.display = "none"; 106 | // save to localStorage so it stays hidden on page refresh 107 | localStorage.setItem("eol-notice-closed", "true"); 108 | } 109 | } 110 | 111 | // Check if the EOL notice was previously closed 112 | window.addEventListener("load", () => { 113 | const eolNotice = document.getElementById("eol-notice"); 114 | if (eolNotice && localStorage.getItem("eol-notice-closed") === "true") { 115 | eolNotice.style.display = "none"; 116 | } 117 | }); 118 | 119 | // Hide the note element if the current URL contains a query string 120 | document.querySelector(".note").style.display = window.location.href.includes( 121 | "?" 122 | ) 123 | ? "none" 124 | : "block"; 125 | -------------------------------------------------------------------------------- /spotify.py: -------------------------------------------------------------------------------- 1 | import spotipy 2 | from spotipy.oauth2 import SpotifyClientCredentials 3 | import re 4 | import requests 5 | import redis 6 | from os import environ 7 | import logging 8 | 9 | class Spotify: 10 | def __init__(self, client_id=None, client_secret=None) -> None: 11 | self.client_id = client_id if client_id else environ.get("SPOTIPY_CLIENT_ID") 12 | self.client_secret = client_secret if client_secret else environ.get("SPOTIPY_CLIENT_SECRET") 13 | if not (self.client_id and self.client_secret): 14 | self.RRAuth() 15 | else: 16 | cred = SpotifyClientCredentials(self.client_id, self.client_secret) 17 | self.sp = spotipy.Spotify(client_credentials_manager=cred,retries= 3 ) 18 | self.session = requests.Session() 19 | # if client_id is not None: 20 | 21 | def RRAuth(self): 22 | r = redis.Redis( 23 | host=environ.get("REDIS_HOST"), 24 | port=environ.get("REDIS_PORT"), 25 | password=environ.get("REDIS_PASSWD")) 26 | 27 | doc = r.json().get("spotify","$") 28 | doc = doc[0] 29 | cred = doc["cred"][doc["rr"]] 30 | logging.info(f"Spotify Cred: {cred}") 31 | r.json().set("spotify","$.rr",(doc["rr"]+1)%len(doc["cred"])) 32 | r.close() 33 | cred = SpotifyClientCredentials(cred[0], cred[1]) 34 | self.sp = spotipy.Spotify(client_credentials_manager=cred,retries= 3 ) 35 | 36 | def get_tarck(self, link=None, track=None) -> dict: 37 | if link is not None: track = self.get_spotify_id(link) 38 | return self.sp.track(track) 39 | 40 | def get_album_tarck(self, id: str) -> dict: 41 | return self.sp.album_tracks(id) 42 | 43 | def get_tracks(self, ids) -> list: 44 | return self.sp.tracks(ids)["tracks"] 45 | 46 | def get_isrc(self, link): 47 | if not (self.client_id and self.client_secret): 48 | self.RRAuth() 49 | isrcs = [] 50 | track = None 51 | match =re.search(r'spotify.link/\w+', link) 52 | if match: 53 | link = self.session.get(link).url 54 | 55 | match = re.search(r'album/(\w+)', link) 56 | if match: 57 | tracks = self.get_album_tarck(match.group(1)) 58 | link = None 59 | ids = [temp["id"] for temp in tracks["items"]] 60 | tracks = self.get_tracks(ids) 61 | 62 | for i in tracks: 63 | if "external_ids" in i: 64 | try: 65 | image = i["album"]["images"][1]["url"] 66 | except: 67 | image = None 68 | 69 | if i["external_ids"].get("isrc"): 70 | isrcs.append({"isrc": i["external_ids"]["isrc"], "image": image, "track": i}) 71 | else: 72 | isrcs.append("The Track is missing its ISRC on Spotify.") 73 | else: 74 | return "Error in get_isrc" 75 | 76 | return isrcs 77 | 78 | else: 79 | track = self.get_tarck(link, track) 80 | print(link) 81 | if "external_ids" in track: 82 | img = track["album"]["images"][1]["url"] 83 | isrcs.append({"isrc": track["external_ids"]["isrc"], "image": img, "track": track}) 84 | return isrcs 85 | else: 86 | return "Error in get_isrc" 87 | 88 | def artist_albums(self, link, albums=[], offset=0) -> list: 89 | data = self.sp.artist_albums(link, limit=50, offset=offset, album_type="album,single,compilation") 90 | offset = offset + 50 91 | albums.extend(data["items"]) 92 | if data["next"]: 93 | return self.artist_albums(link, albums, offset) 94 | else: 95 | return albums 96 | 97 | def get_spotify_id(self, link): 98 | match = re.search(r'track/(\w+)', link) 99 | if match: 100 | return match.group(1) 101 | elif re.search(r'artist/(\w+)', link): 102 | return re.search(r'track/(\w+)', link).group(1) 103 | 104 | else: 105 | return None 106 | 107 | def search_by_isrc(self, isrc): 108 | data = self.sp.search(f"isrc:{isrc}") 109 | if data["tracks"]["items"]: 110 | track = data["tracks"]["items"][0] 111 | if isrc == track["external_ids"]["isrc"]: 112 | img = track["album"]["images"][1]["url"] 113 | return [{"isrc": track["external_ids"]["isrc"], "image": img, "track": track}] 114 | return ["No track found with this ISRC"] 115 | -------------------------------------------------------------------------------- /static/countries.json: -------------------------------------------------------------------------------- 1 | { 2 | "BD": "Bangladesh", 3 | "BE": "Belgium", 4 | "BF": "Burkina Faso", 5 | "BG": "Bulgaria", 6 | "BA": "Bosnia and Herzegovina", 7 | "BB": "Barbados", 8 | "WF": "Wallis and Futuna", 9 | "BL": "Saint Barthelemy", 10 | "BM": "Bermuda", 11 | "BN": "Brunei", 12 | "BO": "Bolivia", 13 | "BH": "Bahrain", 14 | "BI": "Burundi", 15 | "BJ": "Benin", 16 | "BT": "Bhutan", 17 | "JM": "Jamaica", 18 | "BV": "Bouvet Island", 19 | "BW": "Botswana", 20 | "WS": "Samoa", 21 | "BQ": "Bonaire, Saint Eustatius and Saba ", 22 | "BR": "Brazil", 23 | "BS": "Bahamas", 24 | "JE": "Jersey", 25 | "BY": "Belarus", 26 | "BZ": "Belize", 27 | "RU": "Russia", 28 | "RW": "Rwanda", 29 | "RS": "Serbia", 30 | "TL": "East Timor", 31 | "RE": "Reunion", 32 | "TM": "Turkmenistan", 33 | "TJ": "Tajikistan", 34 | "RO": "Romania", 35 | "TK": "Tokelau", 36 | "GW": "Guinea-Bissau", 37 | "GU": "Guam", 38 | "GT": "Guatemala", 39 | "GS": "South Georgia and the South Sandwich Islands", 40 | "GR": "Greece", 41 | "GQ": "Equatorial Guinea", 42 | "GP": "Guadeloupe", 43 | "JP": "Japan", 44 | "GY": "Guyana", 45 | "GG": "Guernsey", 46 | "GF": "French Guiana", 47 | "GE": "Georgia", 48 | "GD": "Grenada", 49 | "GB": "United Kingdom", 50 | "GA": "Gabon", 51 | "SV": "El Salvador", 52 | "GN": "Guinea", 53 | "GM": "Gambia", 54 | "GL": "Greenland", 55 | "GI": "Gibraltar", 56 | "GH": "Ghana", 57 | "OM": "Oman", 58 | "TN": "Tunisia", 59 | "JO": "Jordan", 60 | "HR": "Croatia", 61 | "HT": "Haiti", 62 | "HU": "Hungary", 63 | "HK": "Hong Kong", 64 | "HN": "Honduras", 65 | "HM": "Heard Island and McDonald Islands", 66 | "VE": "Venezuela", 67 | "PR": "Puerto Rico", 68 | "PS": "Palestinian Territory", 69 | "PW": "Palau", 70 | "PT": "Portugal", 71 | "SJ": "Svalbard and Jan Mayen", 72 | "PY": "Paraguay", 73 | "IQ": "Iraq", 74 | "PA": "Panama", 75 | "PF": "French Polynesia", 76 | "PG": "Papua New Guinea", 77 | "PE": "Peru", 78 | "PK": "Pakistan", 79 | "PH": "Philippines", 80 | "PN": "Pitcairn", 81 | "PL": "Poland", 82 | "PM": "Saint Pierre and Miquelon", 83 | "ZM": "Zambia", 84 | "EH": "Western Sahara", 85 | "EE": "Estonia", 86 | "EG": "Egypt", 87 | "ZA": "South Africa", 88 | "EC": "Ecuador", 89 | "IT": "Italy", 90 | "VN": "Vietnam", 91 | "SB": "Solomon Islands", 92 | "ET": "Ethiopia", 93 | "SO": "Somalia", 94 | "ZW": "Zimbabwe", 95 | "SA": "Saudi Arabia", 96 | "ES": "Spain", 97 | "ER": "Eritrea", 98 | "ME": "Montenegro", 99 | "MD": "Moldova", 100 | "MG": "Madagascar", 101 | "MF": "Saint Martin", 102 | "MA": "Morocco", 103 | "MC": "Monaco", 104 | "UZ": "Uzbekistan", 105 | "MM": "Myanmar", 106 | "ML": "Mali", 107 | "MO": "Macao", 108 | "MN": "Mongolia", 109 | "MH": "Marshall Islands", 110 | "MK": "Macedonia", 111 | "MU": "Mauritius", 112 | "MT": "Malta", 113 | "MW": "Malawi", 114 | "MV": "Maldives", 115 | "MQ": "Martinique", 116 | "MP": "Northern Mariana Islands", 117 | "MS": "Montserrat", 118 | "MR": "Mauritania", 119 | "IM": "Isle of Man", 120 | "UG": "Uganda", 121 | "TZ": "Tanzania", 122 | "MY": "Malaysia", 123 | "MX": "Mexico", 124 | "IL": "Israel", 125 | "FR": "France", 126 | "IO": "British Indian Ocean Territory", 127 | "SH": "Saint Helena", 128 | "FI": "Finland", 129 | "FJ": "Fiji", 130 | "FK": "Falkland Islands", 131 | "FM": "Micronesia", 132 | "FO": "Faroe Islands", 133 | "NI": "Nicaragua", 134 | "NL": "Netherlands", 135 | "NO": "Norway", 136 | "NA": "Namibia", 137 | "VU": "Vanuatu", 138 | "NC": "New Caledonia", 139 | "NE": "Niger", 140 | "NF": "Norfolk Island", 141 | "NG": "Nigeria", 142 | "NZ": "New Zealand", 143 | "NP": "Nepal", 144 | "NR": "Nauru", 145 | "NU": "Niue", 146 | "CK": "Cook Islands", 147 | "XK": "Kosovo", 148 | "CI": "Ivory Coast", 149 | "CH": "Switzerland", 150 | "CO": "Colombia", 151 | "CN": "China", 152 | "CM": "Cameroon", 153 | "CL": "Chile", 154 | "CC": "Cocos Islands", 155 | "CA": "Canada", 156 | "CG": "Republic of the Congo", 157 | "CF": "Central African Republic", 158 | "CD": "Democratic Republic of the Congo", 159 | "CZ": "Czech Republic", 160 | "CY": "Cyprus", 161 | "CX": "Christmas Island", 162 | "CR": "Costa Rica", 163 | "CW": "Curacao", 164 | "CV": "Cape Verde", 165 | "CU": "Cuba", 166 | "SZ": "Swaziland", 167 | "SY": "Syria", 168 | "SX": "Sint Maarten", 169 | "KG": "Kyrgyzstan", 170 | "KE": "Kenya", 171 | "SS": "South Sudan", 172 | "SR": "Suriname", 173 | "KI": "Kiribati", 174 | "KH": "Cambodia", 175 | "KN": "Saint Kitts and Nevis", 176 | "KM": "Comoros", 177 | "ST": "Sao Tome and Principe", 178 | "SK": "Slovakia", 179 | "KR": "South Korea", 180 | "SI": "Slovenia", 181 | "KP": "North Korea", 182 | "KW": "Kuwait", 183 | "SN": "Senegal", 184 | "SM": "San Marino", 185 | "SL": "Sierra Leone", 186 | "SC": "Seychelles", 187 | "KZ": "Kazakhstan", 188 | "KY": "Cayman Islands", 189 | "SG": "Singapore", 190 | "SE": "Sweden", 191 | "SD": "Sudan", 192 | "DO": "Dominican Republic", 193 | "DM": "Dominica", 194 | "DJ": "Djibouti", 195 | "DK": "Denmark", 196 | "VG": "British Virgin Islands", 197 | "DE": "Germany", 198 | "YE": "Yemen", 199 | "DZ": "Algeria", 200 | "US": "United States", 201 | "UY": "Uruguay", 202 | "YT": "Mayotte", 203 | "UM": "United States Minor Outlying Islands", 204 | "LB": "Lebanon", 205 | "LC": "Saint Lucia", 206 | "LA": "Laos", 207 | "TV": "Tuvalu", 208 | "TW": "Taiwan", 209 | "TT": "Trinidad and Tobago", 210 | "TR": "Turkey", 211 | "LK": "Sri Lanka", 212 | "LI": "Liechtenstein", 213 | "LV": "Latvia", 214 | "TO": "Tonga", 215 | "LT": "Lithuania", 216 | "LU": "Luxembourg", 217 | "LR": "Liberia", 218 | "LS": "Lesotho", 219 | "TH": "Thailand", 220 | "TF": "French Southern Territories", 221 | "TG": "Togo", 222 | "TD": "Chad", 223 | "TC": "Turks and Caicos Islands", 224 | "LY": "Libya", 225 | "VA": "Vatican", 226 | "VC": "Saint Vincent and the Grenadines", 227 | "AE": "United Arab Emirates", 228 | "AD": "Andorra", 229 | "AG": "Antigua and Barbuda", 230 | "AF": "Afghanistan", 231 | "AI": "Anguilla", 232 | "VI": "U.S. Virgin Islands", 233 | "IS": "Iceland", 234 | "IR": "Iran", 235 | "AM": "Armenia", 236 | "AL": "Albania", 237 | "AO": "Angola", 238 | "AQ": "Antarctica", 239 | "AS": "American Samoa", 240 | "AR": "Argentina", 241 | "AU": "Australia", 242 | "AT": "Austria", 243 | "AW": "Aruba", 244 | "IN": "India", 245 | "AX": "Aland Islands", 246 | "AZ": "Azerbaijan", 247 | "IE": "Ireland", 248 | "ID": "Indonesia", 249 | "UA": "Ukraine", 250 | "QA": "Qatar", 251 | "MZ": "Mozambique" 252 | } 253 | -------------------------------------------------------------------------------- /templates/mxm.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 13 | Musixmatch Source 14 | 19 | 20 | 21 | 22 | 26 | 27 | 28 | 29 |

53 | 54 | 55 | 65 | 66 | 67 |
68 |

Musixmatch Album source

69 |
70 | 71 | 77 | 78 |
79 |
80 |
81 |

The tool can only get the source of the album.

82 |

If a track page connected to more than one album, 83 | it's better to enter the album page.

84 | {% if album %} 85 |
86 |
87 |
88 |
89 |
{{ album.album_name }}
90 | 91 |

92 | Album: 93 | {{ album.album_name }} 98 | 99 |

100 | 101 |

102 | Album ID: {{ album.album_id }} 103 |

104 | 105 | {% if album.external_ids.spotify %} 106 |

107 | Spotify source: 108 |

109 |
    110 | {% for id in album.external_ids.spotify %} 111 |
  • 112 | {{ id }} 117 |
  • 118 |

    119 | {% endfor %} 120 |
121 |
122 | {% else %} 123 |

124 | Spotify source: No Connected Source 125 |

126 | {% endif %} 127 | 128 | {% if album.external_ids.itunes %} 129 |

130 | Apple Music source: 131 |

132 |
    133 | {% for id in album.external_ids.itunes %} 134 |
  • 135 | {{ id }} 140 |
  • 141 |

    142 | {% endfor %} 143 |
144 |
145 | {% else %} 146 |

147 | Apple Music source: No Connected Source 148 |

149 | {% endif %} 150 |
151 |
152 |
153 |
154 | {% endif %} 155 | {% if error %} 156 |
157 |
158 |
159 |
160 |

error: {{ error }}

161 |
162 |
163 |
164 |
165 | {% endif %} 166 | 167 | 168 |
169 | 190 | 191 | 192 |
193 | 194 | 201 | 202 | 203 | -------------------------------------------------------------------------------- /templates/split.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | 18 | Split Checker 19 | 24 | 25 | 26 | 27 | 31 | 32 | 33 | 34 | 58 | 59 | 60 | 70 | 71 |
72 |

Split Checker

73 |
74 | 75 | 81 | 82 | 88 | 89 |
90 |
91 |
92 |
93 | {% if split_result %} 94 |
95 |
96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 129 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 |
Track 1Track 2
Track 1 ISRC:{{ split_result.track1.isrc }}{{ split_result.track2.isrc }}
Track 1 Name:{{ split_result.track1.track.name }}{{ split_result.track2.track.name }}
Track ID:{{ split_result.track1.commontrack_id }}{{ split_result.track2.commontrack_id }}
Musixmatch Link: 123 | Musixmatch 128 | 130 | Musixmatch 135 |
{{ message|safe }}
144 |
145 |
146 | {% else %}{% if error %} 147 |
148 |

{{ error }}

149 |
150 | {% endif %} {% endif %} 151 |
152 |
153 | 172 | 203 |
204 | 205 | 206 | -------------------------------------------------------------------------------- /templates/api.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | 18 | Set MXM API 19 | 24 | 25 | 26 | 27 | 31 | 32 | 33 | 34 | 58 | 59 | 60 | 70 | 71 |
72 |

Set Your API Key

73 |
74 | 75 | 81 | 82 |
83 |
84 |
85 |

86 | This Page is for any one want to use the tool with their Private API 87 | key 88 |

89 |

90 | {% if key %} 91 |
92 |
93 |
94 |
95 |

Your Key: {{ key }}

96 |

97 | Delete your key 100 |

101 |
102 |
103 |
104 |
105 | {% elif error%} 106 |
107 |
108 |
109 |
110 |

Erorr: {{ error }}

111 |
112 |
113 |
114 |
115 | {% endif %} 116 | 117 |

How To Get An API Key:

118 |
    119 |
  1. 120 | signup in https://developer.musixmatch.com/signup 127 |
  2. 128 |
  3. 129 | Go to https://developer.musixmatch.com/admin/applications 135 |
  4. 136 |
  5. Copy the key and enter it on this page
  6. 137 |
  7. 138 | Please save and pin your key on the clipboard as it is saved in the 139 | browser you use only. If you changed browsers, you'll need to 140 | re-enter it 141 |
  8. 142 |
143 |

144 | How can I return to use the tool API: 147 |

148 | 151 |

152 | Would the tool use my key for other users: 155 |

156 | 159 |

How secure will be the API Key?

160 | 168 |
169 | 193 | 221 |
222 | 223 | 224 | -------------------------------------------------------------------------------- /templates/isrc.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 13 | Spotify Data 14 | 19 | 20 | 21 | 22 | 26 | 27 | 28 | 29 | 53 | 54 | 55 | 65 | 66 | 67 |
68 |

Get Spotify Data

69 |
70 | 71 | 77 | 78 |
79 |
80 |
81 | {% if tracks_data %} 82 |
83 | {% for track in tracks_data %} {% if track.isrc is defined %} 84 |
85 |
86 | {{ track.track_name }} 87 |
88 |
{{ track.track.name }}
89 | 90 |

91 | Album: 92 | {{ track.track.album.name }} 97 | 98 |

99 |

ISRC: {{ track.isrc }}

100 | {% if track.track.available_markets %} 101 |

Available Markets

102 | {% else %} 103 |

No Available Markets

104 | {% endif %} 105 | 106 |
107 |
108 |
109 | {% else %} 110 |
111 |

{{ track }}

112 |
113 | {% endif %} {% endfor %} 114 |
115 | 116 | {% if tracks_data[0].track is defined %} 117 | 180 | {% endif %} 181 | 182 | 183 | {% endif %} 184 |
185 | 206 | 207 | 208 |
209 | 210 | 217 | 218 | 219 | -------------------------------------------------------------------------------- /templates/abstrack.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 13 | Musixmatch Source 14 | 19 | 20 | 21 | 22 | 26 | 27 | 28 | 29 | 53 | 54 | 55 | 65 | 66 | 67 |
68 |

Musixmatch Abstrack

69 |
70 | 71 | 72 | 73 |
74 |
75 |
76 | {% if error %} 77 |
78 |
79 |
80 |
81 |

error: {{ error }}

82 |
83 |
84 |
85 |
86 | {% else %} 87 | {% if album %} 88 |
89 |
90 |
91 |
92 |
Track Info:
93 |

94 | Track Name: {{ track.track_name }}

95 |

96 | Album Name: {{ track.album_name }}

97 |

98 | Artist Name: {{ track.artist_name }}

99 |

100 | Explicit: {{ track.explicit }}

101 |

102 | Has Lyrics: {{ track.has_lyrics }}

103 |

104 | Has Subtitles: {{ track.has_subtitles }} 105 |

106 |

107 | Link: 108 | Musixmatch 109 |

110 | 111 |
Album Info:
112 |

113 | Album: 114 | {{ album.album_name }} 115 |

116 |

117 | Album ID: {{ album.album_id }} 118 |

119 | 120 | {% if album.external_ids.spotify %} 121 |

Spotify source:

122 |
123 |
    124 | {% for id in album.external_ids.spotify %} 125 |
  • 126 | {{ id }} 127 |
  • 128 | {% endfor %} 129 |
130 |
131 | {% else %} 132 |

Spotify source: No Connected Source

133 | {% endif %} 134 | 135 | {% if album.external_ids.itunes %} 136 |

Apple Music source:

137 |
138 |
    139 | {% for id in album.external_ids.itunes %} 140 |
  • 141 | {{ id }} 142 |
  • 143 | {% endfor %} 144 |
145 |
146 | {% else %} 147 |

Apple Music source: No Connected Source

148 | {% endif %} 149 | 150 | {% if album.external_ids.amazon_music %} 151 |

Amazon Music source:

152 |
153 |
    154 | {% for id in album.external_ids.amazon_music %} 155 |
  • 156 | {{ id }} 157 |
  • 158 | {% endfor %} 159 |
160 |
161 | {% else %} 162 |

Amazon Music source: No Connected Source

163 | {% endif %} 164 |
165 |
166 |
167 |
168 | {% endif %} 169 | {% if error %} 170 |
171 |
172 |
173 |
174 |

Error: {{ error }}

175 |
176 |
177 |
178 |
179 | {% endif %} 180 | {% endif %} 181 |
182 | 183 | 204 | 205 | 206 |
207 | 208 | 215 | 216 | 217 | -------------------------------------------------------------------------------- /mxm.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | import jellyfish 4 | import Asyncmxm 5 | import asyncio 6 | from urllib.parse import unquote 7 | import redis 8 | 9 | class MXM: 10 | DEFAULT_KEY = os.environ.get("MXM_API") 11 | DEFAULT_KEY2 = os.environ.get("MXM_API2") 12 | 13 | def __init__(self, key=None, session=None): 14 | self.key = key or self.DEFAULT_KEY 15 | self.key2 = key or self.DEFAULT_KEY2 16 | if not self.key: 17 | r = redis.Redis( 18 | host=os.environ.get("REDIS_HOST"), 19 | port=os.environ.get("REDIS_PORT"), 20 | password=os.environ.get("REDIS_PASSWD")) 21 | key1 = r.get("live:1") 22 | key2 = r.get("live:2") 23 | self.key = key1.decode() 24 | self.key2 = key2.decode() 25 | print(self.key," ", self.key2) 26 | r.close() 27 | 28 | 29 | self.session = session 30 | self.musixmatch = Asyncmxm.Musixmatch(self.key,requests_session=session) 31 | self.musixmatch2 = Asyncmxm.Musixmatch(self.key2,requests_session=session) 32 | 33 | def change_key(self, key): 34 | self.key = key 35 | 36 | async def track_get(self, isrc=None, commontrack_id=None, vanity_id =None) -> dict: 37 | try: 38 | response = await self.musixmatch.track_get( 39 | track_isrc=isrc, commontrack_id=commontrack_id, 40 | commontrack_vanity_id= vanity_id 41 | ) 42 | return response 43 | except Asyncmxm.exceptions.MXMException as e: 44 | return str(e) 45 | 46 | async def matcher_track(self, sp_id): 47 | try: 48 | response = await self.musixmatch2.matcher_track_get( 49 | q_track="null", track_spotify_id=sp_id 50 | ) 51 | return response 52 | except Asyncmxm.exceptions.MXMException as e: 53 | return str(e) 54 | 55 | async def Track_links(self, sp_data): 56 | if isinstance(sp_data, dict): 57 | track = await self.track_get(sp_data.get("isrc")) 58 | try: 59 | id = track["message"]["body"]["track"]["commontrack_id"] 60 | except TypeError as e: 61 | return track 62 | 63 | track = track["message"]["body"]["track"] 64 | track["isrc"] = sp_data["isrc"] 65 | track["image"] = sp_data["image"] 66 | track["beta"] = str(track["track_share_url"]).replace("www.","com-beta.",1) 67 | 68 | return track 69 | else: 70 | return sp_data 71 | 72 | async def matcher_links(self, sp_data): 73 | id = sp_data["track"]["id"] 74 | track = await self.matcher_track(id) 75 | try: 76 | id = track["message"]["body"]["track"]["commontrack_id"] 77 | except TypeError as e: 78 | return track 79 | 80 | track = track["message"]["body"]["track"] 81 | track["isrc"] = sp_data["isrc"] 82 | track["image"] = sp_data["image"] 83 | track["beta"] = str(track["track_share_url"]).replace("www.","beta.",1) 84 | return track 85 | 86 | async def Tracks_Data(self, sp_data, split_check = False): 87 | links = [] 88 | tracks = await self.tracks_get(sp_data) 89 | 90 | 91 | if isinstance(sp_data[0], dict) and sp_data[0].get("track"): 92 | matchers = await self.tracks_matcher(sp_data) 93 | else: 94 | return tracks 95 | 96 | for i in range(len(tracks)): 97 | track = tracks[i] 98 | matcher = matchers[i] 99 | if split_check: 100 | links.append(track) 101 | continue 102 | 103 | # detecting what issues can facing the track 104 | if isinstance(track, dict) and isinstance(matcher, dict): 105 | 106 | # the get call and the matcher call are the same and both have valid response 107 | if (track["commontrack_id"] == matcher["commontrack_id"]): 108 | track["matcher_album"] = [ 109 | matcher["album_id"], 110 | matcher["album_name"], 111 | ] 112 | links.append(track) 113 | ''' when we get different data, the sp id attached to the matcher so we try to detect 114 | if the matcher one is vailid or it just a ISRC error. 115 | I used the probability here to choose the most accurate data to the spotify data 116 | ''' 117 | else: 118 | matcher_title = re.sub(r'[()-.]', '', matcher.get("track_name")) 119 | matcher_album = re.sub(r'[()-.]', '', matcher.get("album_name")) 120 | sp_title = re.sub(r'[()-.]', '', sp_data[i]["track"]["name"]) 121 | sp_album = re.sub(r'[()-.]', '', sp_data[i]["track"]["album"]["name"]) 122 | track_title = re.sub(r'[()-.]', '', track.get("track_name")) 123 | track_album = re.sub(r'[()-.]', '', track.get("album_name")) 124 | if (matcher.get("album_name") == sp_data[i]["track"]["album"]["name"] 125 | and matcher.get("track_name") == sp_data[i]["track"]["name"] 126 | or jellyfish.jaro_similarity(matcher_title.lower(), sp_title.lower()) 127 | * jellyfish.jaro_similarity(matcher_album.lower(), sp_album.lower()) >= 128 | jellyfish.jaro_similarity(track_title.lower(), sp_title.lower()) 129 | * jellyfish.jaro_similarity(track_album.lower(), sp_album.lower()) ): 130 | matcher["note"] = f'''This track may having two pages with the same ISRC, 131 | the other page from album.''' 134 | links.append(matcher) 135 | else: 136 | 137 | track["note"] = f'''This track may be facing an ISRC issue 138 | as the Spotify ID is connected to another page from album.''' 141 | links.append(track) 142 | continue 143 | 144 | elif isinstance(track, str) and isinstance(matcher, str): 145 | if re.search("404", track): 146 | track = """ 147 | The track hasn't been imported yet. Please try again after 1-5 minutes. 148 | Sometimes it may take longer, up to 15 minutes, depending on the MXM API and their servers. 149 | """ 150 | links.append(track) 151 | continue 152 | else: links.append(track) 153 | elif isinstance(track, str) and isinstance(matcher, dict): 154 | if matcher.get("album_name") == sp_data[i]["track"]["album"]["name"]: 155 | links.append(matcher) 156 | continue 157 | else: links.append(matcher) 158 | elif isinstance(track, dict) and isinstance(matcher, str): 159 | track["note"] = "This track may missing its Spotify id" 160 | links.append(track) 161 | else: 162 | links.append(track) 163 | return links 164 | 165 | async def tracks_get(self, data): 166 | coro = [self.Track_links(isrc) for isrc in data] 167 | tasks = [asyncio.create_task(c) for c in coro] 168 | tracks = await asyncio.gather(*tasks) 169 | return tracks 170 | 171 | async def tracks_matcher(self, data): 172 | coro = [self.matcher_links(isrc) for isrc in data] 173 | tasks = [asyncio.create_task(c) for c in coro] 174 | tracks = await asyncio.gather(*tasks) 175 | return tracks 176 | 177 | async def album_sp_id(self,link): 178 | site = re.search(r"musixmatch.com",link) 179 | match = re.search(r'album/([^?]+/[^?]+)|album/(\d+)|lyrics/([^?]+/[^?]+)', unquote(link)) 180 | if match and site: 181 | try: 182 | if match.group(1): 183 | album = await self.musixmatch.album_get(album_vanity_id=match.group(1)) 184 | elif match.group(2): 185 | album = await self.musixmatch.album_get(match.group(2)) 186 | else: 187 | track = await self.musixmatch.track_get(commontrack_vanity_id=match.group(3)) 188 | album_id = track["message"]["body"]["track"]["album_id"] 189 | album = await self.musixmatch.album_get(album_id) 190 | print(album) 191 | return {"album": album["message"]["body"]["album"]} 192 | except Asyncmxm.exceptions.MXMException as e: 193 | return {"error": str(e)} 194 | else: 195 | return {"error": "Unsupported link."} 196 | 197 | async def abstrack(self, id : int) -> tuple[dict,dict]: 198 | """Get the track and the album data from the abstrack.""" 199 | try: 200 | track = await self.musixmatch.track_get(commontrack_id=id) 201 | track = track["message"]["body"]["track"] 202 | album = await self.musixmatch.album_get(track["album_id"]) 203 | album = album["message"]["body"]["album"] 204 | return track, album 205 | except Asyncmxm.exceptions.MXMException as e: 206 | return {"error": str(e)}, {"error": str(e)} 207 | 208 | -------------------------------------------------------------------------------- /templates/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 13 | Spotify to Musixmatch Link 14 | 19 | 20 | 21 | 22 | 26 | 27 | 28 | 29 | 30 |
31 |
32 | ⚠️ 33 | 34 | End of Life Notice: This tool will become defunct on August 25, 2025, following the termination of the Musixmatch API free tier. You can still set your own API key. 35 | Learn More 36 | 37 | 38 |
39 |
40 | 41 | 65 | 66 | 67 | 77 | 78 | 79 |
80 |

Spotify to Musixmatch Link

81 |
82 | 83 | 89 | 90 |
91 |
92 |
93 | {% if tracks_data %} 94 |
95 | {% for track in tracks_data %} {% if track.isrc %} 96 |
97 |
98 | {{ track.track_name }} 99 |
100 |
{{ track.track_name }}
101 | {% if track.matcher_album %} 102 |

103 | Album: 104 | {{ track.matcher_album[1] }} 110 |

111 | {% else %} 112 |

113 | Album: 114 | {{ track.album_name }} 120 |

121 | {% endif %} 122 |

ISRC: {{ track.isrc }}

123 |

Track ID: {{ track.commontrack_id}}

124 |

125 | Link: 126 | Musixmatch, 132 | Beta Musixmatch 138 |

139 | {% if track.note %} 140 |

Note: {{ track.note|safe }}

141 | {% endif %} 142 |
143 |
144 |
145 | {% else %} 146 |
147 |

{{ track }}

148 |
149 | {% endif %} {% endfor %} 150 |
151 | {% endif %} {% if artist %} 152 |
153 | {% for album in artist %} 154 |
155 |
156 | {{ album.name }} 157 |
158 |
{{ album.name }}
159 |

type: {{ album.album_type}}

160 |

161 | Spotify: 162 | Album link 165 |

166 | Get MXM Links 171 |
172 |
173 |
174 | {% endfor %} 175 |
176 | {% endif %} 177 |
178 | 202 |
203 | × 204 |

205 | Importing new release songs now works, but it may take some additional time. 206 |

207 |
208 | 261 | 262 |
263 | 264 | 265 | -------------------------------------------------------------------------------- /static/styles.css: -------------------------------------------------------------------------------- 1 | @import url("https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@100;200;300;400;500;600;700;800&family=Nunito:ital,wght@0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;0,1000;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900;1,1000&family=Source+Sans+Pro:ital,wght@0,200;0,300;0,400;0,600;0,700;0,900;1,200;1,300;1,400;1,600;1,700;1,900&family=Source+Serif+Pro:ital,wght@1,200;1,300;1,400;1,600;1,700;1,900&family=Ubuntu:wght@300;400;500;700&display=swap"); 2 | 3 | body { 4 | background: #f5f5f5 url(bg-music.png) no-repeat top right; 5 | font-family: "Nunito", "Open Sans", sans-serif; 6 | } 7 | 8 | .container { 9 | max-width: 800px; 10 | margin: 0 auto; 11 | text-align: center; 12 | background: #fff; 13 | padding: 20px; 14 | border-radius: 10px; 15 | box-shadow: 0px 0px 10px #ccc; 16 | transition: background-color 0.3s ease; 17 | } 18 | 19 | h1 { 20 | font-size: 3em; 21 | font-weight: 600; 22 | margin-bottom: 20px; 23 | color: #1db954; 24 | } 25 | 26 | form { 27 | margin: 0 auto; 28 | width: 80%; 29 | display: flex; 30 | flex-wrap: wrap; 31 | align-items: center; 32 | justify-content: center; 33 | margin-bottom: 30px; 34 | } 35 | 36 | label { 37 | font-size: 1.2em; 38 | font-weight: 600; 39 | margin-right: 10px; 40 | } 41 | 42 | input[type="text"] { 43 | padding: 12px 20px; 44 | margin: 8px 0; 45 | box-sizing: border-box; 46 | border: 2px solid #ccc; 47 | border-radius: 4px; 48 | width: 100%; 49 | background-color: #f5f5f5; 50 | transition: border-color 0.3s ease; 51 | } 52 | 53 | input[type="text"]:focus { 54 | outline: none; 55 | border-color: #1db954; 56 | box-shadow: 0 0 5px #1db954; 57 | } 58 | 59 | input[type="text"]::placeholder { 60 | color: #999; 61 | } 62 | 63 | input[type="text"]:hover:not(:focus) { 64 | border-color: #999; 65 | } 66 | 67 | button[type="submit"] { 68 | width: 100%; 69 | padding: 14px 20px; 70 | margin: 8px 0; 71 | border: none; 72 | border-radius: 4px; 73 | background-color: #1db954; 74 | color: white; 75 | font-size: 1.2em; 76 | font-weight: 600; 77 | cursor: pointer; 78 | transition: background-color 0.3s ease; 79 | } 80 | 81 | button[type="submit"]:hover { 82 | background-color: #198649; 83 | } 84 | 85 | .output { 86 | text-align: left; 87 | margin-top: 30px; 88 | font-size: 1.2em; 89 | } 90 | 91 | .output p { 92 | margin: 10px 0; 93 | } 94 | 95 | .loading { 96 | display: none; 97 | margin: 0 auto; 98 | text-align: center; 99 | border: 8px solid #f3f3f3; 100 | border-radius: 50%; 101 | width: 40px; 102 | height: 40px; 103 | animation: spin 2s linear infinite; 104 | box-shadow: 0 4px 0 0 #5fe15b; 105 | } 106 | 107 | .error { 108 | color: red; 109 | display: none; 110 | margin: 10px 0; 111 | text-align: center; 112 | } 113 | 114 | .logo { 115 | display: flex; 116 | justify-content: space-between; 117 | align-items: center; 118 | margin-bottom: 20px; 119 | } 120 | 121 | .logo img { 122 | width: 50px; 123 | height: 50px; 124 | } 125 | 126 | .logo img:first-child { 127 | margin-right: 10px; 128 | } 129 | 130 | .logo img:last-child { 131 | margin-left: 10px; 132 | } 133 | 134 | .card { 135 | background-color: #fff; 136 | border-radius: 10px; 137 | box-shadow: 0px 0px 10px #ccc; 138 | margin: 20px; 139 | padding: 20px; 140 | display: flex; 141 | flex-direction: row; 142 | align-items: center; 143 | transition: transform 0.3s ease; 144 | } 145 | 146 | .card img { 147 | width: 150px; 148 | margin-right: 20px; 149 | } 150 | 151 | .card-details { 152 | flex: 1; 153 | margin-top: -10px; 154 | } 155 | 156 | .card-title { 157 | margin-bottom: 5px; 158 | } 159 | 160 | .card-text { 161 | margin-bottom: 0; 162 | } 163 | 164 | .card-text:last-child { 165 | margin-bottom: 5px; 166 | } 167 | 168 | .card-link { 169 | margin-top: 10px; 170 | text-decoration: none; 171 | color: #ff6050; 172 | transition: color 0.3s ease; 173 | position: relative; 174 | } 175 | 176 | .card-link:hover { 177 | margin-top: 10px; 178 | text-decoration: none; 179 | color: #f95546; 180 | } 181 | 182 | .card-link::before { 183 | content: ""; 184 | position: absolute; 185 | bottom: -2px; 186 | left: 0; 187 | width: 100%; 188 | height: 2px; 189 | background-color: #ff6050; 190 | transform: scaleX(0); 191 | transition: transform 0.3s ease; 192 | } 193 | 194 | .card-link:hover::before { 195 | transform: scaleX(1); 196 | } 197 | 198 | @keyframes spin { 199 | 0% { 200 | transform: rotate(0deg); 201 | } 202 | 100% { 203 | transform: rotate(360deg); 204 | } 205 | } 206 | 207 | .instructions { 208 | position: sticky; 209 | top: 10px; 210 | right: 10px; 211 | z-index: 1; 212 | text-align: center; 213 | } 214 | 215 | .instructions a { 216 | display: inline-block; 217 | padding: 10px 20px; 218 | border: 1px solid #ccc; 219 | border-radius: 5px; 220 | background-color: #f8f8f8; 221 | text-decoration: none; 222 | color: #333; 223 | font-size: 14px; 224 | } 225 | 226 | .instructions a:hover { 227 | background-color: #ddd; 228 | } 229 | 230 | /* Modal styles */ 231 | .modal { 232 | display: none; 233 | position: fixed; 234 | z-index: 1; 235 | left: 0; 236 | top: 0; 237 | width: 100%; 238 | height: 100%; 239 | overflow: auto; 240 | background-color: rgba(0, 0, 0, 0.5); 241 | } 242 | 243 | .modal-content { 244 | background-color: #fefefe; 245 | margin: 10% auto; 246 | padding: 20px; 247 | border: 1px solid #888; 248 | width: 80%; 249 | max-width: 600px; 250 | } 251 | 252 | .close { 253 | display: block; 254 | position: absolute; 255 | top: 10px; 256 | right: 10px; 257 | font-size: 24px; 258 | font-weight: bold; 259 | color: #aaa; 260 | text-shadow: 1px 1px #fff; /* add a subtle text shadow */ 261 | opacity: 0.7; /* reduce opacity to make it semi-transparent */ 262 | transition: opacity 0.2s ease-in-out; /* add a transition effect */ 263 | } 264 | 265 | .close:hover, 266 | .close:focus { 267 | color: #000; 268 | text-decoration: none; 269 | cursor: pointer; 270 | opacity: 1; /* increase opacity on hover/focus */ 271 | } 272 | 273 | .note { 274 | position: fixed; 275 | bottom: 10px; 276 | left: 10px; 277 | z-index: 10; 278 | background-color: #fff; 279 | padding: 10px; 280 | border: 1px solid #ccc; 281 | border-radius: 5px; 282 | max-width: 300px; 283 | box-shadow: 0px 0px 5px 0px rgba(0, 0, 0, 0.2); 284 | display: block; 285 | } 286 | 287 | .note-text { 288 | font-size: 16px; 289 | font-family: sans-serif; 290 | line-height: 1.5; 291 | color: #333; 292 | } 293 | 294 | .container:hover { 295 | background-color: #f8f8f8; 296 | } 297 | 298 | .card:hover { 299 | transform: scale(1.02); 300 | } 301 | 302 | .button-container { 303 | position: relative; 304 | width: 100%; 305 | height: 100%; 306 | } 307 | 308 | .button-container:hover .card-link { 309 | color: #1db954; 310 | } 311 | 312 | .button-container::before { 313 | content: ""; 314 | position: absolute; 315 | top: 0; 316 | left: 0; 317 | width: 100%; 318 | height: 100%; 319 | background-color: rgba(29, 185, 84, 0.2); 320 | opacity: 0; 321 | transition: opacity 0.3s ease; 322 | } 323 | 324 | .button-container:hover::before { 325 | opacity: 1; 326 | } 327 | 328 | #offline-div { 329 | display: none; 330 | } 331 | 332 | .reach a { 333 | text-decoration: none; 334 | color: rgb(0, 128, 255); 335 | } 336 | table { 337 | width: 100%; 338 | border-collapse: collapse; 339 | margin-top: 20px; 340 | } 341 | 342 | th, 343 | td { 344 | padding: 10px; 345 | text-align: left; 346 | border-bottom: 1px solid #ddd; 347 | } 348 | 349 | th { 350 | background-color: #f2f2f2; 351 | font-weight: bold; 352 | color: #333; 353 | } 354 | 355 | td:first-child { 356 | font-weight: bold; 357 | color: #555; 358 | } 359 | 360 | tr:nth-child(even) { 361 | background-color: #f9f9f9; 362 | } 363 | 364 | tr:hover { 365 | background-color: #e9e9e9; 366 | } 367 | 368 | td:last-child { 369 | font-style: italic; 370 | text-align: center; 371 | } 372 | 373 | /* Navigation Bar */ 374 | .navbar { 375 | max-width: 800px; 376 | margin: 0 auto; 377 | background-color: #1db954; 378 | padding: 20px; 379 | border-radius: 10px; 380 | display: flex; 381 | justify-content: center; 382 | } 383 | 384 | .navbar ul { 385 | list-style: none; 386 | margin: 0; 387 | padding: 0; 388 | display: flex; 389 | } 390 | 391 | .navbar li { 392 | margin: 0 15px; 393 | } 394 | 395 | .navbar a { 396 | text-decoration: none; 397 | color: #fff; 398 | font-size: 1.03em; 399 | font-weight: bold; 400 | padding: 5px 5px; 401 | border-radius: 5px; 402 | transition: background-color 0.3s ease; 403 | } 404 | 405 | .navbar a:hover { 406 | background-color: rgba(255, 255, 255, 0.2); 407 | } 408 | 409 | /* Active link style (optional) */ 410 | .navbar a.active { 411 | background-color: rgba(255, 255, 255, 0.5); 412 | } 413 | 414 | /* Media Query for screens with a max-width of 768px */ 415 | @media (max-width: 768px) { 416 | .container { 417 | max-width: 100%; 418 | padding: 10px; 419 | } 420 | 421 | .card { 422 | flex-direction: column; 423 | align-items: flex-start; 424 | } 425 | 426 | .card img { 427 | width: 100%; 428 | margin-right: 0; 429 | margin-bottom: 10px; 430 | } 431 | 432 | .card-details { 433 | margin-top: 0; 434 | text-align: left; 435 | } 436 | 437 | .navbar { 438 | padding: 10px; 439 | } 440 | 441 | .navbar ul { 442 | flex-direction: column; 443 | align-items: center; 444 | } 445 | 446 | .navbar li { 447 | margin: 5px 0; 448 | } 449 | } 450 | 451 | /* EOL banner */ 452 | .eol-banner { 453 | background: #fff3cd; 454 | padding: 16px 0; 455 | position: sticky; 456 | top: 0; 457 | z-index: 1000; 458 | box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); 459 | border-radius: 12px; 460 | margin-bottom: 20px; 461 | } 462 | 463 | .eol-content { 464 | max-width: 1200px; 465 | margin: 0 auto; 466 | display: flex; 467 | align-items: center; 468 | justify-content: space-between; 469 | padding: 0 20px; 470 | font-size: 14px; 471 | line-height: 1.4; 472 | } 473 | 474 | .eol-icon { 475 | font-size: 18px; 476 | margin-right: 10px; 477 | flex-shrink: 0; 478 | } 479 | 480 | .eol-text { 481 | flex: 1; 482 | margin-right: 15px; 483 | color: #856404; 484 | } 485 | 486 | .eol-learn-more { 487 | color: #856404; 488 | text-decoration: underline; 489 | font-weight: 600; 490 | margin-left: 8px; 491 | } 492 | 493 | .eol-learn-more:hover { 494 | color: #533f03; 495 | } 496 | 497 | .eol-close { 498 | background: none; 499 | border: none; 500 | font-size: 20px; 501 | font-weight: bold; 502 | color: #856404; 503 | cursor: pointer; 504 | padding: 0; 505 | width: 24px; 506 | height: 24px; 507 | display: flex; 508 | align-items: center; 509 | justify-content: center; 510 | border-radius: 50%; 511 | } 512 | 513 | .eol-close:hover { 514 | background: rgba(133, 100, 4, 0.1); 515 | } 516 | 517 | /* mobile optimization */ 518 | @media (max-width: 768px) { 519 | .eol-banner { 520 | padding: 12px 0; 521 | border-radius: 8px; 522 | } 523 | 524 | .eol-content { 525 | padding: 0 16px; 526 | font-size: 13px; 527 | flex-direction: row; 528 | align-items: center; 529 | gap: 0; 530 | } 531 | 532 | .eol-text { 533 | margin-right: 10px; 534 | margin-bottom: 0; 535 | } 536 | 537 | .eol-learn-more { 538 | margin-left: 6px; 539 | } 540 | 541 | .eol-close { 542 | position: static; 543 | width: 20px; 544 | height: 20px; 545 | font-size: 16px; 546 | } 547 | } 548 | 549 | @media (max-width: 480px) { 550 | .eol-content { 551 | font-size: 12px; 552 | padding: 0 12px; 553 | } 554 | 555 | .eol-icon { 556 | font-size: 16px; 557 | margin-right: 8px; 558 | } 559 | 560 | .eol-text { 561 | line-height: 1.3; 562 | } 563 | 564 | .eol-learn-more { 565 | font-size: 11px; 566 | } 567 | 568 | .eol-close { 569 | width: 18px; 570 | height: 18px; 571 | font-size: 14px; 572 | } 573 | } 574 | -------------------------------------------------------------------------------- /app.py: -------------------------------------------------------------------------------- 1 | import re 2 | import json 3 | import datetime 4 | import base64 5 | import hmac 6 | import hashlib 7 | import os 8 | import aiohttp 9 | from flask import Flask, request, render_template, make_response 10 | from asgiref.wsgi import WsgiToAsgi 11 | from mxm import MXM 12 | from spotify import Spotify 13 | 14 | 15 | secret_key_value = os.environ.get("secret_key") 16 | 17 | SECRET_KEY = secret_key_value 18 | 19 | SECRET_KEY = SECRET_KEY.encode('utf-8') 20 | 21 | 22 | def generate_token(payload): 23 | header = {'alg': 'HS256', 'typ': 'JWT'} 24 | encoded_header = base64.urlsafe_b64encode( 25 | json.dumps(header).encode('utf-8')).rstrip(b'=') 26 | encoded_payload = base64.urlsafe_b64encode( 27 | json.dumps(payload).encode('utf-8')).rstrip(b'=') 28 | 29 | signature = hmac.new(SECRET_KEY, encoded_header + 30 | b'.' + encoded_payload, hashlib.sha256).digest() 31 | encoded_signature = base64.urlsafe_b64encode(signature).rstrip(b'=') 32 | 33 | return encoded_header + b'.' + encoded_payload + b'.' + encoded_signature 34 | 35 | 36 | def verify_token(token): 37 | encoded_header, encoded_payload, encoded_signature = token.split('.') 38 | 39 | header = base64.urlsafe_b64decode(encoded_header + '==').decode('utf-8') 40 | payload = base64.urlsafe_b64decode(encoded_payload + '==').decode('utf-8') 41 | 42 | expected_signature = hmac.new( 43 | SECRET_KEY, (encoded_header + '.' + encoded_payload).encode('utf-8'), hashlib.sha256).digest() 44 | expected_encoded_signature = base64.urlsafe_b64encode( 45 | expected_signature).rstrip(b'=') 46 | 47 | if expected_encoded_signature != encoded_signature.encode('utf-8'): 48 | return False 49 | 50 | payload = json.loads(payload) 51 | return payload 52 | 53 | def jwt_ref(resp,payload): 54 | current_time = datetime.datetime.now() 55 | payload["exp"] = int( 56 | (current_time + datetime.timedelta(days=3)).timestamp()) 57 | new_token = generate_token(payload) 58 | expire_date = current_time + datetime.timedelta(days=3) 59 | resp.set_cookie("api_token", new_token.decode('utf-8'), expires=expire_date) 60 | return resp 61 | 62 | 63 | class StartAiohttp: 64 | session = None 65 | 66 | def __init__(self, limit, limit_per_host) -> None: 67 | self.limit = limit 68 | self.limit_per_host = limit_per_host 69 | 70 | def start_session(self): 71 | self.close_session() 72 | connector = aiohttp.TCPConnector( 73 | limit=self.limit, limit_per_host=self.limit_per_host) 74 | self.session = aiohttp.ClientSession(connector=connector) 75 | 76 | def get_session(self): 77 | return self.session 78 | 79 | async def close_session(self): 80 | if self.session: 81 | await self.session.close() 82 | self.session = None 83 | 84 | 85 | client = StartAiohttp(7, 7) 86 | 87 | 88 | app = Flask(__name__) 89 | sp = Spotify() 90 | 91 | 92 | @app.route('/', methods=['GET']) 93 | async def index(): 94 | if request.cookies.get('api_key'): 95 | payload = {"mxm-key": request.cookies.get('api_key'), "exp": int( 96 | (datetime.datetime.now() + datetime.timedelta(days=3)).timestamp())} 97 | token = generate_token(payload) 98 | 99 | resp = make_response(render_template( 100 | "index.html")) 101 | expire_date = datetime.datetime.now() + datetime.timedelta(hours=1) 102 | resp.delete_cookie("api_key") 103 | resp.set_cookie("api_token", token, expires=expire_date) 104 | return resp 105 | 106 | 107 | link = request.args.get('link') 108 | key = None 109 | token = request.cookies.get('api_token') 110 | if link: 111 | if token: 112 | payload = verify_token(token) 113 | if payload: 114 | key = payload.get("mxm-key") 115 | 116 | client.start_session() 117 | mxm = MXM(key, session=client.get_session()) 118 | try: 119 | if (len(link) < 12): 120 | return render_template('index.html', tracks_data=["Wrong Spotify Link Or Wrong ISRC"]) 121 | elif re.search(r'artist/(\w+)', link): 122 | return render_template('index.html', artist=sp.artist_albums(link, [])) 123 | else: 124 | sp_data = sp.get_isrc(link) if len(link) > 12 else [ 125 | {"isrc": link, "image": None}] 126 | except Exception as e: 127 | return render_template('index.html', tracks_data=[str(e)]) 128 | 129 | mxmLinks = await mxm.Tracks_Data(sp_data) 130 | if isinstance(mxmLinks, str): 131 | return mxmLinks 132 | 133 | await client.close_session() 134 | 135 | return render_template('index.html', tracks_data=mxmLinks) 136 | 137 | # refresh the token every time the user enter the site 138 | if token: 139 | payload = verify_token(token) 140 | resp = make_response(render_template( 141 | "index.html")) 142 | resp = jwt_ref(resp,payload) 143 | return resp 144 | 145 | return render_template('index.html') 146 | 147 | 148 | @app.route('/split', methods=['GET']) 149 | async def split(): 150 | link = request.args.get('link') 151 | link2 = request.args.get('link2') 152 | key = None 153 | if link and link2: 154 | token = request.cookies.get('api_token') 155 | if token: 156 | payload = verify_token(token) 157 | if payload: 158 | key = payload.get("mxm-key") 159 | client.start_session() 160 | mxm = MXM(key, session=client.get_session()) 161 | match = re.search(r'open.spotify.com', 162 | link) and re.search(r'track', link) 163 | match = match and re.search( 164 | r'open.spotify.com', link2) and re.search(r'track', link2) 165 | if match: 166 | sp_data1 = sp.get_isrc(link) 167 | sp_data2 = sp.get_isrc(link2) 168 | track1 = await mxm.Tracks_Data(sp_data1, True) 169 | track1 = track1[0] 170 | if isinstance(track1, str): 171 | return render_template('split.html', error="track1: " + track1) 172 | track2 = await mxm.Tracks_Data(sp_data2, True) 173 | track2 = track2[0] 174 | if isinstance(track2, str): 175 | return render_template('split.html', error="track2: " + track1) 176 | await client.close_session() 177 | track1["track"] = sp_data1[0]["track"] 178 | track2["track"] = sp_data2[0]["track"] 179 | try: 180 | if track1["isrc"] != track2["isrc"] and track1["commontrack_id"] == track2["commontrack_id"]: 181 | message = f"""Can be splitted
182 | you can c/p:
183 | :mxm: MXM Page
184 | :spotify: {track1["track"]["name"]}, 185 | :isrc: {track1["isrc"]}
186 | :spotify: {track2["track"]["name"]}, 187 | :isrc: {track2["isrc"]} 188 | """ 189 | elif track1["isrc"] == track2["isrc"] and track1["commontrack_id"] == track2["commontrack_id"]: 190 | message = "Can not be splitted as they have the Same ISRC" 191 | else: 192 | message = "They have different Pages" 193 | except: 194 | return render_template('split.html', error="Something went wrong") 195 | 196 | return render_template('split.html', split_result={"track1": track1, "track2": track2}, message=message) 197 | else: 198 | return render_template('split.html', error="Wrong Spotify Link") 199 | 200 | else: 201 | return render_template('split.html') 202 | 203 | 204 | @app.route('/spotify', methods=['GET']) 205 | def isrc(): 206 | link = request.args.get('link') 207 | if link: 208 | match = re.search(r'open.spotify.com', link) and re.search( 209 | r'track|album', link) 210 | if match: 211 | return render_template('isrc.html', tracks_data=sp.get_isrc(link)) 212 | 213 | else: 214 | # the link is an isrc code 215 | if len(link) == 12: 216 | # search by isrc 217 | return render_template('isrc.html', tracks_data=sp.search_by_isrc(link)) 218 | return render_template('isrc.html', tracks_data=["Wrong Spotify Link"]) 219 | else: 220 | return render_template('isrc.html') 221 | 222 | 223 | @app.route('/api', methods=['GET']) 224 | async def setAPI(): 225 | key = request.args.get('key') 226 | delete = request.args.get("delete_key") 227 | 228 | # Get the existing token from the cookie 229 | token = request.cookies.get('api_token') 230 | if token: 231 | payload = verify_token(token) 232 | if payload: 233 | key = payload.get("mxm-key") 234 | censored_key = '*' * len(key) if key else None 235 | 236 | # refresh the token each time the user enter the "/api" 237 | resp = make_response(render_template( 238 | "api.html", key=censored_key)) 239 | resp = jwt_ref(resp,payload) 240 | return resp 241 | 242 | 243 | if key: 244 | # check the key 245 | client.start_session() 246 | mxm = MXM(key, session=client.get_session()) 247 | sp_data = [{"isrc": "DGA072332812", "image": None}] 248 | 249 | # Call the Tracks_Data method with the appropriate parameters 250 | mxmLinks = await mxm.Tracks_Data(sp_data) 251 | print(mxmLinks) 252 | await client.close_session() 253 | 254 | if isinstance(mxmLinks[0], str): 255 | return render_template("api.html", error="Please Enter A Valid Key") 256 | 257 | payload = {"mxm-key": key, "exp": int( 258 | (datetime.datetime.now() + datetime.timedelta(hours=1)).timestamp())} 259 | token = generate_token(payload) 260 | 261 | resp = make_response(render_template( 262 | "api.html", key="Token Generated")) 263 | expire_date = datetime.datetime.now() + datetime.timedelta(hours=1) 264 | resp.set_cookie("api_token", token.decode('utf-8'), expires=expire_date) 265 | return resp 266 | 267 | elif delete: 268 | resp = make_response(render_template("api.html")) 269 | resp.delete_cookie("api_token") 270 | return resp 271 | 272 | else: 273 | return render_template("api.html", key=None) 274 | 275 | 276 | @app.route('/mxm', methods=['GET']) 277 | async def mxm_to_sp(): 278 | link = request.args.get('link') 279 | key = None 280 | if link: 281 | token = request.cookies.get('api_token') 282 | if token: 283 | payload = verify_token(token) 284 | if payload: 285 | key = payload.get("mxm-key") 286 | 287 | client.start_session() 288 | mxm = MXM(key, session=client.get_session()) 289 | album = await mxm.album_sp_id(link) 290 | await client.close_session() 291 | return render_template("mxm.html", album=album.get("album"), error=album.get("error")) 292 | else: 293 | return render_template("mxm.html") 294 | 295 | @app.route('/abstrack', methods=['GET']) 296 | async def abstrack() -> str: 297 | """ Get the track data from the abstract track """ 298 | id = request.args.get('id') 299 | key = None 300 | if id: 301 | token = request.cookies.get('api_token') 302 | if token: 303 | payload = verify_token(token) 304 | if payload: 305 | key = payload.get("mxm-key") 306 | if not re.match("^[0-9]+$",id): 307 | return render_template("abstrack.html", error = "Invalid input!") 308 | client.start_session() 309 | mxm = MXM(key, session=client.get_session()) 310 | track, album = await mxm.abstrack(id) 311 | await client.close_session() 312 | return render_template("abstrack.html", track=track, album= album, error=track.get("error")) 313 | else: 314 | return render_template("abstrack.html") 315 | 316 | 317 | asgi_app = WsgiToAsgi(app) 318 | if __name__ == '__main__': 319 | import asyncio 320 | from hypercorn.config import Config 321 | from hypercorn.asyncio import serve 322 | asyncio.run(serve(app, Config())) 323 | # app.run(debug=True) 324 | -------------------------------------------------------------------------------- /Asyncmxm/client.py: -------------------------------------------------------------------------------- 1 | """ A simple Async Python library for the Musixmatch Web API """ 2 | 3 | 4 | 5 | import asyncio 6 | import aiohttp 7 | import json 8 | 9 | from Asyncmxm.exceptions import MXMException 10 | 11 | class Musixmatch(object): 12 | """""" 13 | 14 | max_retries = 3 15 | default_retry_codes = (429, 500, 502, 503, 504) 16 | 17 | def __init__( 18 | self, 19 | API_key, 20 | limit = 4, 21 | requests_session=None, 22 | retries=max_retries, 23 | requests_timeout=5, 24 | backoff_factor=0.3, 25 | ): 26 | """ 27 | Create a Musixmatch Client. 28 | :param api_key: The API key, Get one at https://developer.musixmatch.com/signup 29 | :param requests_session: A Requests session object or a truthy value to create one. 30 | :param retries: Total number of retries to allow 31 | :param requests_timeout: Stop waiting for a response after a given number of seconds 32 | :param backoff: Factor to apply between attempts after the second try 33 | """ 34 | 35 | self._url = "https://api.musixmatch.com/ws/1.1/" 36 | self._key = API_key 37 | self.requests_timeout = requests_timeout 38 | self.backoff_factor = backoff_factor 39 | self.retries = retries 40 | self.limit = limit 41 | 42 | if isinstance(requests_session, aiohttp.ClientSession): 43 | self._session = requests_session 44 | else: 45 | self._build_session() 46 | 47 | def _build_session(self): 48 | connector = aiohttp.TCPConnector(limit=self.limit, limit_per_host=self.limit) 49 | self._session = aiohttp.ClientSession(connector=connector,loop=asyncio.get_event_loop()) 50 | ''' 51 | async def __aexit__(self, exc_type, exc_value, exc_tb): 52 | """Make sure the connection gets closed""" 53 | await self._session.close() 54 | ''' 55 | 56 | async def _api_call(self, method, api_method, params = None): 57 | url = self._url + api_method 58 | if params: 59 | params["apikey"] = self._key 60 | else: 61 | params = {"apikey": self._key} 62 | 63 | retries = 0 64 | 65 | while retries < self.max_retries: 66 | try: 67 | #print(params) 68 | async with self._session.request(method=method, url=str(url), params = params) as response: 69 | 70 | response.raise_for_status() 71 | res = await response.text() 72 | print(res) 73 | res = json.loads(res) 74 | status_code = res["message"]["header"]["status_code"] 75 | if status_code == 200: 76 | return res 77 | else: 78 | retries = self.max_retries 79 | hint = res["message"]["header"].get("hint") or None 80 | raise MXMException(status_code,hint) 81 | except (aiohttp.ClientError, asyncio.TimeoutError) as e: 82 | retries +=1 83 | await asyncio.sleep(self.backoff_factor * retries) 84 | continue 85 | raise Exception("API request failed after retries") 86 | 87 | 88 | 89 | async def track_get( 90 | self, 91 | commontrack_id=None, 92 | track_id=None, 93 | track_isrc=None, 94 | commontrack_vanity_id=None, 95 | track_spotify_id=None, 96 | track_itunes_id=None, 97 | ): 98 | """ 99 | Get a track info from their database by objects 100 | Just one Parameter is required 101 | :param commontrack_id: Musixmatch commontrack id 102 | :param track_id: Musixmatch track id 103 | :param track_isrc: A valid ISRC identifier 104 | :param commontrack_vanity_id: Musixmatch vanity id ex "Imagine-Dragons/Demons" 105 | :param track_spotify_id: Spotify Track ID 106 | :param track_itunes_id: Apple track ID 107 | """ 108 | 109 | params = {k: v for k, v in locals().items() if v is not None and k !='self'} 110 | return await self._api_call("get", "track.get", params) 111 | 112 | async def matcher_track_get( 113 | self, 114 | q_track=None, 115 | q_artist=None, 116 | q_album=None, 117 | commontrack_id=None, 118 | track_id=None, 119 | track_isrc=None, 120 | commontrack_vanity_id=None, 121 | track_spotify_id=None, 122 | track_itunes_id=None, 123 | **filters, 124 | ): 125 | """ 126 | Match your song against Musixmatch database. 127 | 128 | QUERYING: (At least one required) 129 | :param q_track: search for a text string among song titles 130 | :param q_artist: search for a text string among artist names 131 | :param q_album: The song album 132 | 133 | Objects: (optional) 134 | :param commontrack_id: Musixmatch commontrack id 135 | :param track_id: Musixmatch track id 136 | :param track_isrc: A valid ISRC identifier 137 | :param commontrack_vanity_id: Musixmatch vanity id ex "Imagine-Dragons/Demons" 138 | :param track_spotify_id: Spotify Track ID 139 | :param track_itunes_id: Apple track ID 140 | 141 | FILTERING: (optional) 142 | :param f_has_lyrics: Filter by objects with available lyrics 143 | :param f_is_instrumental: Filter instrumental songs 144 | :param f_has_subtitle: Filter by objects with available subtitles (1 or 0) 145 | :param f_music_genre_id: Filter by objects with a specific music category 146 | :param f_subtitle_length: Filter subtitles by a given duration in seconds 147 | :param f_subtitle_length_max_deviation: Apply a deviation to a given subtitle duration (in seconds) 148 | :param f_lyrics_language: Filter the tracks by lyrics language 149 | :param f_artist_id: Filter by objects with a given Musixmatch artist_id 150 | :param f_artist_mbid: Filter by objects with a given musicbrainz artist id 151 | 152 | """ 153 | 154 | params = {k: v for k, v in locals().items() if v is not None and k !='self' and k != "filters"} 155 | params = {**params, **filters} 156 | return await self._api_call("get", "matcher.track.get", params) 157 | 158 | async def chart_artists_get(self, page, page_size, country="US"): 159 | """ 160 | This api provides you the list of the top artists of a given country. 161 | 162 | :param page: Define the page number for paginated results 163 | :param page_size: Define the page size for paginated results. Range is 1 to 100. 164 | :param country: A valid country code (default US) 165 | """ 166 | params = {k: v for k, v in locals().items() if v is not None and k !='self'} 167 | return await self._api_call("get", "chart.artists.get", params) 168 | 169 | async def chart_tracks_get( 170 | self, chart_name,page = 1, page_size = 100, f_has_lyrics = 1, country="US" 171 | ): 172 | """ 173 | This api provides you the list of the top artists of a given country. 174 | 175 | :param page: Define the page number for paginated results 176 | :param page_size: Define the page size for paginated results. Range is 1 to 100. 177 | :param chart_name: Select among available charts: 178 | top : editorial chart 179 | hot : Most viewed lyrics in the last 2 hours 180 | mxmweekly : Most viewed lyrics in the last 7 days 181 | mxmweekly_new : Most viewed lyrics in the last 7 days limited to new releases only 182 | :param f_has_lyrics: When set, filter only contents with lyrics, Takes (0 or 1) 183 | :param country: A valid country code (default US) 184 | """ 185 | params = {k: v for k, v in locals().items() if v is not None and k !='self'} 186 | return await self._api_call("get", "chart.tracks.get", params) 187 | 188 | async def track_search(self, page = 1, page_size = 100, **params): 189 | """ 190 | Search for track in Musixmatch database. 191 | 192 | :param q_track: The song title 193 | :param q_artist: The song artist 194 | :param q_lyrics: Any word in the lyrics 195 | :param q_track_artist: Any word in the song title or artist name 196 | :param q_writer: Search among writers 197 | :param q: Any word in the song title or artist name or lyrics 198 | :param f_artist_id: When set, filter by this artist id 199 | :param f_music_genre_id: When set, filter by this music category id 200 | :param f_lyrics_language: Filter by the lyrics language (en,it,..) 201 | :param f_has_lyrics: When set, filter only contents with lyrics 202 | :param f_track_release_group_first_release_date_min: 203 | When set, filter the tracks with release date newer than value, format is YYYYMMDD 204 | :param f_track_release_group_first_release_date_max: 205 | When set, filter the tracks with release date older than value, format is YYYYMMDD 206 | :param s_artist_rating: Sort by our popularity index for artists (asc|desc) 207 | :param s_track_rating: Sort by our popularity index for tracks (asc|desc) 208 | :param quorum_factor: Search only a part of the given query string.Allowed range is (0.1 - 0.9) 209 | :param page: Define the page number for paginated results 210 | :param page_size: Define the page size for paginated results. Range is 1 to 100. 211 | """ 212 | locs = locals().copy() 213 | locs.pop("params") 214 | params = {**params, **locs} 215 | return await self._api_call("get", "track.search", params) 216 | 217 | async def track_lyrics_get(self, commontrack_id=None, track_id=None, track_spotify_id=None): 218 | """ 219 | Get the lyrics of a track. 220 | 221 | :param commontrack_id: Musixmatch commontrack id 222 | :param track_id: Musixmatch track id 223 | :param track_spotify_id: Spotify Track ID 224 | """ 225 | params = {k: v for k, v in locals().items() if v is not None and k !='self'} 226 | return await self._api_call("get", "track.lyrics.get", params) 227 | 228 | async def track_lyrics_post(self, lyrics:str, commontrack_id=None, track_isrc=None): 229 | """ 230 | Submit a lyrics to Musixmatch database. 231 | 232 | :param lyrics: The lyrics to be submitted 233 | :param commontrack_id: The track commontrack 234 | :param track_isrc: A valid ISRC identifier 235 | """ 236 | params = {k: v for k, v in locals().items() if v is not None and k !='self'} 237 | return await self._api_call("post", "track.lyrics.post", params) 238 | 239 | async def track_lyrics_mood_get(self,commontrack_id=None, track_isrc=None): 240 | """ 241 | Get the mood list (and raw value that generated it) of a lyrics 242 | 243 | :note: Not available for the free plan 244 | 245 | :param commontrack_id: The track commontrack 246 | :param track_isrc: A valid ISRC identifier 247 | """ 248 | params = {k: v for k, v in locals().items() if v is not None and k !='self'} 249 | return await self._api_call("get", "track.lyrics.mood.get", params) 250 | 251 | async def track_snippet_get(self,commontrack_id=None, 252 | track_id=None, 253 | track_isrc=None, 254 | track_spotify_id=None 255 | ): 256 | """ 257 | Get the snippet for a given track. 258 | A lyrics snippet is a very short representation of a song lyrics. 259 | It's usually twenty to a hundred characters long 260 | 261 | :param commontrack_id: The track commontrack 262 | :param track_id: Musixmatch track id 263 | :param track_isrc: A valid ISRC identifier 264 | :param track_spotify_id: Spotify Track ID 265 | """ 266 | 267 | params = {k: v for k, v in locals().items() if v is not None and k !='self'} 268 | return await self._api_call("get", "track.snippet.get", params) 269 | 270 | async def track_subtitle_get(self,commontrack_id=None, 271 | track_id=None, 272 | subtitle_format = None, 273 | track_isrc=None, 274 | f_subtitle_length = None, 275 | f_subtitle_length_max_deviation = None 276 | ): 277 | """ 278 | Retreive the subtitle of a track. 279 | Return the subtitle of a track in LRC or DFXP format. 280 | 281 | :param commontrack_id: The track commontrack 282 | :param track_id: Musixmatch track id 283 | :param track_isrc: A valid ISRC identifier 284 | :param subtitle_format: The format of the subtitle (lrc,dfxp,stledu). Default to lrc 285 | :param f_subtitle_length: The desired length of the subtitle (seconds) 286 | :param f_subtitle_length_max_deviation: The maximum deviation allowed from the f_subtitle_length (seconds) 287 | """ 288 | 289 | params = {k: v for k, v in locals().items() if v is not None and k !='self'} 290 | return await self._api_call("get", "track.subtitle.get", params) 291 | 292 | async def track_richsync_get(self,commontrack_id=None, 293 | track_id=None, 294 | track_isrc=None, 295 | track_spotify_id=None, 296 | f_richsync_length = None, 297 | f_richsync_length_max_deviation = None 298 | ): 299 | """ 300 | A rich sync is an enhanced version of the standard sync. 301 | 302 | :param commontrack_id: The track commontrack 303 | :param track_id: Musixmatch track id 304 | :param track_isrc: A valid ISRC identifier 305 | :param track_spotify_id: Spotify Track ID 306 | :param f_richsync_length: The desired length of the sync (seconds) 307 | :param f_richsync_length_max_deviation: The maximum deviation allowed from the f_sync_length (seconds) 308 | """ 309 | 310 | params = {k: v for k, v in locals().items() if v is not None and k !='self'} 311 | return await self._api_call("get", "track.richsync.get", params) 312 | 313 | async def track_lyrics_translation_get(self,commontrack_id=None, 314 | track_id=None, 315 | track_isrc=None, 316 | track_spotify_id=None, 317 | selected_language = None, 318 | min_completed = None 319 | ): 320 | """ 321 | Get a translated lyrics for a given language 322 | 323 | :param commontrack_id: The track commontrack 324 | :param track_id: Musixmatch track id 325 | :param track_isrc: A valid ISRC identifier 326 | :param track_spotify_id: Spotify Track ID 327 | :param selected_language: he language of the translated lyrics (ISO 639-1) 328 | :param min_completed: Teal from 0 to 1. If present, 329 | only the tracks with a translation ratio over this specific value, 330 | for a given language, are returned Set it to 1 for completed translation only, to 0.7 for a mimimum of 70% complete translation. 331 | :param f_subtitle_length: The desired length of the subtitle (seconds) 332 | :param f_subtitle_length_max_deviation: The maximum deviation allowed from the f_subtitle_length (seconds) 333 | """ 334 | 335 | params = {k: v for k, v in locals().items() if v is not None and k !='self'} 336 | return await self._api_call("get", "track.lyrics.translation.get", params) 337 | 338 | async def track_subtitle_translation_get(self,commontrack_id=None, 339 | track_id=None, 340 | track_isrc=None, 341 | track_spotify_id=None, 342 | selected_language = None, 343 | min_completed = None, 344 | f_subtitle_length = None, 345 | f_subtitle_length_max_deviation = None 346 | ): 347 | """ 348 | Get a translated subtitle for a given language 349 | 350 | :param commontrack_id: The track commontrack 351 | :param track_id: Musixmatch track id 352 | :param track_isrc: A valid ISRC identifier 353 | :param track_spotify_id: Spotify Track ID 354 | :param selected_language: he language of the translated lyrics (ISO 639-1) 355 | :param min_completed: Teal from 0 to 1. If present, 356 | only the tracks with a translation ratio over this specific value, 357 | for a given language, are returned Set it to 1 for completed translation only, to 0.7 for a mimimum of 70% complete translation. 358 | """ 359 | 360 | params = {k: v for k, v in locals().items() if v is not None and k !='self'} 361 | return await self._api_call("get", "track.subtitle.translation.get", params) 362 | 363 | async def music_genres_get(self): 364 | """ 365 | Get the list of the music genres of our catalogue. 366 | """ 367 | return await self._api_call("get", "music.genres.get") 368 | 369 | async def matcher_lyrics_get( 370 | self, 371 | q_track=None, 372 | q_artist=None, 373 | q_album=None, 374 | commontrack_id=None, 375 | track_id=None, 376 | track_isrc=None, 377 | commontrack_vanity_id=None, 378 | track_spotify_id=None, 379 | track_itunes_id=None, 380 | **filters, 381 | ): 382 | """ 383 | Get the lyrics for track based on title and artist 384 | 385 | QUERYING: (At least one required) 386 | :param q_track: search for a text string among song titles 387 | :param q_artist: search for a text string among artist names 388 | :param q_album: The song album 389 | 390 | Objects: (optional) 391 | :param commontrack_id: Musixmatch commontrack id 392 | :param track_id: Musixmatch track id 393 | :param track_isrc: A valid ISRC identifier 394 | :param commontrack_vanity_id: Musixmatch vanity id ex "Imagine-Dragons/Demons" 395 | :param track_spotify_id: Spotify Track ID 396 | :param track_itunes_id: Apple track ID 397 | 398 | FILTERING: (optional) 399 | :param f_subtitle_length: The desired length of the subtitle (seconds) 400 | :param f_subtitle_length_max_deviation: The maximum deviation allowed from the f_subtitle_length (seconds) 401 | :param f_has_lyrics: Filter by objects with available lyrics 402 | :param f_is_instrumental: Filter instrumental songs 403 | :param f_has_subtitle: Filter by objects with available subtitles (1 or 0) 404 | :param f_music_genre_id: Filter by objects with a specific music category 405 | :param f_lyrics_language: Filter the tracks by lyrics language 406 | :param f_artist_id: Filter by objects with a given Musixmatch artist_id 407 | :param f_artist_mbid: Filter by objects with a given musicbrainz artist id 408 | 409 | """ 410 | 411 | params = {k: v for k, v in locals().items() if v is not None and k !='self' and k != "filters"} 412 | params = {**params, **filters} 413 | return await self._api_call("get", "matcher.lyrics.get", params) 414 | 415 | async def matcher_subtitle_get( 416 | self, 417 | q_track=None, 418 | q_artist=None, 419 | q_album=None, 420 | commontrack_id=None, 421 | track_id=None, 422 | track_isrc=None, 423 | commontrack_vanity_id=None, 424 | track_spotify_id=None, 425 | track_itunes_id=None, 426 | **filters, 427 | ): 428 | """ 429 | Get the subtitles for a song given his title,artist and duration. 430 | You can use the f_subtitle_length_max_deviation to fetch subtitles within a given duration range. 431 | 432 | 433 | QUERYING: (At least one required) 434 | :param q_track: search for a text string among song titles 435 | :param q_artist: search for a text string among artist names 436 | :param q_album: The song album 437 | 438 | Objects: (optional) 439 | :param commontrack_id: Musixmatch commontrack id 440 | :param track_id: Musixmatch track id 441 | :param track_isrc: A valid ISRC identifier 442 | :param commontrack_vanity_id: Musixmatch vanity id ex "Imagine-Dragons/Demons" 443 | :param track_spotify_id: Spotify Track ID 444 | :param track_itunes_id: Apple track ID 445 | 446 | FILTERING: (optional) 447 | :param f_subtitle_length: The desired length of the subtitle (seconds) 448 | :param f_subtitle_length_max_deviation: The maximum deviation allowed from the f_subtitle_length (seconds) 449 | :param f_has_lyrics: Filter by objects with available lyrics 450 | :param f_is_instrumental: Filter instrumental songs 451 | :param f_has_subtitle: Filter by objects with available subtitles (1 or 0) 452 | :param f_music_genre_id: Filter by objects with a specific music category 453 | :param f_lyrics_language: Filter the tracks by lyrics language 454 | :param f_artist_id: Filter by objects with a given Musixmatch artist_id 455 | :param f_artist_mbid: Filter by objects with a given musicbrainz artist id 456 | 457 | """ 458 | 459 | params = {k: v for k, v in locals().items() if v is not None and k !='self' and k != "filters"} 460 | params = {**params, **filters} 461 | return await self._api_call("get", "matcher.subtitle.get", params) 462 | 463 | async def artist_get(self, artist_id): 464 | """ 465 | Get the artist data. 466 | 467 | :param artist_id: Musixmatch artist id 468 | 469 | """ 470 | return await self._api_call("get", "artist.get", locals()) 471 | 472 | async def artist_search(self, 473 | q_artist, 474 | page = 1, 475 | page_size = 100, 476 | f_artist_id = None 477 | ): 478 | """ 479 | Search for artists 480 | 481 | :param q_artist: The song artist 482 | :param page: Define the page number for paginated results 483 | :param page_size: Define the page size for paginated results. Range is 1 to 100. 484 | :param f_artist_id: When set, filter by this artist id 485 | """ 486 | params = {k: v for k, v in locals().items() if v is not None and k !='self'} 487 | return await self._api_call("get", "artist.search", params) 488 | 489 | async def artist_albums_get(self, 490 | artist_id, 491 | page = 1, 492 | page_size = 100, 493 | g_album_name = 1, 494 | s_release_date = "desc" 495 | ): 496 | """ 497 | Get the album discography of an artist 498 | 499 | :param q_artist: The song artist 500 | :param page: Define the page number for paginated results 501 | :param page_size: Define the page size for paginated results. Range is 1 to 100. 502 | :param g_album_name: Group by Album Name 503 | :param s_release_date: Sort by release date (asc|desc) 504 | """ 505 | params = {k: v for k, v in locals().items() if v is not None and k !='self'} 506 | return await self._api_call("get", "artist.albums.get", params) 507 | 508 | async def artist_related_get(self, 509 | artist_id, 510 | page = 1, 511 | page_size = 100, 512 | ): 513 | """ 514 | Get a list of artists somehow related to a given one. 515 | 516 | :param q_artist: The song artist 517 | :param page: Define the page number for paginated results 518 | :param page_size: Define the page size for paginated results. Range is 1 to 100. 519 | """ 520 | params = {k: v for k, v in locals().items() if v is not None and k !='self'} 521 | return await self._api_call("get", "artist.related.get", params) 522 | 523 | async def album_get(self, album_id=None,album_vanity_id=None): 524 | """ 525 | Get the album object using the musixmatch id. 526 | 527 | :param album_id: The musixmatch album id. 528 | """ 529 | params = {k: v for k, v in locals().items() if v is not None and k !='self'} 530 | return await self._api_call("get", "album.get", params) 531 | 532 | async def album_tracks_get(self, album_id, 533 | f_has_lyrics = 0, 534 | page = 1, 535 | page_size = 100 536 | ): 537 | """ 538 | This api provides you the list of the songs of an album. 539 | 540 | :param album_id: The musixmatch album id. 541 | :param f_has_lyrics: When set, filter only contents with lyrics. 542 | :param page: Define the page number for paginated results 543 | :param page_size: Define the page size for paginated results. Range is 1 to 100. 544 | """ 545 | return await self._api_call("get", "album.tracks.get", locals()) --------------------------------------------------------------------------------