Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fad252a099 | ||
|
|
4a73d2f388 | ||
|
|
c56340ffed | ||
|
|
cc58529e99 | ||
|
|
2e8a80c045 | ||
|
|
13e5d34446 | ||
|
|
0f6a62335f |
@@ -12,3 +12,6 @@ build/
|
|||||||
.idea/
|
.idea/
|
||||||
*.swp
|
*.swp
|
||||||
*~
|
*~
|
||||||
|
tmp
|
||||||
|
Music
|
||||||
|
cookies.txt
|
||||||
|
|||||||
+302
-27
@@ -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
|
||||||
project_root_path = pathlib.Path(__file__).parent.resolve()
|
class ReleaseInfo:
|
||||||
tmp_path = f"{project_root_path}/tmp"
|
artist: str
|
||||||
|
title: str
|
||||||
|
art_url: str
|
||||||
|
is_album: bool
|
||||||
|
is_topic: bool
|
||||||
|
|
||||||
while True:
|
|
||||||
queue_is_empty = len(rip_queue) == 0
|
|
||||||
if queue_is_empty:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Creates tmp directory, if it already exists, it clears it, and makes a new one.
|
@dataclass
|
||||||
if not os.path.isdir(tmp_path):
|
class ReleaseStatus:
|
||||||
os.mkdir(tmp_path)
|
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:
|
else:
|
||||||
shutil.rmtree(tmp_path)
|
shutil.rmtree(path)
|
||||||
os.mkdir(tmp_path)
|
os.mkdir(path)
|
||||||
|
|
||||||
current_url = rip_queue[0]
|
|
||||||
|
|
||||||
|
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:
|
try:
|
||||||
with YoutubeDL(
|
with YoutubeDL(
|
||||||
{
|
{
|
||||||
"format": "m4a/bestaudio/best",
|
"format": "bestaudio/best",
|
||||||
|
"ignoreerrors": True,
|
||||||
|
"restrictfilenames": True,
|
||||||
"postprocessors": [
|
"postprocessors": [
|
||||||
{
|
{
|
||||||
"key": "FFmpegExtractAudio",
|
"key": "FFmpegExtractAudio",
|
||||||
"preferredcodec": "m4a",
|
"preferredcodec": "mp3",
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"outtmpl": f"{tmp_path}/%(title)s.%(ext)s",
|
"outtmpl": f"{path}/%(title)s.%(ext)s",
|
||||||
}
|
}
|
||||||
) as ydl:
|
) as ydl:
|
||||||
error_code = ydl.download([current_url])
|
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:
|
except DownloadError:
|
||||||
print("Download Error")
|
raise RipError("Content Download Failed")
|
||||||
|
|
||||||
rip_queue.pop(0)
|
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()
|
||||||
|
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:
|
||||||
|
if len(rip_queue) == 0:
|
||||||
time.sleep(1)
|
time.sleep(1)
|
||||||
|
continue
|
||||||
|
|
||||||
|
status_callback("Gathering info", False)
|
||||||
|
|
||||||
|
url = rip_queue.pop(0)
|
||||||
|
create_or_wipe_folder(tmp_path)
|
||||||
|
|
||||||
|
try:
|
||||||
|
info = find_release_info(url)
|
||||||
|
status_callback(info, False)
|
||||||
|
|
||||||
|
status_callback("Downloading art", False)
|
||||||
|
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")
|
||||||
|
|
||||||
|
status_callback("Downloading content (0)", False)
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
rip_queue = []
|
rip_queue: list[str] = []
|
||||||
ripping_thread = Thread(target=rip_worker, args=(rip_queue,), daemon=True).start()
|
finished_queue: list[ReleaseStatus] = []
|
||||||
|
|
||||||
|
current_release_info: ReleaseInfo | None = None
|
||||||
|
current_release_status_message: str = ""
|
||||||
|
|
||||||
|
def get_current_release_status(x: ReleaseInfo | str, finished: bool):
|
||||||
|
global current_release_info
|
||||||
|
global current_release_status_message
|
||||||
|
global finished_queue
|
||||||
|
|
||||||
|
if type(x) is ReleaseInfo:
|
||||||
|
current_release_info = x
|
||||||
|
|
||||||
|
elif type(x) is str:
|
||||||
|
current_release_status_message = x
|
||||||
|
|
||||||
|
if finished:
|
||||||
|
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("/")
|
@app.route("/")
|
||||||
def index():
|
def index():
|
||||||
return render_template("index.html")
|
return render_template("index.html")
|
||||||
|
|
||||||
|
|
||||||
@app.route("/rip", methods=["POST"])
|
@app.route("/rip", methods=["POST"])
|
||||||
def rip():
|
def rip():
|
||||||
url = request.form.get("url")
|
url = request.form.get("url")
|
||||||
|
if url is None:
|
||||||
|
return jsonify({"success": False, "message": "Url is None"})
|
||||||
|
|
||||||
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"])
|
||||||
|
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()
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
Flask==3.1.3
|
||||||
|
mutagen==1.47.0
|
||||||
|
yt_dlp==2026.8.19
|
||||||
@@ -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.
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.
Reference in New Issue
Block a user