diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..049abd1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,66 @@ +# Common .gitignore Configuration # +############################################# +# # +# Based on the gist provided by # +# GitHub at: # +# https://gist.github.com/octocat/9257657 # +# # +############################################# + +# Compiled source # +################### +*.com +*.class +*.dll +*.exe +*.o +*.so + +# Packages # +############ +# It's better to unpack these files and commit the raw source. +# Git has its own built in compression methods. +*.7z +*.dmg +*.gz +*.iso +*.jar +*.rar +*.tar +*.zip + +# Output Folders # +################## +bin/ +obj/ +out/ +build/ + +# Logs and databases # +###################### +*.log +*.sql +*.sqlite + +# OS generated files # +###################### +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# Backup or temporary files # +############################# +*.pyo +*.pyc +*~ +*.bak +*.swp + + +oauth.json +browser.json +.env \ No newline at end of file diff --git a/app.py b/app.py new file mode 100644 index 0000000..be64305 --- /dev/null +++ b/app.py @@ -0,0 +1,240 @@ +from flask import * +from markupsafe import escape +from ytmusicapi import YTMusic,OAuthCredentials +from dotenv import load_dotenv,dotenv_values +import dominate +from dominate.tags import * +import os +import json +import requests + +app = Flask(__name__) +load_dotenv() + + + +ytmusic = YTMusic("browser.json") + +@app.route('/') +def root(): + return render_template('index.html') + +@app.route('/search/') +@app.route('/search/') +def search(query=" "): + results = (ytmusic.search(query=query)) + doc = dominate.document(title=query) + + with doc.head: + link(rel='stylesheet', href=url_for('static', filename='style.css')) + script(type='text/javascript', src=url_for('static', filename='script.js')) + + with doc: + with div(id='header').add(ol()): + for i in ['home']: + li(a(i.title(), href='/')) + + artists = [] + songs = [] + playlists = [] + videos = [] + + div(input_(type="text",cls="form-control",id="searchYTM",oninput="apiCall()") ,id='container') + + for result in results: + type = result['resultType'] + match type: + case 'artist': + artists.append(result) + case 'song': + songs.append(result) + case 'video': + videos.append(result) + case 'playlist': + playlists.append(result) + p('Songs') + for result in songs: + with div(): + thumbnails = result['thumbnails'] + url = thumbnails[len(thumbnails)-1]['url'] + img(src=url, width=100) + title = result['title'] + video_id = result['videoId'] + a(title,href=f'https://music.youtube.com/watch?v={video_id}',cls="video") + artist_name = "" + if 'artist' in result: + name = result['artist'] + if 'browseId' in result: + id = result['browseId'] + a(name,href=f'/artist/{id}',cls="artist") + else: + span(result) + if artist_name == "": + artist_name = name + if 'artists' in result: + for artist in result['artists']: + name = artist['name'] + if artist_name == "": + artist_name = name + id = artist['id'] + a(name,href=f'/artist/{id}',cls="artist") + button("Add to Queue",type="button",cls="button queue",onclick=f'add(event,"{title}","{artist_name}","{video_id}","queue")') + button("Play Next",type="button",cls="button next",onclick=f'add(event,"{title}","{artist_name}","{video_id}","next")') + p('Artists') + for result in artists: + with div(): + thumbnails = result['thumbnails'] + url = thumbnails[len(thumbnails)-1]['url'] + img(src=url, width=100) + if 'artist' in result: + name = result['artist'] + if 'browseId' in result: + id = result['browseId'] + a(name,href=f'/artist/{id}',cls="artist") + else: + span(result) + if 'artists' in result: + for artist in result['artists']: + name = artist['name'] + id = artist['id'] + a(name,href=f'/artist/{id}',cls="artist") + p('Videos') + for result in videos: + with div(): + thumbnails = result['thumbnails'] + url = thumbnails[len(thumbnails)-1]['url'] + img(src=url, width=100) + title = result['title'] + video_id = result['videoId'] + a(title,href=f'https://music.youtube.com/watch?v={video_id}',cls="video") + artist_name = "" + if 'artist' in result: + name = result['artist'] + id = result['browseId'] + a(name,href=f'/artist/{id}',cls="artist") + if artist_name == "": + artist_name = name + if 'artists' in result: + for artist in result['artists']: + name = artist['name'] + if artist_name == "": + artist_name = name + id = artist['id'] + a(name,href=f'/artist/{id}',cls="artist") + button("Add to Queue",type="button",cls="button queue",onclick=f'add(event,"{title}","{artist_name}","{video_id}","queue")') + button("Play Next",type="button",cls="button next",onclick=f'add("event,{title}","{artist_name}","{video_id}","next")') + p('Playlists') + for result in playlists: + with div(): + thumbnails = result['thumbnails'] + url = thumbnails[len(thumbnails)-1]['url'] + img(src=url, width=100) + title = result['title'] + author = result['author'] + if 'browseId' in result: + id = result['browseId'][2:] + a(title,href=f'https://music.youtube.com/playlist?list={id}',cls="playlist") + elif 'playlistId' in result: + id = result['playlistId'][2:] + a(title,href=f'https://music.youtube.com/playlist?list={id}',cls="playlist") + else: + span(result) + span(author) + return doc.render() + +@app.route('/suggestion/') +@app.route('/suggestion/') +def suggestion(query=""): + results = ytmusic.get_search_suggestions(query) + return results + +@app.route('/artist/') +def artist(artist_id): + doc = dominate.document(title=artist_id) + + + + + with doc.head: + link(rel='stylesheet', href=url_for('static', filename='style.css')) + script(type='text/javascript', src=url_for('static', filename='script.js')) + + with doc: + with div(id='header').add(ol()): + for i in ['home']: + li(a(i.title(), href='/')) + div(input_(type="text",cls="form-control",id="searchYTM",oninput="apiCall()") ,id='container') + try: + results = ytmusic.get_artist(artist_id) + name = results['name'] + subscribers = results['subscribers'] + songs = results['songs']['results'] + videos = results['videos']['results'] + thumbnails = results['thumbnails'] + url = thumbnails[len(thumbnails)-1]['url'] + img(src=url, width=200) + a(name,href=f'https://music.youtube.com/channel/{artist_id}',cls="artist external") + span(f'{subscribers} subscribers',cls = "subscribers") + p('Songs') + for result in songs: + with div(): + thumbnails = result['thumbnails'] + url = thumbnails[len(thumbnails)-1]['url'] + img(src=url, width=100) + title = result['title'] + video_id = result['videoId'] + a(title,href=f'https://music.youtube.com/watch?v={video_id}',cls="video") + artist_name = "" + if 'artist' in result: + name = result['artist'] + if 'browseId' in result: + id = result['browseId'] + a(name,href=f'/artist/{id}',cls="artist") + else: + span(result) + if artist_name == "": + artist_name = name + if 'artists' in result: + for artist in result['artists']: + name = artist['name'] + if artist_name == "": + artist_name = name + id = artist['id'] + a(name,href=f'/artist/{id}',cls="artist") + button("Add to Queue",type="button",cls="button queue",onclick=f'add(event,"{title}","{artist_name}","{video_id}","queue")') + button("Play Next",type="button",cls="button next",onclick=f'add(event,"{title}","{artist_name}","{video_id}","next")') + p('Videos') + for result in videos: + with div(): + thumbnails = result['thumbnails'] + url = thumbnails[len(thumbnails)-1]['url'] + img(src=url, width=100) + title = result['title'] + video_id = result['videoId'] + a(title,href=f'https://music.youtube.com/watch?v={video_id}',cls="video") + artist_name = "" + if 'artist' in result: + name = result['artist'] + if 'browseId' in result: + id = result['browseId'] + a(name,href=f'/artist/{id}',cls="artist") + else: + span(result) + if artist_name == "": + artist_name = name + if 'artists' in result: + for artist in result['artists']: + name = artist['name'] + if artist_name == "": + artist_name = name + id = artist['id'] + a(name,href=f'/artist/{id}',cls="artist") + button("Add to Queue",type="button",cls="button queue",onclick=f'add(event,"{video_id}","{artist_name}","{video_id}","queue")') + button("Play Next",type="button",cls="button next",onclick=f'add(event,"{video_id}","{artist_name}","{video_id}","next")') + #span(results) + except: + span(f"Couldn't find artist with id: {artist_id}") + p() + span(span("You can try to find that artist on "),a("Youtube",href=f'https://music.youtube.com/channel/{artist_id}',cls="artist external")) + + return doc.render() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..460ecfe --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +ytmusicapi +flask +python-dotenv +dominate \ No newline at end of file diff --git a/server/server.cjs b/server/server.cjs new file mode 100644 index 0000000..aa60d93 --- /dev/null +++ b/server/server.cjs @@ -0,0 +1,35 @@ +const http = require('http'); + +let currentCommand = null; + +const server = http.createServer((req, res) => { + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); + + if (req.method === 'OPTIONS') { + res.writeHead(204); + res.end(); + return; + } + + if (req.method === 'POST' && req.url === '/command') { + let body = ''; + req.on('data', chunk => { body += chunk.toString(); }); + req.on('end', () => { + currentCommand = JSON.parse(body); + console.log("External Command Received:", currentCommand); + res.end("Queued"); + }); + } + else if (req.method === 'GET' && req.url === '/poll') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + if (currentCommand != null) { + console.log("Sending " + currentCommand + " to Tampermonkey") + } + res.end(JSON.stringify(currentCommand)); + currentCommand = null; + } +}); + +server.listen(3000, () => console.log("Bridge Server active on port 3000")); \ No newline at end of file diff --git a/static/script.js b/static/script.js new file mode 100644 index 0000000..5e94ce2 --- /dev/null +++ b/static/script.js @@ -0,0 +1,83 @@ +function add(e,title,artist,id,action) { + const btn = e.currentTarget; + console.log(title + " " + artist + " " + action + " " + id) + + btn.classList.add('active-click'); + + setTimeout(() => { + btn.classList.remove('active-click'); + }, 500); + + + fetch("http://localhost:3000/command", { + method: "POST", + body: JSON.stringify({ + "videoId": id, + "title": title, + "artist": artist, + "action": action + }), + headers: { + "Content-type": "application/json; charset=UTF-8" + } + }); +} + +function apiCall() { + const container = document.getElementById("container") + var searchInput = document.getElementById("searchYTM"); + + searchInput.onkeydown = stupidFunction(); + function stupidFunction() { + var searchData = document.getElementById("searchYTM").value; + + if (searchData.length >= 0 ) { + while (document.getElementsByClassName('autoComplete')[0]) { + document.getElementsByClassName('autoComplete')[0].remove(); + } + } + + var request = new XMLHttpRequest(); + request.open('GET', '/suggestion/' + searchData, true); + request.onload = function () { + var data = JSON.parse(this.response); + + var wrapper = document.createElement('div'); + wrapper.className = "autoComplete"; + container.appendChild(wrapper); + if (request.status >= 200 && request.status < 400) { + data.forEach(res => { + + const searchResultsContainer = document.createElement('div'); + searchResultsContainer.setAttribute('class', 'row'); + + const h1 = document.createElement('a'); + h1.textContent = res; + h1.href = "/search/" + res + wrapper.appendChild(searchResultsContainer); + searchResultsContainer.appendChild(h1); + }); + } else { + console.log('error'); + } + }; + request.send(); + } +} + +document.addEventListener('DOMContentLoaded', function() { +// Get the input field +var input = document.getElementById("searchYTM"); + +// Execute a function when the user presses a key on the keyboard +input.addEventListener("keypress", function(event) { + console.log("test"); + // If the user presses the "Enter" key on the keyboard + if (event.key === "Enter") { + // Cancel the default action, if needed + event.preventDefault(); + // Trigger the button element with a click + window.location.href = "/search/" + input.value; + } +}); +}); \ No newline at end of file diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..b75dd6f --- /dev/null +++ b/static/style.css @@ -0,0 +1,231 @@ +/* General Reset & Background */ +body { + background-color: #0f0f0f; + color: #ffffff; + font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + margin: 0; + padding: 20px; + display: flex; + flex-direction: column; + align-items: center; +} + +/* Header & Navigation */ +#header { + width: 100%; + max-width: 1200px; + display: flex; + justify-content: center; + margin-bottom: 30px; + border-bottom: 1px solid #333; + padding-bottom: 10px; +} + +#header ol { + list-style: none; + padding: 0; + display: flex; + gap: 40px; +} + +#header a { + color: #aaa; + text-decoration: none; + font-weight: 500; + font-size: 1.1rem; + transition: color 0.3s; +} + +#header a:hover { + color: #fff; +} + +/* Search Container */ +#container { + width: 100%; + max-width: 800px; + margin: 20px auto; + position: relative; +} + +.form-control { + width: 100%; + padding: 14px 25px; + background: #222; + border: 1px solid #444; + border-radius: 30px; + color: white; + font-size: 16px; + outline: none; + box-sizing: border-box; +} + +.autoComplete { + position: absolute; + top: 100%; + left: 0; + right: 0; + background: #282828; + border-radius: 12px; + margin-top: 8px; + box-shadow: 0 8px 24px rgba(0,0,0,0.6); + z-index: 1000; + overflow: hidden; +} + +.autoComplete .row { + padding: 12px 20px; + border-bottom: 1px solid #383838; + text-align: left; +} + +.autoComplete .row a { + color: #eee; + text-decoration: none; + display: block; +} + +.autoComplete .row:hover { background: #3e3e3e; } + +/* Section Titles */ +p { + width: 100%; + max-width: 1200px; + font-size: 1.8rem; + font-weight: bold; + margin-top: 50px; + text-align: center; + color: #fff; +} + +/* Item Rows */ +div:has(img) { + width: 100%; + max-width: 1200px; + display: flex; + flex-direction: row; + align-items: center; + justify-content: flex-start; + gap: 20px; + padding: 10px 20px; + border-radius: 8px; + transition: background 0.2s; + margin-bottom: 8px; + box-sizing: border-box; + white-space: nowrap; +} + +div:has(img):hover { background: #1e1e1e; } + +img { + border-radius: 4px; + object-fit: cover; + flex-shrink: 0; +} + +/* Artist Profile Large Image */ +img[width="200"] { + border-radius: 50%; + margin: 20px auto; + display: block; + border: 4px solid #222; +} + +/* Text Elements */ +a.video { + font-weight: 600; + color: #fff; + text-decoration: none; + overflow: hidden; + text-overflow: ellipsis; + min-width: 250px; +} + +a.artist { + color: #aaa; + text-decoration: none; + font-size: 0.95rem; + margin-right: 10px; +} + +a.artist:hover { color: #fff; text-decoration: underline; } + +.subscribers { + display: block; + color: #888; + font-size: 1rem; + text-align: center; + margin-bottom: 30px; +} + +/* --- REACTIVE BUTTONS --- */ +.button { + position: relative; + background: #2a2a2a; + color: white; + border: none; + padding: 10px 18px; + border-radius: 20px; + cursor: pointer; + font-size: 0.85rem; + font-weight: 600; + transition: all 0.1s; + flex-shrink: 0; + min-width: 120px; /* Ensures button doesn't jump size */ + display: flex; + justify-content: center; + align-items: center; +} + +/* Push first button to the right */ +div:has(img) button:first-of-type { + margin-left: auto; +} + +.button.next { + background: #ffffff; + color: #000; +} + +/* Hover State */ +.button:hover { + transform: scale(1.05); + background: #444; +} +.button.next:hover { background: #ddd; } + +/* The Success State (Applied by JS) */ +.button.active-click { + background-color: #2ecc71 !important; + color: transparent !important; + transform: scale(0.95); +} + +.button.active-click::after { + content: "✓"; + position: absolute; + color: white; + font-size: 1.2rem; +} + +.button.next.active-click::after { + color: black; /* For visibility on the white button */ +} + +/* Outdated Browser Warning */ +.browsehappy { + width: 100%; + background: #ffcc00; + color: #000; + padding: 15px; + text-align: center; + position: sticky; + top: 0; + z-index: 2000; +} + +/* Responsive */ +@media (max-width: 900px) { + div:has(img) { white-space: normal; flex-wrap: wrap; } + div:has(img) button:first-of-type { margin-left: 0; } +} \ No newline at end of file diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..2dd1854 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + \ No newline at end of file