diff --git a/.env b/.env new file mode 100644 index 0000000..2e565b9 --- /dev/null +++ b/.env @@ -0,0 +1,4 @@ +SPOTIPY_CLIENT_ID=clientid +SPOTIPY_CLIENT_SECRET=clientsecret +SPOTIPY_REDIRECT_URI=callback +RADIO_REFRESH_TOKEN=token \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d9a9b42 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +venv +*.pyc +staticfiles +.env +db.sqlite3 +getting-started/* \ No newline at end of file diff --git a/Procfile b/Procfile new file mode 100644 index 0000000..ad538ae --- /dev/null +++ b/Procfile @@ -0,0 +1 @@ +web: gunicorn gettingstarted.wsgi --log-file - diff --git a/Procfile.windows b/Procfile.windows new file mode 100644 index 0000000..00832b6 --- /dev/null +++ b/Procfile.windows @@ -0,0 +1 @@ +web: python manage.py runserver 0.0.0.0:7000 diff --git a/README.md b/README.md new file mode 100644 index 0000000..786406a --- /dev/null +++ b/README.md @@ -0,0 +1,45 @@ +# Python: Getting Started + +A barebones Django app, which can easily be deployed to Heroku. + +This application supports the [Getting Started with Python on Heroku](https://devcenter.heroku.com/articles/getting-started-with-python) article - check it out. + +## Running Locally + +Make sure you have Python 3.7 [installed locally](http://install.python-guide.org). To push to Heroku, you'll need to install the [Heroku CLI](https://devcenter.heroku.com/articles/heroku-cli), as well as [Postgres](https://devcenter.heroku.com/articles/heroku-postgresql#local-setup). + +```sh +$ git clone https://github.com/heroku/python-getting-started.git +$ cd python-getting-started + +$ python3 -m venv getting-started +$ pip install -r requirements.txt + +$ createdb python_getting_started + +$ python manage.py migrate +$ python manage.py collectstatic + +$ heroku local +``` + +Your app should now be running on [localhost:5000](http://localhost:5000/). + +## Deploying to Heroku + +```sh +$ heroku create +$ git push heroku main + +$ heroku run python manage.py migrate +$ heroku open +``` +or + +[![Deploy](https://www.herokucdn.com/deploy/button.svg)](https://heroku.com/deploy) + +## Documentation + +For more information about using Python on Heroku, see these Dev Center articles: + +- [Python on Heroku](https://devcenter.heroku.com/categories/python) diff --git a/api/__init__.py b/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/currently_playing.py b/api/currently_playing.py new file mode 100644 index 0000000..89efc43 --- /dev/null +++ b/api/currently_playing.py @@ -0,0 +1,161 @@ +from datetime import datetime, timedelta +import time +import api.spotify as spotify +import hello.models +import json + +Currently_Playing = None +Kind = None +Progress_Ms = 0 +Duration_Ms = 0 +Is_Playing = False +PlayedAt = 0 # timestamp in ms + + +# Datetime of the last check of Spotify +Last_Spotify_Check = datetime.min +Check_Every = timedelta(seconds=10) + +# because history can be a bit finnicky, we add some extra checks before adding to history +# check most recent history song. If same as current song... +# check if (played_at + duration_ms) <= now +# if so, don't add. Because The old song couldn't finished playing, and its the same song, so just ignore it. +def check_add_history(song, timestamp, duration_ms): + h = hello.models.History.objects.order_by("-Timestamp").first() + if h is not None: + if h.Timestamp == timestamp: + print(f"Not adding {song.Id} to history because of a Timestamp conflict.") + return + if h.Song.Id == song.Id: + # most recent history item is the same as this one, so check the time info + td = timedelta(milliseconds=duration_ms) + if (h.Timestamp + td) >= datetime.now(): + print(f"Not adding {song.Id} to history because it didn't pass the history time-check") + return + h = hello.models.History.objects.create(Timestamp=timestamp, Song=song) + h.save() + return + + +# Tries to add the Spotify song to the database, and place it in the history table too +# Skips adding to history if the Played AT timestamp was already seen +def add_spotify_to_history(spot): + played = datetime.fromtimestamp(int(spot["timestamp"] / 1000)) + songId = spot["item"]["id"] + jdump = json.dumps(spot["item"]) + # first try to add the basic song info to the Songs table + msong = song_get_or_create(songId, "spotify", jdump) + # next, try to add it to the History table + check_add_history(msong, played, spot["item"]["duration_ms"]) + return + +def add_song(obj): + global Currently_Playing, Kind, Progress_Ms, Duration_Ms, PlayedAt, Is_Playing + if obj["kind"] == "youtube": + ytPlayed = int(obj["timestamp"]) + ytId = obj["item"]["id"] + ytPlaying = obj["is_playing"] + + oldId = Currently_Playing["item"]["id"] if Currently_Playing is not None else "" + + if Kind == "spotify" and not ytPlaying: + print("Tried to replace a spotify track with a youtube one, but youtube was paused. Skipping") + return + # only set PlayedAt if the song actually changed. + if oldId != ytId: + PlayedAt = ytPlayed + played = datetime.fromtimestamp(ytPlayed) + jdump = json.dumps(obj["item"]) + Is_Playing = ytPlaying + Kind = "youtube" + Duration_Ms = obj["item"]["duration_ms"] + Progress_Ms = obj["progress_ms"] + Currently_Playing = obj + msong = song_get_or_create(ytId, "youtube", jdump) + # only add to history if we actually play the video and its a different song + if ytPlaying and oldId != ytId: + check_add_history(msong, played, obj["item"]["duration_ms"]) + return + +def song_get_or_create(id, kind, jdata): + song = hello.models.Song.objects.filter(Id=id, Kind=kind).first() + if not song: + song = hello.models.Song.objects.create(Id=id, Kind=kind, JsonData=jdata) + return song + +def get_currently_playing() -> int: + global Currently_Playing, Kind, Progress_Ms, Duration_Ms, PlayedAt, Last_Spotify_Check, Is_Playing + now = datetime.now() + # Prioritize returning any currently playing Spotify song + if (Currently_Playing is None) or ((Last_Spotify_Check + Check_Every) <= now): + print("Spotify hasn't been checked recently...") + song_spot = spotify.get_currently_playing() + if song_spot is not None: + # only override the currently playing if the received song is playing. if it is paused, return currently playing + if Kind == "spotify" or song_spot["is_playing"]: + Is_Playing = song_spot["is_playing"] + add_spotify_to_history(song_spot) + print("Spotify was playing something. It has Priority.") + Currently_Playing = song_spot + Kind = "spotify" + Last_Spotify_Check = now + PlayedAt = int(song_spot["timestamp"] / 1000) # Spotify gives us something that Datetime cant work with + print("played at: " + datetime.fromtimestamp(PlayedAt).strftime("%I:%M:%S")) + Progress_Ms = song_spot["progress_ms"] + Duration_Ms = song_spot["item"]["duration_ms"] + return { + "song": song_spot, + "kind": "spotify" + } + elif not song_spot["is_playing"]: + print("Got a spotify song, but it wasn't playing. Returning currently playing instead.") + + # Next, check if our currently playing song is still valid (i.e., a youtube video) + if Currently_Playing is not None: + dtPlayedat = datetime.fromtimestamp(PlayedAt) + tdelta = timedelta(milliseconds=(Duration_Ms)) + expiry = dtPlayedat + tdelta + if expiry >= now: # we may come into the video halfway through, so subtract our progress + # song is still valid + print("Currently Playing is still valid") + cp = Currently_Playing + if Kind != "spotify": + if Is_Playing: + cp["progress_ms"] = cp["progress_ms"] + int((datetime.now() - datetime.fromtimestamp(PlayedAt)).total_seconds()) + return { + "song": cp, + "kind": Kind, + } + else: + if Progress_Ms < Duration_Ms and Is_Playing: + print("Song should be expired, but user seems to have gone backwards on it.") + return { + "song": Currently_Playing, + "kind": Kind, + } + else: + print("datetime playedat: " + dtPlayedat.strftime("%I:%M:%S")) + print("timedelta (dms - progms): " + str(timedelta(milliseconds=(Duration_Ms - Progress_Ms)))) + print("Time to expiry: " + expiry.strftime("%I:%M:%S")) + print("Currently playing song has expired") + + print("Radio NF is offline - nothing from youtube, currently_playing, or Spotify") + return None + +# NF TODO what is up with signing shit? + +def get_history(previous=50): + hist = [] + skip = 0 + if Currently_Playing is not None: + skip = 1 + for history in hello.models.History.objects.all().order_by("-Timestamp")[skip:previous]: # skip most recent + h = { + "timestamp": int(time.mktime(history.Timestamp.timetuple())), + "song": { + "kind": history.Song.Kind, + "item": json.loads(history.Song.JsonData) + } + } + hist.append(h) + return hist \ No newline at end of file diff --git a/api/spotify.py b/api/spotify.py new file mode 100644 index 0000000..ee7f209 --- /dev/null +++ b/api/spotify.py @@ -0,0 +1,58 @@ +import spotipy +from spotipy.oauth2 import SpotifyOAuth + +scope = "user-library-read user-read-playback-state user-read-currently-playing user-read-recently-played user-top-read user-read-playback-position" + +sp = spotipy.Spotify(auth_manager=SpotifyOAuth(scope=scope)) + +__default_image_icon = "https://community.spotify.com/t5/image/serverpage/image-id/55829iC2AD64ADB887E2A5/image-size/large?v=1.0&px=999" +def get_currently_playing(): + song = sp.current_user_playing_track() + if song is None: + print("User is not currently playing any tracks") + return None + else: + si = song["item"] + item = { + "id": si["id"], + "name": si["name"], + "is_local": si["is_local"], + "href": si["href"], + "preview_url": si["preview_url"], + "duration_ms": si["duration_ms"], + "album": si["album"], + "artists": si["artists"], + "main_image": __default_image_icon, + "main_link": None + } + + + d = { + "timestamp": song["timestamp"], + "progress_ms": song["progress_ms"], + "is_playing": song["is_playing"], + "item": item, + } + + if not si["is_local"]: + a = si["album"] + item["main_image"] = a["images"][0]["url"] + item["main_link"] = si["external_urls"]["spotify"] + item["album"] = { + "id": a["id"], + "images": a["images"], + "name": a["name"], + } + item["artists"] = [ + {"id": x["id"], "name": x["name"], "href":x["href"]} for x in si["artists"] + ] + else: + item["id"] = f"{item['name']}{str(item['duration_ms'])}" # default is local id + + return d + + +# results = sp.current_user_saved_tracks() +# for idx, item in enumerate(results['items']): +# track = item['track'] +# print(idx, track['artists'][0]['name'], " – ", track['name']) diff --git a/app.json b/app.json new file mode 100644 index 0000000..6a6a710 --- /dev/null +++ b/app.json @@ -0,0 +1,22 @@ +{ + "name": "Start on Heroku: Python", + "description": "A barebones Python app, which can easily be deployed to Heroku.", + "image": "heroku/python", + "repository": "https://github.com/heroku/python-getting-started", + "keywords": ["python", "django" ], + "addons": [ "heroku-postgresql" ], + "env": { + "SECRET_KEY": { + "description": "The secret key for the Django application.", + "generator": "secret" + } + }, + "environments": { + "test": { + "scripts": { + "test-setup": "python manage.py collectstatic --noinput", + "test": "python manage.py test" + } + } + } +} diff --git a/assets/logo.svg b/assets/logo.svg new file mode 100644 index 0000000..2344c1e --- /dev/null +++ b/assets/logo.svg @@ -0,0 +1,72175 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + NF RADIO + + + + diff --git a/firefox_extension/Youtube_Video_Title_Get.js b/firefox_extension/Youtube_Video_Title_Get.js new file mode 100644 index 0000000..c8af43f --- /dev/null +++ b/firefox_extension/Youtube_Video_Title_Get.js @@ -0,0 +1,185 @@ + +(function() { + 'use strict'; + + console.log("AYYYY"); + + // /* user modifable items */ + let sendUrl = "http://127.0.0.1:8000/api/send_song"; + let signingKeyB64 = "zphbG1sXK5Ebndyq5ey+IdYfM1CAQSGUR0WCBI52EKL6EBCdNcTmBpn+NFYS6qXDIeEBh8sBFUiLPCpZjgzhVY94SQStRYHJ7Y8pGtzGUdJFZpSuPA0GL87VKWM4YhgncC4hBY8+gIOq6KWQMAAlvUZBWg6sdo0qmsBxCPr0J0w=" + + let cryptoKey = null; + const encoder = new TextEncoder(); + async function makeCryptoKey(b64Key) { + const arrBuff = Base64Binary.base64ToArrayBuffer(b64Key); + console.log(arrBuff); + cryptoKey = await crypto.subtle.importKey("raw", arrBuff, {"name": "HMAC", hash: "SHA-512"}, true, ["sign", "verify"]); + } + + let permission = false; + let mutObserver = null; + const videoIdRegex = new RegExp("v=([a-zA-Z0-9]*?)($|&)"); + + /** Send message to Background Script */ + let myPort = browser.runtime.connect({name:"port-from-cs"}); + + myPort.onMessage.addListener(function(m) { + if (typeof m === 'object' && m.hasOwnProperty("permission")) { + console.log(m); + permission = m.permission; + console.log("Got permission: " + permission); + if (permission) { + setInterval(() => { + console.log("Sending update to server..."); + reload(false); + send(); + }, 10 * 1000); + mutObserver = new MutationObserver(function(mutations) { + reload(true); + }).observe( + document.querySelector('title'), + { subtree: true, characterData: true, childList: true } + ); + } + } + else { + console.log("got wrong data:"); + console.log(m); + } + }); + + + // Your code here... + let title = document.title; + let vid = null; + let reloaded_at = 0; + let vidId = null; + let sendObj = null; + /** Keep track of if our last udpate was a Paused update. If we have two pauses in a row, we don't have to send data */ + let lastSendWasPlaying = true; + + function getVideoId() { + const matches = videoIdRegex.exec(window.location); + if (matches.length > 1) { + return matches[1]; + } + return null; + } + + function getSendObj() { + vidId = getVideoId(); + let sendObj = { + data: { + item: { + id: vidId, + name: document.title, + main_link: window.location.toString(), + main_image: vidId ? `https://img.youtube.com/vi/${vidId}/0.jpg` : "", + duration_ms: Math.ceil(vid.duration*1000), /* duration is in ms to match with Spotify's `duration_ms` field */ + album: null, + artists: null, + is_local: false, + preview_url: false, + href: window.location.toString(), + }, + kind: "youtube", + timestamp: reloaded_at, + is_playing: isPlaying(), + progress_ms: Math.ceil(vid.currentTime * 1000), /* ms to match with Spotify's `progress_ms` */ + }, + signature: "", + }; + return sendObj; + } + + function reload(refresh_time) { + if (refresh_time) { + reloaded_at = Math.ceil(Date.now() / 1000); + } + const vids = document.getElementsByTagName("video"); + vid = null; + if (vids && vids.length > 0) { + vid = vids[0]; + } + + vidId = getVideoId(); + sendObj = getSendObj(); + + console.log("Is playing?: " + sendObj.data.is_playing); + + if (vid) { + vid.onplay = onPlay; + vid.onpause = onPause; + vid.onended = onEnd; + send(); + } + } + + function onPlay() { + sendObj.data.isPlaying = true; + console.log("ON PLAYING"); + send(); + } + + function onPause() { + sendObj.data.isPlaying = false; + console.log("ON PAUSING"); + send(); + } + + function onEnd() { + sendObj.data.isPlaying = false; + console.log("ON END"); + send(); + } + + function isPlaying() { + if (vid && vid.readyState >= vid.HAVE_FUTURE_DATA) { + return !vid.paused; + } + return false; + } + + async function send() { + if (permission) { + if (!lastSendWasPlaying && !sendObj.data.is_playing) { + /* don't send data if this send is paused, and last send was paused */ + console.log("skipping sending data because last one and this one were paused"); + return; + } + lastSendWasPlaying = sendObj.data.isPlaying; + + const signature_object = await crypto.subtle.sign("HMAC", cryptoKey, encoder.encode(sendObj.data)); + console.log(signature_object); + sendObj.signature = await Base64Binary.arrayBufferToBase64(signature_object); + console.log(sendObj); + const response = await fetch(sendUrl, { + method: 'POST', + mode: 'no-cors', // no-cors, *cors, same-origin + cache: 'no-cache', // *default, no-cache, reload, force-cache, only-if-cached + credentials: 'omit', // include, *same-origin, omit + headers: { + 'Content-Type': 'application/json' + }, + redirect: 'follow', // manual, *follow, error + referrerPolicy: 'no-referrer', // no-referrer, *no-referrer-when-downgrade, origin, origin-when-cross-origin, same-origin, strict-origin, strict-origin-when-cross-origin, unsafe-url + body: JSON.stringify(sendObj) // body data type must match "Content-Type" header + }); + console.log(response); + } + } + + + async function main() { + reload(true); + console.log("making crypto key"); + await makeCryptoKey(signingKeyB64); /* needed for signing our requests */ + /* post the message, all other functions flow from the receive message function */ + console.log("checking tab permission"); + myPort.postMessage({function: "checkPermission"}); + } + + main(); + + +})(); diff --git a/firefox_extension/background.js b/firefox_extension/background.js new file mode 100644 index 0000000..d1d946f --- /dev/null +++ b/firefox_extension/background.js @@ -0,0 +1,52 @@ +console.log("background init"); +/** user modifable items */ +const ContainerTabName = "Music Youtube2"; +const ContainerId = "firefox-container-6"; +let portFromCS; + + +function connected(p) { + portFromCS = p; + portFromCS.onMessage.addListener(async function(m) { + console.log(m); + if (typeof m === 'object' && m.function === "checkPermission") { + const tab = (await browser.tabs.query({currentWindow: true, active: true}))[0]; + const cookieStoreId = tab.cookieStoreId; + console.log("getting for cookiestoreid of " + cookieStoreId); + const permission = await checkPermission(cookieStoreId); + console.log("got permission? " + permission); + portFromCS.postMessage({"permission": permission}); + } else { + portFromCS.postMessage({greeting: "In background script, received message from content script:" + m}); + } + }); +} + +async function checkPermission(cookieId) { + try{ + const identities = await browser.contextualIdentities.query({name: ContainerTabName}); + if(!identities || identities.length === 0) { + console.error(`No identity could be found by the name ${ContainerTabName}`); + return false; + } + const identity = identities[0]; + console.log("found identity."); + console.log(identity.cookieStoreId); + return cookieId === identity.cookieStoreId; /* given id vs white-listed identity id */ + } + catch (err) { + console.error("Failed to check container permission:") + console.log(err); + return false; + } +} + +console.log("functions generated"); + +browser.runtime.onConnect.addListener(connected); +console.log("browser runtime connection added"); + + + + + diff --git a/firefox_extension/manifest.json b/firefox_extension/manifest.json new file mode 100644 index 0000000..81e5b65 --- /dev/null +++ b/firefox_extension/manifest.json @@ -0,0 +1,44 @@ +{ + "manifest_version": 2, + "name": "Youtube Video Title Get", + "description": "try to take over the world!", + "version": "0.1", + "permissions": [ + "", + "activeTab", + "cookies", + "contextMenus", + "contextualIdentities", + "history", + "idle", + "management", + "storage", + "tabs", + "webRequestBlocking", + "webRequest" + ], + "applications": { + "gecko": { + "id": "radio_youtube@tampermonkey.net" + } + }, + "background": { + "scripts": [ + "background.js" + ] + }, + "content_scripts": [ + { + "js": [ + "utils.js", + "Youtube_Video_Title_Get.js" + ], + "run_at": "document_end", + "matches": [ + "https://www.youtube.com/watch?v=*" + ], + "all_frames": true, + "match_about_blank": true + } + ] +} \ No newline at end of file diff --git a/firefox_extension/utils.js b/firefox_extension/utils.js new file mode 100644 index 0000000..0385073 --- /dev/null +++ b/firefox_extension/utils.js @@ -0,0 +1,91 @@ +var NCrypto = { + /** returns a CryptoKey */ + newHmac: async function() { + return await crypto.subtle.generateKey({name: "HMAC", hash: {name: "SHA-512"}}, true, ["sign", "verify"]); + }, + + +} +var Base64Binary = { + _keyStr : "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=", + + arrayBufferToBase64: function(input) { + const p = new Promise((resolve, reject) => { + const blob = new Blob([input]) + const reader = new FileReader(); + reader.onload = function(event){ + var base64 = event.target.result + base64 = base64.split("data:application/octet-stream;base64,")[1] + resolve(base64); + }; + reader.readAsDataURL(blob); + }); + return p; + }, + + base64ToArrayBuffer: function(base64) { + var binary_string = window.atob(base64); + var len = binary_string.length; + var bytes = new Uint8Array(len); + for (var i = 0; i < len; i++) { + bytes[i] = binary_string.charCodeAt(i); + } + return bytes.buffer; + }, + + // /* will return a Uint8Array type */ + // decodeArrayBuffer: function(input) { + // var bytes = (input.length/4) * 3; + // var ab = new ArrayBuffer(bytes); + // this.decode(input, ab); + + // return ab; + // }, + + removePaddingChars: function(input){ + var lkey = this._keyStr.indexOf(input.charAt(input.length - 1)); + if(lkey == 64){ + return input.substring(0,input.length - 1); + } + return input; + }, + + decode: function (input, arrayBuffer) { + //get last chars to see if are valid + input = this.removePaddingChars(input); + input = this.removePaddingChars(input); + + var bytes = parseInt((input.length / 4) * 3, 10); + + var uarray; + var chr1, chr2, chr3; + var enc1, enc2, enc3, enc4; + var i = 0; + var j = 0; + + if (arrayBuffer) + uarray = new Uint8Array(arrayBuffer); + else + uarray = new Uint8Array(bytes); + + input = input.replace(/[^A-Za-z0-9\+\/\=]/g, ""); + + for (i=0; i> 4); + chr2 = ((enc2 & 15) << 4) | (enc3 >> 2); + chr3 = ((enc3 & 3) << 6) | enc4; + + uarray[i] = chr1; + if (enc3 != 64) uarray[i+1] = chr2; + if (enc4 != 64) uarray[i+2] = chr3; + } + + return uarray; + } +} \ No newline at end of file diff --git a/gettingstarted/settings.py b/gettingstarted/settings.py new file mode 100644 index 0000000..0fee2f3 --- /dev/null +++ b/gettingstarted/settings.py @@ -0,0 +1,119 @@ +""" +Django settings for gettingstarted project. + +Generated by 'django-admin startproject' using Django 2.0. + +For more information on this file, see +https://docs.djangoproject.com/en/2.0/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/2.0/ref/settings/ +""" + +import os +import django_heroku + + +# Build paths inside the project like this: os.path.join(BASE_DIR, ...) +BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/2.0/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = "CHANGE_ME!!!! (P.S. the SECRET_KEY environment variable will be used, if set, instead)." + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = True + +ALLOWED_HOSTS = [] + + +# Application definition + +INSTALLED_APPS = [ + # "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "hello", +] + +MIDDLEWARE = [ + "django.middleware.security.SecurityMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", +] + +ROOT_URLCONF = "gettingstarted.urls" + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.jinja2.Jinja2", + "DIRS": ["hello/templates"], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + ] + }, + } +] + +WSGI_APPLICATION = "gettingstarted.wsgi.application" + + +# Database +# https://docs.djangoproject.com/en/2.0/ref/settings/#databases + +DATABASES = { + "default": { + "ENGINE" : "django.db.backends.sqlite3", + "NAME": os.path.join(BASE_DIR, "db.sqlite3") + } +} + +# Password validation +# https://docs.djangoproject.com/en/2.0/ref/settings/#auth-password-validators + +AUTH_PASSWORD_VALIDATORS = [ + { + "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator" + }, + {"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"}, + {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"}, + {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"}, +] + + +# Internationalization +# https://docs.djangoproject.com/en/2.0/topics/i18n/ + +LANGUAGE_CODE = "en-us" + +TIME_ZONE = "UTC" + +USE_I18N = True + +USE_L10N = True + +USE_TZ = False + + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/2.0/howto/static-files/ + +STATIC_ROOT = os.path.join(BASE_DIR, 'staticfiles') +STATIC_URL = "/static/" + +django_heroku.settings(locals()) diff --git a/gettingstarted/static/humans.txt b/gettingstarted/static/humans.txt new file mode 100644 index 0000000..e69de29 diff --git a/gettingstarted/urls.py b/gettingstarted/urls.py new file mode 100644 index 0000000..4ee9536 --- /dev/null +++ b/gettingstarted/urls.py @@ -0,0 +1,22 @@ +from django.urls import path, include + +# from django.contrib import admin + +# admin.autodiscover() + +import hello.views + +# To add a new path, first import the app: +# import blog +# +# Then add the new path: +# path('blog/', blog.urls, name="blog") +# +# Learn more here: https://docs.djangoproject.com/en/2.1/topics/http/urls/ + +urlpatterns = [ + path("", hello.views.index, name="index"), + path("api/currently_playing", hello.views.currently_playing, name="currently_playing"), + path("api/get_history", hello.views.get_history, name="get_history"), + path("api/send_song", hello.views.send_song, name="send_song"), +] diff --git a/gettingstarted/wsgi.py b/gettingstarted/wsgi.py new file mode 100644 index 0000000..7e8b8cc --- /dev/null +++ b/gettingstarted/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for gettingstarted project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/2.1/howto/deployment/wsgi/ +""" + +import os + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "gettingstarted.settings") + +from django.core.wsgi import get_wsgi_application + +application = get_wsgi_application() diff --git a/hello/__init__.py b/hello/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hello/migrations/0001_initial.py b/hello/migrations/0001_initial.py new file mode 100644 index 0000000..61eb641 --- /dev/null +++ b/hello/migrations/0001_initial.py @@ -0,0 +1,23 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.1 on 2016-01-27 21:54 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Greeting', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('when', models.DateTimeField(auto_now_add=True, verbose_name=b'date created')), + ], + ), + ] diff --git a/hello/migrations/0002_add_songs.py b/hello/migrations/0002_add_songs.py new file mode 100644 index 0000000..6b1523c --- /dev/null +++ b/hello/migrations/0002_add_songs.py @@ -0,0 +1,37 @@ +# Generated by Django 3.1.5 on 2021-02-03 09:17 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('hello', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='History', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('Timestamp', models.DateTimeField(verbose_name='timestamp')), + ], + ), + migrations.CreateModel( + name='Song', + fields=[ + ('Id', models.TextField(primary_key=True, serialize=False)), + ('Kind', models.TextField(verbose_name='kind')), + ('JsonData', models.TextField(verbose_name='jsondata')), + ], + ), + migrations.DeleteModel( + name='Greeting', + ), + migrations.AddField( + model_name='history', + name='Song', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='hello.song'), + ), + ] diff --git a/hello/migrations/0003_history_has_timestamp_as_id.py b/hello/migrations/0003_history_has_timestamp_as_id.py new file mode 100644 index 0000000..eeb8c08 --- /dev/null +++ b/hello/migrations/0003_history_has_timestamp_as_id.py @@ -0,0 +1,27 @@ +# Generated by Django 3.1.5 on 2021-02-03 14:56 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('hello', '0002_add_songs'), + ] + + operations = [ + migrations.RemoveField( + model_name='history', + name='id', + ), + migrations.AlterField( + model_name='history', + name='Timestamp', + field=models.DateTimeField(primary_key=True, serialize=False, verbose_name='timestamp'), + ), + migrations.AlterField( + model_name='song', + name='Id', + field=models.TextField(primary_key=True, serialize=False, verbose_name='Id'), + ), + ] diff --git a/hello/migrations/0004_song_pk_both_id_and_kind.py b/hello/migrations/0004_song_pk_both_id_and_kind.py new file mode 100644 index 0000000..e272d7e --- /dev/null +++ b/hello/migrations/0004_song_pk_both_id_and_kind.py @@ -0,0 +1,17 @@ +# Generated by Django 3.1.5 on 2021-02-06 23:08 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('hello', '0003_history_has_timestamp_as_id'), + ] + + operations = [ + migrations.AddConstraint( + model_name='song', + constraint=models.UniqueConstraint(fields=('Id', 'Kind'), name='unique_kind_id'), + ), + ] diff --git a/hello/migrations/__init__.py b/hello/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hello/models.py b/hello/models.py new file mode 100644 index 0000000..5abaddf --- /dev/null +++ b/hello/models.py @@ -0,0 +1,26 @@ +from django.db import models + + +class Song(models.Model): + Id = models.TextField("Id", primary_key=True) + Kind = models.TextField("kind") + JsonData = models.TextField("jsondata") + + @classmethod + def createSong(self, song_id, json_data): + song = self.create(Id = song_id, jsonData = json_data) + return song + + class Meta: + constraints = [ + models.UniqueConstraint(fields=['Id', 'Kind'], name='unique_kind_id') + ] + +class History(models.Model): + Timestamp = models.DateTimeField("timestamp", primary_key=True) + Song = models.ForeignKey(Song, on_delete=models.CASCADE) + + @classmethod + def createHistory(self, timestamp, song): + h = self.create(Timestamp = timestamp, Song = song) + return h \ No newline at end of file diff --git a/hello/static/Spotify_Icon_RGB_Green.png b/hello/static/Spotify_Icon_RGB_Green.png new file mode 100644 index 0000000..26410e2 Binary files /dev/null and b/hello/static/Spotify_Icon_RGB_Green.png differ diff --git a/hello/static/lang-logo.png b/hello/static/lang-logo.png new file mode 100644 index 0000000..f04aff1 Binary files /dev/null and b/hello/static/lang-logo.png differ diff --git a/hello/static/logo_web.png b/hello/static/logo_web.png new file mode 100644 index 0000000..3cc0341 Binary files /dev/null and b/hello/static/logo_web.png differ diff --git a/hello/static/logo_web.svg b/hello/static/logo_web.svg new file mode 100644 index 0000000..7d12e7d --- /dev/null +++ b/hello/static/logo_web.svg @@ -0,0 +1,117 @@ + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + diff --git a/hello/static/yt_logo_rgb_dark.png b/hello/static/yt_logo_rgb_dark.png new file mode 100644 index 0000000..64d91a3 Binary files /dev/null and b/hello/static/yt_logo_rgb_dark.png differ diff --git a/hello/templates/base.html b/hello/templates/base.html new file mode 100644 index 0000000..b22f315 --- /dev/null +++ b/hello/templates/base.html @@ -0,0 +1,228 @@ + + + + + + + + + + + + +{% block content %}{% endblock %} + +
+ See this project on GitHub +
+ + + diff --git a/hello/templates/index2.html b/hello/templates/index2.html new file mode 100644 index 0000000..2e00756 --- /dev/null +++ b/hello/templates/index2.html @@ -0,0 +1,555 @@ +{% extends "base.html" %} + +{% set cursong = current_song.song if current_song else none %} +{% set noSong = cursong is none %} + +{% block content %} + +
+ +

