Compare commits

..
7 Commits
Author SHA1 Message Date
Ethanscharlie fad252a099 Added requirements.txt 2026-09-07 14:16:29 -06:00
Ethanscharlie 4a73d2f388 Everythings working 2026-09-07 14:15:20 -06:00
Ethanscharlie c56340ffed Videos downloading correctly 2026-09-07 11:58:18 -06:00
Ethanscharlie cc58529e99 Downloading playlists that aren't albums 2026-09-07 11:52:46 -06:00
Ethanscharlie 2e8a80c045 Cleaned code 2026-09-07 10:49:35 -06:00
Ethanscharlie 13e5d34446 Nice looking cards 2026-09-06 17:18:32 -06:00
Ethanscharlie 0f6a62335f Working queue and info json 2026-09-06 16:31:38 -06:00
20 changed files with 410 additions and 44 deletions
+3
View File
@@ -12,3 +12,6 @@ build/
.idea/ .idea/
*.swp *.swp
*~ *~
tmp
Music
cookies.txt
+319 -44
View File
@@ -1,65 +1,340 @@
from flask import Flask, render_template, request, jsonify from dataclasses import dataclass
from threading import Thread
import time
from yt_dlp import YoutubeDL
from yt_dlp.utils import DownloadError
import pathlib import pathlib
import os import os
import re
import shutil import shutil
import urllib.request
from threading import Thread
import time
from collections.abc import Callable
from yt_dlp import YoutubeDL
from yt_dlp.postprocessor import PostProcessor
from yt_dlp.utils import DownloadError, ExtractorError
import mutagen
from mutagen.easyid3 import EasyID3
from mutagen.mp3 import MP3
from mutagen.id3 import ID3, APIC
from flask import Flask, render_template, request, jsonify
MUSIC_FOLDER = "Music"
def rip_worker(rip_queue: list[str]): @dataclass
class ReleaseInfo:
artist: str
title: str
art_url: str
is_album: bool
is_topic: bool
@dataclass
class ReleaseStatus:
info: ReleaseInfo | None
status: str
class RipError(Exception):
def __init__(self, msg):
super().__init__(msg)
self.msg = msg
class MetadataPostProcessor(PostProcessor):
def __init__(self, info: ReleaseInfo, art_path: str, on_finish: Callable):
super().__init__()
self.info = info
self.art_path = art_path
self.on_finish = on_finish
def run(self, information):
file = pathlib.Path(information["filepath"])
audio = EasyID3(file)
audio.delete()
audio["title"] = information["title"]
audio["artist"] = information["uploader"]
audio["album"] = self.info.title
index = information.get("playlist_index")
if index is None:
index = 1
audio["tracknumber"] = str(index)
audio.save()
id3audio = MP3(file, ID3=ID3)
id3audio_tags = id3audio.tags
if id3audio_tags is None:
raise RipError("ID3 tags missing")
id3audio_tags.add(
APIC(
mime="image/jpeg",
type=3,
desc="Cover",
data=open(self.art_path, "rb").read(),
)
)
id3audio.save()
self.on_finish()
return [], information
def create_or_wipe_folder(path: str) -> None:
"""Creates tmp directory, if it already exists, it clears it, and makes a new one."""
if not os.path.isdir(path):
os.mkdir(path)
else:
shutil.rmtree(path)
os.mkdir(path)
def get_content_json(url: str):
with YoutubeDL({"playlist_items": "1"}) as ydl:
return ydl.extract_info(url, download=False)
def download_content(path: str, url: str, post_processor: PostProcessor) -> None:
try:
with YoutubeDL(
{
"format": "bestaudio/best",
"ignoreerrors": True,
"restrictfilenames": True,
"postprocessors": [
{
"key": "FFmpegExtractAudio",
"preferredcodec": "mp3",
}
],
"outtmpl": f"{path}/%(title)s.%(ext)s",
}
) as ydl:
ydl.add_post_processor(post_processor)
error_code = ydl.download([url])
if error_code > 1:
raise RipError(f"Content download failed ({error_code})")
except DownloadError:
raise RipError("Content Download Failed")
except ExtractorError:
raise RipError("Content Extraction Failed")
def find_release_info(url: str) -> ReleaseInfo:
try:
info = get_content_json(url)
except DownloadError:
raise RipError("Info Download Failed")
except ExtractorError:
raise RipError("Info Extraction Failed")
entries = info.get("entries")
if entries is None:
first_entry = info
else:
first_entry = next(iter(entries), None)
if not first_entry:
raise RipError("Entries is empty")
artist = first_entry.get("uploader")
if artist is None:
raise RipError("Uploader tag is none")
is_topic = False
if " - Topic" in artist:
is_topic = True
artist = artist.replace(" - Topic", "")
title = info.get("title")
if title is None:
raise RipError("Title tag is none")
is_album = False
if "Album - " in title:
is_album = True
title = title.replace("Album - ", "")
thumbnails = info.get("thumbnails")
if thumbnails is None:
raise RipError("Thumbnails is none")
art_url = thumbnails[1]["url"]
return ReleaseInfo(artist, title, art_url, is_album, is_topic)
def find_tmp_path() -> str:
project_root_path = pathlib.Path(__file__).parent.resolve() project_root_path = pathlib.Path(__file__).parent.resolve()
tmp_path = f"{project_root_path}/tmp" return f"{project_root_path}/tmp"
def sort_into_music(path: str, info: ReleaseInfo) -> None:
try:
for file_name in os.listdir(path):
if file_name == "cover.jpg":
continue
audio = EasyID3(os.path.join(path, file_name))
artist_list = audio.get("artist")
if artist_list is None:
raise RipError("Artist tag was None")
artist = artist_list[0]
re.sub(r"[^\w_. -]", "_", artist)
title = info.title
re.sub(r"[^\w_. -]", "_", title)
artist_path = os.path.join(MUSIC_FOLDER, artist)
release_path = os.path.join(MUSIC_FOLDER, artist, title)
if not os.path.exists(artist_path):
os.makedirs(artist_path)
if not os.path.exists(release_path):
os.mkdir(release_path)
dst_file_path = os.path.join(release_path, file_name)
dst_file_path_cover = os.path.join(release_path, "cover.jpg")
if os.path.exists(dst_file_path):
os.remove(dst_file_path)
if os.path.exists(dst_file_path_cover):
os.remove(dst_file_path_cover)
source_file_path = os.path.join(path, file_name)
shutil.move(source_file_path, release_path)
source_file_path_cover = os.path.join(path, "cover.jpg")
shutil.copy(source_file_path_cover, release_path)
except Exception:
raise RipError("Filesystem error")
def rip_worker(rip_queue: list[str], status_callback: Callable[[ReleaseInfo | str, bool],]):
tmp_path = find_tmp_path()
while True: while True:
queue_is_empty = len(rip_queue) == 0 if len(rip_queue) == 0:
if queue_is_empty: time.sleep(1)
continue continue
# Creates tmp directory, if it already exists, it clears it, and makes a new one. status_callback("Gathering info", False)
if not os.path.isdir(tmp_path):
os.mkdir(tmp_path)
else:
shutil.rmtree(tmp_path)
os.mkdir(tmp_path)
current_url = rip_queue[0] url = rip_queue.pop(0)
create_or_wipe_folder(tmp_path)
try: try:
with YoutubeDL( info = find_release_info(url)
{ status_callback(info, False)
"format": "m4a/bestaudio/best",
"postprocessors": [
{
"key": "FFmpegExtractAudio",
"preferredcodec": "m4a",
}
],
"outtmpl": f"{tmp_path}/%(title)s.%(ext)s",
}
) as ydl:
error_code = ydl.download([current_url])
except DownloadError: status_callback("Downloading art", False)
print("Download Error") art_path = os.path.join(tmp_path, "cover.jpg")
urllib.request.urlretrieve(info.art_url, art_path)
if not os.path.exists(art_path):
raise RipError("Art download failed")
rip_queue.pop(0) status_callback("Downloading content (0)", False)
time.sleep(1)
on_index = 0
def on_download_finish() -> None:
nonlocal on_index
on_index += 1
status_callback(f"Downloading content ({on_index})", False)
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")
status_callback("Sorting content", False)
sort_into_music(tmp_path, info)
except RipError as e:
status_callback(e.msg, True)
except Exception:
status_callback("Unknown Error", True)
else:
status_callback("Finished sucessfully", True)
app = Flask(__name__) if __name__ == "__main__":
rip_queue = [] app = Flask(__name__)
ripping_thread = Thread(target=rip_worker, args=(rip_queue,), daemon=True).start() rip_queue: list[str] = []
finished_queue: list[ReleaseStatus] = []
current_release_info: ReleaseInfo | None = None
current_release_status_message: str = ""
@app.route("/") def get_current_release_status(x: ReleaseInfo | str, finished: bool):
def index(): global current_release_info
return render_template("index.html") global current_release_status_message
global finished_queue
if type(x) is ReleaseInfo:
current_release_info = x
@app.route("/rip", methods=["POST"]) elif type(x) is str:
def rip(): current_release_status_message = x
url = request.form.get("url")
rip_queue.append(url) if finished:
return jsonify({"success": True, "message": f"Added, {url}!"}) if current_release_info is None:
current_release_info = ReleaseInfo("Unknown", "Unknown", "url", False, False)
finished_queue.append(ReleaseStatus(current_release_info, current_release_status_message))
current_release_info = None
current_release_status_message = "Waiting"
if len(finished_queue) > 30:
finished_queue.pop(0)
ripping_thread = Thread(target=rip_worker, args=(rip_queue, get_current_release_status), daemon=True).start()
@app.route("/")
def index():
return render_template("index.html")
@app.route("/rip", methods=["POST"])
def rip():
url = request.form.get("url")
if url is None:
return jsonify({"success": False, "message": "Url is None"})
rip_queue.append(url)
return jsonify({"success": True, "message": f"Added, {url}!"})
@app.route("/status", methods=["GET"])
def status():
data = {"success": True, "queue": rip_queue, "finishedQueue": [], "status": current_release_status_message}
for q in finished_queue:
if q.info is None:
continue
data["finishedQueue"].append(
{"artist": q.info.artist, "title": q.info.title, "status": q.status, "art_url": q.info.art_url}
)
if current_release_info is not None:
data["currentReleaseInfo"] = {
"artist": current_release_info.artist,
"title": current_release_info.title,
"art_url": current_release_info.art_url,
}
return jsonify(data)
app.run()
+3
View File
@@ -0,0 +1,3 @@
Flask==3.1.3
mutagen==1.47.0
yt_dlp==2026.8.19
+85
View File
@@ -30,7 +30,57 @@
</form> </form>
</div> </div>
</br>
<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> <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>
`;
}
const ripFormInput = document.getElementById("urlInput");
document document
.getElementById("rip-form") .getElementById("rip-form")
.addEventListener("submit", async function (e) { .addEventListener("submit", async function (e) {
@@ -44,8 +94,43 @@
}); });
const data = await response.json(); const data = await response.json();
ripFormInput.value = "";
}); });
const queueTableBody = document.getElementById("queue-table-body");
const finishTableBody = document.getElementById("finish-table-body");
const statusMessage = document.getElementById("status-message");
const statusMessageImage = document.getElementById("status-message-img");
async function status() {
const response = await fetch("/status", { method: "GET" });
const statusJson = await response.json();
if ("currentReleaseInfo" in statusJson) {
const currentInfo = statusJson.currentReleaseInfo;
statusMessage.innerHTML = createCard(currentInfo.artist, currentInfo.title, statusJson.status, currentInfo.art_url);
}
else {
statusMessage.innerHTML = `<p>${statusJson.status}</p>`;
}
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 += createCard(q.artist, q.title, q.status, q.art_url);
}
}
setInterval(status, 100);
</script> </script>
<script <script
src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/js/bootstrap.bundle.min.js" src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/js/bootstrap.bundle.min.js"
integrity="sha384-FKyoEForCGlyvwx9Hj09JcYn3nv7wiPVlz7YYwJrWVcXK/BmnVDxM+D2scQbITxI" integrity="sha384-FKyoEForCGlyvwx9Hj09JcYn3nv7wiPVlz7YYwJrWVcXK/BmnVDxM+D2scQbITxI"
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.