Compare commits

..
11 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
6 changed files with 455 additions and 179 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)
@@ -229,7 +240,7 @@ def sort_into_music(path: str, info: ReleaseInfo) -> set[str]:
return destinations return destinations
def rip_worker(rip_queue: list[str], status_callback: Callable[[ReleaseInfo | str | set[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:
@@ -262,8 +273,11 @@ 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)
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[[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() 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"})
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) 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 %}
+9 -24
View File
@@ -1,18 +1,4 @@
<!doctype html> {% extends 'base.html' %} {% block content %}
<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>
<div class="container"> <div class="container">
<h2>Request</h2> <h2>Request</h2>
<form id="rip-form"> <form id="rip-form">
@@ -26,6 +12,12 @@
required required
/> />
</div> </div>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="is-channel-check" name="is-channel-check">
<label class="form-check-label" for="is-channel-check">Is Channel</label>
</div>
<button type="submit" class="btn btn-primary">Queue</button> <button type="submit" class="btn btn-primary">Queue</button>
</form> </form>
</div> </div>
@@ -157,18 +149,11 @@
} }
finishTableBody.innerHTML = ""; finishTableBody.innerHTML = "";
for (const q of statusJson.finishedQueue) { for (const q of statusJson.finishedQueue.toReversed()) {
finishTableBody.innerHTML += createFinishCard(q.artist, q.title, q.status, q.art_url, q.destinations); finishTableBody.innerHTML += createFinishCard(q.artist, q.title, q.status, q.art_url, q.destinations);
} }
} }
setInterval(status, 100); setInterval(status, 100);
</script> </script>
{% endblock %}
<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>