Currently Listening to:

+
+ + + + + + {% if current_song and current_song.song.item.href is not none %} + + {% else %} + + {% endif %} + + + + + + + + + + +
+

{{current_song.song.item.name if not noSong else ''}}

+
+ +
+ {% if not noSong and current_song.song.item.artists %} + {% for artist in current_song.song.item.artists %} + {% if artist.href %} + {{artist.name}} + {% else %} + {{artist.name}} + {% endif %} + {% endfor %} + {% endif %} +
+ + +
+ {% if not noSong and current_song.song.item.album %} + {% if current_song.song.item.album.href is not none %} + {{current_song.song.item.album.name}} + {% else %} + {{current_song.song.item.album.name}} + {% endif %} + {% else %} + album unknown + {% endif %} +
+ + + +
+
+ + 0:00 + 3:25 +
+
+
+ +
+ + +
+

Radio NF is currently offline

+

Come back later for some true bangers.

+ +

In the meantime, check out what sick beats were played recently:

+
+
+ +
+

History

+
+ +
+
+ + +
+ + + +{% endblock %} + diff --git a/hello/tests.py b/hello/tests.py new file mode 100644 index 0000000..10873f0 --- /dev/null +++ b/hello/tests.py @@ -0,0 +1,19 @@ +from django.contrib.auth.models import AnonymousUser, User +from django.test import TestCase, RequestFactory + +from .views import index + + +class SimpleTest(TestCase): + def setUp(self): + # Every test needs access to the request factory. + self.factory = RequestFactory() + + def test_details(self): + # Create an instance of a GET request. + request = self.factory.get("/") + request.user = AnonymousUser() + + # Test my_view() as if it were deployed at /customer/details + response = index(request) + self.assertEqual(response.status_code, 200) diff --git a/hello/verify.py b/hello/verify.py new file mode 100644 index 0000000..96c5e4e --- /dev/null +++ b/hello/verify.py @@ -0,0 +1,45 @@ +import hmac, base64, hashlib +import time +import json + + +signingKey = "zphbG1sXK5Ebndyq5ey+IdYfM1CAQSGUR0WCBI52EKL6EBCdNcTmBpn+NFYS6qXDIeEBh8sBFUiLPCpZjgzhVY94SQStRYHJ7Y8pGtzGUdJFZpSuPA0GL87VKWM4YhgncC4hBY8+gIOq6KWQMAAlvUZBWg6sdo0qmsBxCPr0J0w=" + +def verifySignature(string_to_verify, signature, shared_secret): + hm = hmac.new(shared_secret, string_to_verify, hashlib.sha512) + hmd = hm.digest() + return ct_compare(hmd, signature) + +def verifyTime(timestamp): + if int(time.time()) - timestamp > 30: + return False + return True + +def ct_compare(a, b): + if len(a) != len(b): + return False + + result = 0 + for ch_a, ch_b in zip(a, b): + # result |= ord(ch_a) ^ ord(ch_b) + result |= ch_a ^ ch_b + return result == 0 + + +def verify(data, signature): + decoded_data = bytes(json.dumps(data), encoding="utf-8") + decoded_signature = base64.urlsafe_b64decode(signature) + + b64Key = base64.urlsafe_b64decode(signingKey) + if verifySignature(decoded_data, decoded_signature, b64Key): + print('Valid signature') + # Verify timestamp + if not verifyTime(time.time()): # todo fetch real timestamp + print("Timestamp too far back.") + return False + print('Timestamp verified') + return True + print("invalid signature") + return False + + \ No newline at end of file diff --git a/hello/views.py b/hello/views.py new file mode 100644 index 0000000..7e4e55d --- /dev/null +++ b/hello/views.py @@ -0,0 +1,55 @@ +from django.shortcuts import render +from django.http import HttpResponse, HttpResponseForbidden, HttpResponseBadRequest, JsonResponse, HttpResponseServerError +from django.views.decorators.http import require_http_methods +from django.views.decorators.csrf import csrf_exempt +import api.spotify +import api.currently_playing +import spotipy +import json +from datetime import datetime + +from .verify import verify + +from .models import Song, History + + +# Create your views here. +def index(request): + res = { + "current_song": api.currently_playing.get_currently_playing(), + "history": api.currently_playing.get_history(50) + } + return render(request, "index2.html", res) + + +@require_http_methods(["GET"]) +def currently_playing(request): + res = { + "current_song": api.currently_playing.get_currently_playing(), + } + return JsonResponse(res) + +@require_http_methods(["GET"]) +def get_history(request): + res = api.currently_playing.get_history() + return JsonResponse(res, safe=False) + + +@csrf_exempt +@require_http_methods(["POST"]) +def send_song(request): + body = json.loads(request.body) + if "signature" in body and "data" in body: + signature = body["signature"] + data = body["data"] + else: + return HttpResponseBadRequest("Request was missing either a body object or a signature") + + if not verify(data, signature): + pass # nf todo verify is broken atm. come back later + #return HttpResponseBadRequest("Request was signed with an invalid key") + api.currently_playing.add_song(data) + return HttpResponse() + + + diff --git a/manage.py b/manage.py new file mode 100644 index 0000000..85c6c7d --- /dev/null +++ b/manage.py @@ -0,0 +1,10 @@ +#!/usr/bin/env python +import os +import sys + +if __name__ == "__main__": + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "gettingstarted.settings") + + from django.core.management import execute_from_command_line + + execute_from_command_line(sys.argv) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..74d8187 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +django +gunicorn +django-heroku +spotipy +jinja2 \ No newline at end of file diff --git a/runtime.txt b/runtime.txt new file mode 100644 index 0000000..73e28e8 --- /dev/null +++ b/runtime.txt @@ -0,0 +1 @@ +python-3.7.8