Compare commits

...
9 Commits
Author SHA1 Message Date
Ethanscharlie 83c2593ff4 Added README 2026-09-19 21:49:06 -06:00
Ethanscharlie 170914539d Updated requirements.txt 2026-09-19 21:48:58 -06:00
Ethanscharlie 4060cb672f Added Deno 2026-09-19 21:48:51 -06:00
Ethanscharlie 7acb9f63a3 Fixed small artists 2026-09-19 01:21:14 -06:00
Ethanscharlie 0897b3002c Fixed the / in title bug 2026-09-18 23:49:53 -06:00
Ethanscharlie b6d155f071 Artist Ripper working 2026-09-18 23:45:26 -06:00
Ethanscharlie 86d8283217 Updated ui 2026-09-07 20:55:15 -06:00
Ethanscharlie f01b246934 Fixed topic channel name 2026-09-07 20:21:22 -06:00
Ethanscharlie ed0eab672b Added constants that use environment variables 2026-09-07 19:01:05 -06:00
6 changed files with 454 additions and 178 deletions
+21
View File
@@ -0,0 +1,21 @@
# Running with Docker
TBD
# Dependences
This section is only for people who do not want to use docker.
## Python Dependences
```
pip install -r requirements.txt
```
## Deno (JavaScript runtime for ytdlp)
For Linux
```
curl -fsSL https://deno.land/install.sh | sh
```
For Windows
```
irm https://deno.land/install.ps1 | iex
```
+137 -10
View File
@@ -1,4 +1,5 @@
from dataclasses import dataclass from dataclasses import dataclass
from http.client import NETWORK_AUTHENTICATION_REQUIRED
import pathlib import pathlib
import os import os
import re import re
@@ -15,9 +16,15 @@ import mutagen
from mutagen.easyid3 import EasyID3 from mutagen.easyid3 import EasyID3
from mutagen.mp3 import MP3 from mutagen.mp3 import MP3
from mutagen.id3 import ID3, APIC from mutagen.id3 import ID3, APIC
from flask import Flask, render_template, request, jsonify, send_file from flask import Flask, render_template, request, jsonify
from ytmusicapi import YTMusic
MUSIC_FOLDER = "Music" MUSIC_FOLDER: str = os.environ.get("HAPPY_RIPPER_MUSIC_DIR", "Music")
ACCESS_URL: str = os.environ.get("HAPPY_RIPPER_ACCESS_URL", "https://test.test.test")
PORT = int(os.environ.get("HAPPY_RIPPER_MUSIC_PORT", 5000))
DENO_PATH: str = os.environ.get("HAPPY_RIPPER_DENO_PATH", str(pathlib.Path.home() / ".deno/bin/deno"))
JS_RUNTIME_KEYS = {"deno": {"path": DENO_PATH}}
@dataclass @dataclass
@@ -55,7 +62,7 @@ class MetadataPostProcessor(PostProcessor):
audio = EasyID3(file) audio = EasyID3(file)
audio.delete() audio.delete()
audio["title"] = information["title"] audio["title"] = information["title"]
audio["artist"] = information["uploader"] audio["artist"] = information["uploader"].replace(" - Topic", "")
audio["album"] = self.info.title audio["album"] = self.info.title
@@ -97,7 +104,7 @@ def create_or_wipe_folder(path: str) -> None:
def get_content_json(url: str): def get_content_json(url: str):
with YoutubeDL({"playlist_items": "1"}) as ydl: with YoutubeDL({"js_runtimes": JS_RUNTIME_KEYS, "playlist_items": "1"}) as ydl:
return ydl.extract_info(url, download=False) return ydl.extract_info(url, download=False)
@@ -105,6 +112,7 @@ def download_content(path: str, url: str, post_processor: PostProcessor) -> None
try: try:
with YoutubeDL( with YoutubeDL(
{ {
"js_runtimes": JS_RUNTIME_KEYS,
"format": "bestaudio/best", "format": "bestaudio/best",
"ignoreerrors": True, "ignoreerrors": True,
"restrictfilenames": True, "restrictfilenames": True,
@@ -171,6 +179,9 @@ def find_release_info(url: str) -> ReleaseInfo:
art_url = thumbnails[1]["url"] art_url = thumbnails[1]["url"]
if not is_album:
artist = "Various Artists"
return ReleaseInfo(artist, title, art_url, is_album, is_topic) return ReleaseInfo(artist, title, art_url, is_album, is_topic)
@@ -193,9 +204,9 @@ def sort_into_music(path: str, info: ReleaseInfo) -> set[str]:
raise RipError("Artist tag was None") raise RipError("Artist tag was None")
artist = artist_list[0] artist = artist_list[0]
re.sub(r"[^\w_. -]", "_", artist) artist = re.sub(r"[^\w_. -]", "_", artist)
title = info.title title = info.title
re.sub(r"[^\w_. -]", "_", title) title = re.sub(r"[^\w_. -]", "_", title)
artist_path = os.path.join(MUSIC_FOLDER, artist) artist_path = os.path.join(MUSIC_FOLDER, artist)
release_path = os.path.join(MUSIC_FOLDER, artist, title) release_path = os.path.join(MUSIC_FOLDER, artist, title)
@@ -262,8 +273,11 @@ def rip_worker(rip_queue: list[str], status_callback: Callable):
status_callback(f"Downloading content ({on_index})", False) status_callback(f"Downloading content ({on_index})", False)
download_content(tmp_path, url, MetadataPostProcessor(info, art_path, on_download_finish)) download_content(tmp_path, url, MetadataPostProcessor(info, art_path, on_download_finish))
if len(os.listdir(tmp_path)) == 0:
raise RipError("No content was downloaded") filelist = os.listdir(tmp_path)
filelist.remove("cover.jpg")
if len(filelist) == 0:
raise RipError("Nothing ripped, Try again")
status_callback("Sorting content", False) status_callback("Sorting content", False)
destinations = sort_into_music(tmp_path, info) destinations = sort_into_music(tmp_path, info)
@@ -279,11 +293,40 @@ def rip_worker(rip_queue: list[str], status_callback: Callable):
status_callback("Finished sucessfully", True) status_callback("Finished sucessfully", True)
def get_all_playlists_from_channel(browseId: str) -> list[str]:
urls: list[str] = []
yt = YTMusic()
artist = yt.get_artist(browseId)
albums_info = artist["albums"]
if "params" in albums_info:
albums = yt.get_artist_albums(
albums_info["browseId"],
albums_info["params"],
limit=None,
)
for album in albums:
playlist_id = album.get("playlistId")
if playlist_id:
urls.append(f"https://music.youtube.com/playlist?list={playlist_id}")
else:
for album in artist["albums"]["results"]:
playlist_id = album.get("audioPlaylistId")
if playlist_id:
urls.append(f"https://music.youtube.com/playlist?list={playlist_id}")
return urls
if __name__ == "__main__": if __name__ == "__main__":
app = Flask(__name__) app = Flask(__name__)
rip_queue: list[str] = [] rip_queue: list[str] = []
finished_queue: list[ReleaseStatus] = [] finished_queue: list[ReleaseStatus] = []
yt_music = YTMusic()
current_release_info: ReleaseInfo | None = None current_release_info: ReleaseInfo | None = None
current_release_status_message: str = "" current_release_status_message: str = ""
current_release_destinations: set[str] = set() current_release_destinations: set[str] = set()
@@ -323,13 +366,33 @@ if __name__ == "__main__":
def index(): def index():
return render_template("index.html") return render_template("index.html")
@app.route("/browse")
def browse():
return render_template("browse.html")
@app.route("/rip", methods=["POST"]) @app.route("/rip", methods=["POST"])
def rip(): def rip():
print("RIP")
url = request.form.get("url") url = request.form.get("url")
if url is None: if url is None:
return jsonify({"success": False, "message": "Url is None"}) return jsonify({"success": False, "message": "Url is None"})
rip_queue.append(url) is_channel = request.form.get("is-channel-check")
is_channel = is_channel is not None
if is_channel:
channel_playlists = get_all_playlists_from_channel(url)
if len(channel_playlists) == 0:
print("ERROR: Found no playlists in channel")
return jsonify({"success": False, "message": "channel has no playlists"})
for playlist_url in channel_playlists:
rip_queue.append(playlist_url)
else:
rip_queue.append(url)
return jsonify({"success": True, "message": f"Added, {url}!"}) return jsonify({"success": True, "message": f"Added, {url}!"})
@app.route("/status", methods=["GET"]) @app.route("/status", methods=["GET"])
@@ -359,4 +422,68 @@ if __name__ == "__main__":
return jsonify(data) return jsonify(data)
app.run() @app.route("/search", methods=["POST"])
def search():
global yt_music
query = request.form.get("search")
if query is None:
return jsonify({"success": False, "message": "Search query is None"})
responce = yt_music.search(query)
data = []
for release in responce:
release_data = {}
# Category ===============================================================
category = release.get("resultType")
if category is None:
return jsonify({"success": False, "message": "Category is None"})
release_data["category"] = category
# Artist ===============================================================
if "artist" in release:
release_data["artist"] = release.get("artist", "Unknown")
elif "artists" in release:
artists = release.get("artists")
if artists is None:
return jsonify({"success": False, "message": "Artists exists but is None"})
release_data["artist"] = artists[0].get("name", "Unknown")
print(artists)
elif "author" in release:
release_data["artist"] = release.get("author", "Unknown")
else:
release_data["artist"] = "Unknown"
# Title ===============================================================
if category == "artist":
release_data["title"] = release_data["artist"]
else:
release_data["title"] = release.get("title", "Unknown")
# Thumbnails ===============================================================
if "thumbnails" in release:
release_data["thumbnails"] = release.get("thumbnails")
else:
return jsonify({"success": False, "message": "Thumbnails is None"})
if "playlistId" in release:
release_data["url"] = f"https://music.youtube.com/playlist?list={release['playlistId']}"
elif "videoId" in release:
release_data["url"] = f"https://music.youtube.com/watch?v={release['videoId']}"
elif "browseId" in release and category == "artist":
release_data["url"] = release.get("browseId")
elif category == "artist" and "artists" in release:
release_data["url"] = release.get("artists")[0].get("id")
data.append(release_data)
return jsonify(data)
app.run(host="0.0.0.0", port=PORT)
+1
View File
@@ -1,3 +1,4 @@
Flask==3.1.3 Flask==3.1.3
mutagen==1.47.0 mutagen==1.47.0
yt_dlp==2026.8.19 yt_dlp==2026.8.19
ytmusicapi==1.12.1
+47
View File
@@ -0,0 +1,47 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Happy Ripper</title>
<link
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css"
rel="stylesheet"
integrity="sha384-sRIl4kxILFvY47J16cr9ZwB07vP4J8+LH7qKQnuqkuIAvNWLzeN8tE5YBujZqJLB"
crossorigin="anonymous"
/>
</head>
<body>
<nav class="navbar navbar-expand-lg bg-body-tertiary">
<div class="container-fluid">
<a class="navbar-brand" href="#">Happy Ripper</a>
<button
class="navbar-toggler"
type="button"
data-bs-toggle="collapse"
data-bs-target="#navbarNavAltMarkup"
aria-controls="navbarNavAltMarkup"
aria-expanded="false"
aria-label="Toggle navigation"
>
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNavAltMarkup">
<div class="navbar-nav">
<a class="nav-link active" aria-current="page" href="/">Queue</a>
<a class="nav-link" href="/browse">Browse</a>
</div>
</div>
</div>
</nav>
<section class="content">{% block content %}{% endblock %}</section>
<script
src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/js/bootstrap.bundle.min.js"
integrity="sha384-FKyoEForCGlyvwx9Hj09JcYn3nv7wiPVlz7YYwJrWVcXK/BmnVDxM+D2scQbITxI"
crossorigin="anonymous"
></script>
</body>
</html>
+95
View File
@@ -0,0 +1,95 @@
{% extends 'base.html' %} {% block content %}
<div class="container">
<form id="browse-form">
<div class="mb-3">
<input
type="text"
name="search"
id="searchInput"
placeholder="Album, Arist, Playlist..."
class="form-control"
required
/>
</div>
<button type="submit" class="btn btn-primary">Search</button>
</form>
</div>
</br>
<div class="container" id="results"></div>
<script>
async function enqueue(url, is_artist) {
const formData = new FormData();
formData.append("url", url);
if (is_artist) {
formData.append("is-channel-check", "");
}
const response = await fetch("/rip", {
method: "POST",
body: formData,
});
const data = await response.json();
}
const searchInput = document.getElementById("searchInput");
const results = document.getElementById("results");
document
.getElementById("browse-form")
.addEventListener("submit", async function (e) {
e.preventDefault();
const formData = new FormData(this);
const response = await fetch("/search", {
method: "POST",
body: formData,
});
const data = await response.json();
console.log(data)
results.innerHTML = "";
for (release of data) {
var button = "url" in release ? `
<div class="col">
<button class="btn btn-primary" onclick="enqueue('${release.url}', ${release.category == 'artist'})">
Enqueue
</button>
</div>` : "";
results.innerHTML += `
<div class="card" style="max-width: 720px;">
<div class="row">
<div class="col">
<img
src="${release.thumbnails[0].url}"
class="card-img-top"
style="max-width: 120px;"
>
</div>
<div class="col">
<div class="card-body">
<h5 class="card-title">${release.title}</h5>
<p class="card-text">${release.category} • ${release.artist}</p>
</div>
</div>
${button}
</div>
</div>
<br>
`;
}
});
</script>
{% endblock %}
+153 -168
View File
@@ -1,174 +1,159 @@
<!doctype html> {% extends 'base.html' %} {% block content %}
<html lang="en"> <div class="container">
<head> <h2>Request</h2>
<meta charset="UTF-8" /> <form id="rip-form">
<meta name="viewport" content="width=device-width, initial-scale=1" /> <div class="mb-3">
<title>Happy Ripper</title> <input
<link type="text"
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css" name="url"
rel="stylesheet" id="urlInput"
integrity="sha384-sRIl4kxILFvY47J16cr9ZwB07vP4J8+LH7qKQnuqkuIAvNWLzeN8tE5YBujZqJLB" placeholder="https://music.youtube.com/..."
crossorigin="anonymous" class="form-control"
/> required
</head> />
</div>
<body>
<div class="container"> <div class="mb-3 form-check">
<h2>Request</h2> <input type="checkbox" class="form-check-input" id="is-channel-check" name="is-channel-check">
<form id="rip-form"> <label class="form-check-label" for="is-channel-check">Is Channel</label>
<div class="mb-3"> </div>
<input
type="text" <button type="submit" class="btn btn-primary">Queue</button>
name="url" </form>
id="urlInput" </div>
placeholder="https://music.youtube.com/..."
class="form-control" </br>
required
/> <div class="container">
<h2>Current</h2>
<div id="status-message"></div>
</div>
</br>
<div class="container">
<h2>Queue</h2>
<table class="table" id="queue-table">
<thead>
<tr>
<th scope="col">#</th>
<th scope="col">URL</th>
</tr>
</thead>
<tbody id="queue-table-body"></tbody>
</table>
</div>
</br>
<div class="container">
<h2>Finished</h2>
<div id=finish-table-body></div>
</div>
<script>
function createCard(artist, title, status, art) {
return `
<div class="card mb-3" style="max-width: 540px;">
<div class="row g-0">
<div class="col-md-4">
<img src="${art}" class="img-fluid rounded-start">
</div>
<div class="col-md-8">
<div class="card-body">
<h5 class="card-title">${title}</h5>
<p class="card-text">${artist}</p>
<p class="card-text"><small class="text-body-secondary">${status}</small></p>
</div>
</div>
</div>
</div> </div>
<button type="submit" class="btn btn-primary">Queue</button> `;
</form> }
</div>
function createFinishCard(artist, title, status, art, destinations) {
</br> let dstList = ""
for (destination of destinations) {
<div class="container"> dstList += `<li>${destination}</li>`;
<h2>Current</h2> }
<div id="status-message"></div>
</div> let extraClasses = ""
if (status !== "Finished sucessfully") {
</br> extraClasses = "border-danger"
}
<div class="container">
<h2>Queue</h2> return `
<table class="table" id="queue-table"> <div class="card mb-3 ${extraClasses}" style="max-width: 540px;">
<thead> <div class="row g-0">
<tr> <div class="col-md-4">
<th scope="col">#</th> <img src="${art}" class="img-fluid rounded-start">
<th scope="col">URL</th> </div>
</tr> <div class="col-md-8">
</thead> <div class="card-body">
<tbody id="queue-table-body"></tbody> <h5 class="card-title">${title}</h5>
</table> <p class="card-text">${artist}</p>
</div>
<ol>
</br> ${dstList}
</ol>
<div class="container">
<h2>Finished</h2> <p class="card-text"><small class="text-body-secondary">${status}</small></p>
<div id=finish-table-body></div>
</div>
<script>
function createCard(artist, title, status, art) {
return `
<div class="card mb-3" style="max-width: 540px;">
<div class="row g-0">
<div class="col-md-4">
<img src="${art}" class="img-fluid rounded-start">
</div>
<div class="col-md-8">
<div class="card-body">
<h5 class="card-title">${title}</h5>
<p class="card-text">${artist}</p>
<p class="card-text"><small class="text-body-secondary">${status}</small></p>
</div>
</div> </div>
</div> </div>
</div> </div>
`; </div>
} `;
}
function createFinishCard(artist, title, status, art, destinations) {
let dstList = "" const ripFormInput = document.getElementById("urlInput");
for (destination of destinations) {
dstList += `<li>${destination}</li>`; document
} .getElementById("rip-form")
.addEventListener("submit", async function (e) {
let extraClasses = "" e.preventDefault();
if (status !== "Finished sucessfully") {
extraClasses = "border-danger" const formData = new FormData(this);
}
const response = await fetch("/rip", {
return ` method: "POST",
<div class="card mb-3 ${extraClasses}" style="max-width: 540px;"> body: formData,
<div class="row g-0">
<div class="col-md-4">
<img src="${art}" class="img-fluid rounded-start">
</div>
<div class="col-md-8">
<div class="card-body">
<h5 class="card-title">${title}</h5>
<p class="card-text">${artist}</p>
<ol>
${dstList}
</ol>
<p class="card-text"><small class="text-body-secondary">${status}</small></p>
</div>
</div>
</div>
</div>
`;
}
const ripFormInput = document.getElementById("urlInput");
document
.getElementById("rip-form")
.addEventListener("submit", async function (e) {
e.preventDefault();
const formData = new FormData(this);
const response = await fetch("/rip", {
method: "POST",
body: formData,
});
const data = await response.json();
ripFormInput.value = "";
}); });
const queueTableBody = document.getElementById("queue-table-body"); const data = await response.json();
const finishTableBody = document.getElementById("finish-table-body"); ripFormInput.value = "";
const statusMessage = document.getElementById("status-message"); });
const statusMessageImage = document.getElementById("status-message-img");
const queueTableBody = document.getElementById("queue-table-body");
async function status() { const finishTableBody = document.getElementById("finish-table-body");
const response = await fetch("/status", { method: "GET" }); const statusMessage = document.getElementById("status-message");
const statusJson = await response.json(); const statusMessageImage = document.getElementById("status-message-img");
if ("currentReleaseInfo" in statusJson) { async function status() {
const currentInfo = statusJson.currentReleaseInfo; const response = await fetch("/status", { method: "GET" });
statusMessage.innerHTML = createCard(currentInfo.artist, currentInfo.title, statusJson.status, currentInfo.art_url); const statusJson = await response.json();
}
else { if ("currentReleaseInfo" in statusJson) {
statusMessage.innerHTML = `<p>${statusJson.status}</p>`; const currentInfo = statusJson.currentReleaseInfo;
} statusMessage.innerHTML = createCard(currentInfo.artist, currentInfo.title, statusJson.status, currentInfo.art_url);
queueTableBody.innerHTML = "";
var i = 1;
for (const url of statusJson.queue) {
queueTableBody.innerHTML += `<tr><td>${i}</td><td>${url}</td></tr>`;
i ++;
}
finishTableBody.innerHTML = "";
for (const q of statusJson.finishedQueue) {
finishTableBody.innerHTML += createFinishCard(q.artist, q.title, q.status, q.art_url, q.destinations);
}
} }
else {
setInterval(status, 100); statusMessage.innerHTML = `<p>${statusJson.status}</p>`;
</script> }
<script queueTableBody.innerHTML = "";
src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/js/bootstrap.bundle.min.js"
integrity="sha384-FKyoEForCGlyvwx9Hj09JcYn3nv7wiPVlz7YYwJrWVcXK/BmnVDxM+D2scQbITxI" var i = 1;
crossorigin="anonymous" for (const url of statusJson.queue) {
></script> queueTableBody.innerHTML += `<tr><td>${i}</td><td>${url}</td></tr>`;
</body> i ++;
</html> }
finishTableBody.innerHTML = "";
for (const q of statusJson.finishedQueue.toReversed()) {
finishTableBody.innerHTML += createFinishCard(q.artist, q.title, q.status, q.art_url, q.destinations);
}
}
setInterval(status, 100);
</script>
{% endblock %}