Compare commits

...
13 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
Ethanscharlie d2f83ee5af Merge 2026-09-07 18:48:15 -06:00
Ethanscharlie cb9b7dabf8 Removed comma at the end 2026-09-07 18:46:27 -06:00
Ethanscharlie 3f5cc7e8de Removed comma at the end 2026-09-07 18:44:04 -06:00
Ethanscharlie 66144e92d4 Shows destinations 2026-09-07 17:26:07 -06:00
6 changed files with 481 additions and 149 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
```
+164 -15
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
@@ -16,8 +17,14 @@ 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 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
@@ -33,6 +40,7 @@ class ReleaseInfo:
class ReleaseStatus: class ReleaseStatus:
info: ReleaseInfo | None info: ReleaseInfo | None
status: str status: str
destinations: set[str]
class RipError(Exception): class RipError(Exception):
@@ -54,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
@@ -96,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)
@@ -104,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,
@@ -170,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)
@@ -178,7 +190,9 @@ def find_tmp_path() -> str:
return f"{project_root_path}/tmp" return f"{project_root_path}/tmp"
def sort_into_music(path: str, info: ReleaseInfo) -> None: def sort_into_music(path: str, info: ReleaseInfo) -> set[str]:
destinations: set[str] = set()
try: try:
for file_name in os.listdir(path): for file_name in os.listdir(path):
if file_name == "cover.jpg": if file_name == "cover.jpg":
@@ -190,9 +204,9 @@ def sort_into_music(path: str, info: ReleaseInfo) -> None:
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)
@@ -218,11 +232,15 @@ def sort_into_music(path: str, info: ReleaseInfo) -> None:
source_file_path_cover = os.path.join(path, "cover.jpg") source_file_path_cover = os.path.join(path, "cover.jpg")
shutil.copy(source_file_path_cover, release_path) shutil.copy(source_file_path_cover, release_path)
destinations.add(release_path)
except Exception: except Exception:
raise RipError("Filesystem error") raise RipError("Filesystem error")
return destinations
def rip_worker(rip_queue: list[str], status_callback: Callable[[ReleaseInfo | str, bool],]):
def rip_worker(rip_queue: list[str], status_callback: Callable):
tmp_path = find_tmp_path() tmp_path = find_tmp_path()
while True: while True:
@@ -255,11 +273,15 @@ def rip_worker(rip_queue: list[str], status_callback: Callable[[ReleaseInfo | st
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)
sort_into_music(tmp_path, info) destinations = sort_into_music(tmp_path, info)
status_callback(destinations, False)
except RipError as e: except RipError as e:
status_callback(e.msg, True) status_callback(e.msg, True)
@@ -271,17 +293,48 @@ def rip_worker(rip_queue: list[str], status_callback: Callable[[ReleaseInfo | st
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()
def get_current_release_status(x: ReleaseInfo | str, finished: bool): def get_current_release_status(x: ReleaseInfo | str | set[str], finished: bool):
global current_release_info global current_release_info
global current_release_status_message global current_release_status_message
global current_release_destinations
global finished_queue global finished_queue
if type(x) is ReleaseInfo: if type(x) is ReleaseInfo:
@@ -290,13 +343,19 @@ if __name__ == "__main__":
elif type(x) is str: elif type(x) is str:
current_release_status_message = x current_release_status_message = x
elif type(x) is set:
current_release_destinations = x.copy()
if finished: if finished:
if current_release_info is None: if current_release_info is None:
current_release_info = ReleaseInfo("Unknown", "Unknown", "url", False, False) current_release_info = ReleaseInfo("Unknown", "Unknown", "url", False, False)
finished_queue.append(ReleaseStatus(current_release_info, current_release_status_message)) finished_queue.append(
ReleaseStatus(current_release_info, current_release_status_message, current_release_destinations.copy())
)
current_release_info = None current_release_info = None
current_release_status_message = "Waiting" current_release_status_message = "Waiting"
current_release_destinations = set()
if len(finished_queue) > 30: if len(finished_queue) > 30:
finished_queue.pop(0) finished_queue.pop(0)
@@ -307,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"])
@@ -325,7 +404,13 @@ if __name__ == "__main__":
continue continue
data["finishedQueue"].append( data["finishedQueue"].append(
{"artist": q.info.artist, "title": q.info.title, "status": q.status, "art_url": q.info.art_url} {
"artist": q.info.artist,
"title": q.info.title,
"status": q.status,
"art_url": q.info.art_url,
"destinations": list(q.destinations),
}
) )
if current_release_info is not None: if current_release_info is not None:
@@ -337,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 %}
+134 -115
View File
@@ -1,140 +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="mb-3 form-check">
<div class="container"> <input type="checkbox" class="form-check-input" id="is-channel-check" name="is-channel-check">
<h2>Request</h2> <label class="form-check-label" for="is-channel-check">Is Channel</label>
<form id="rip-form"> </div>
<div class="mb-3">
<input
type="text"
name="url"
id="urlInput"
placeholder="https://music.youtube.com/..."
class="form-control"
required
/>
</div>
<button type="submit" class="btn btn-primary">Queue</button>
</form>
</div>
</br> <button type="submit" class="btn btn-primary">Queue</button>
</form>
</div>
<div class="container"> </br>
<h2>Current</h2>
<div id="status-message"></div>
</div>
</br> <div class="container">
<h2>Current</h2>
<div id="status-message"></div>
</div>
<div class="container"> </br>
<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>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>
<div class="container"> </br>
<h2>Finished</h2>
<div id=finish-table-body></div>
</div>
<script> <div class="container">
function createCard(artist, title, status, art) { <h2>Finished</h2>
return ` <div id=finish-table-body></div>
<div class="card mb-3" style="max-width: 540px;"> </div>
<div class="row g-0">
<div class="col-md-4"> <script>
<img src="${art}" class="img-fluid rounded-start"> function createCard(artist, title, status, art) {
</div> return `
<div class="col-md-8"> <div class="card mb-3" style="max-width: 540px;">
<div class="card-body"> <div class="row g-0">
<h5 class="card-title">${title}</h5> <div class="col-md-4">
<p class="card-text">${artist}</p> <img src="${art}" class="img-fluid rounded-start">
<p class="card-text"><small class="text-body-secondary">${status}</small></p> </div>
</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>
`;
}
function createFinishCard(artist, title, status, art, destinations) {
let dstList = ""
for (destination of destinations) {
dstList += `<li>${destination}</li>`;
} }
const ripFormInput = document.getElementById("urlInput"); let extraClasses = ""
if (status !== "Finished sucessfully") {
extraClasses = "border-danger"
}
document return `
.getElementById("rip-form") <div class="card mb-3 ${extraClasses}" style="max-width: 540px;">
.addEventListener("submit", async function (e) { <div class="row g-0">
e.preventDefault(); <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>
const formData = new FormData(this); <ol>
${dstList}
</ol>
const response = await fetch("/rip", { <p class="card-text"><small class="text-body-secondary">${status}</small></p>
method: "POST", </div>
body: formData, </div>
}); </div>
</div>
`;
}
const data = await response.json(); const ripFormInput = document.getElementById("urlInput");
ripFormInput.value = "";
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 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");
async function status() { const queueTableBody = document.getElementById("queue-table-body");
const response = await fetch("/status", { method: "GET" }); const finishTableBody = document.getElementById("finish-table-body");
const statusJson = await response.json(); const statusMessage = document.getElementById("status-message");
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 {
statusMessage.innerHTML = `<p>${statusJson.status}</p>`;
}
queueTableBody.innerHTML = ""; if ("currentReleaseInfo" in statusJson) {
const currentInfo = statusJson.currentReleaseInfo;
var i = 1; statusMessage.innerHTML = createCard(currentInfo.artist, currentInfo.title, statusJson.status, currentInfo.art_url);
for (const url of statusJson.queue) { }
queueTableBody.innerHTML += `<tr><td>${i}</td><td>${url}</td></tr>`; else {
i ++; statusMessage.innerHTML = `<p>${statusJson.status}</p>`;
}
finishTableBody.innerHTML = "";
for (const q of statusJson.finishedQueue) {
finishTableBody.innerHTML += createCard(q.artist, q.title, q.status, q.art_url);
}
} }
setInterval(status, 100); queueTableBody.innerHTML = "";
</script>
<script var i = 1;
src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/js/bootstrap.bundle.min.js" for (const url of statusJson.queue) {
integrity="sha384-FKyoEForCGlyvwx9Hj09JcYn3nv7wiPVlz7YYwJrWVcXK/BmnVDxM+D2scQbITxI" queueTableBody.innerHTML += `<tr><td>${i}</td><td>${url}</td></tr>`;
crossorigin="anonymous" i ++;
></script> }
</body>
</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 %}