]> littlesong.place Git - littlesongplace.git/commitdiff
Add YouTube importer
authorChris Fulljames <christianfulljames@gmail.com>
Sat, 8 Feb 2025 14:02:33 +0000 (09:02 -0500)
committerChris Fulljames <christianfulljames@gmail.com>
Sat, 8 Feb 2025 14:02:33 +0000 (09:02 -0500)
main.py
requirements.txt
static/styles.css
templates/edit-song.html
templates/profile.html
test/test_offline.py

diff --git a/main.py b/main.py
index 856e062f04ef13792c6cef885072a46bb4c9e8c0..415b194e8f13e5f7c3214f4f37c63d3eceb976a2 100644 (file)
--- a/main.py
+++ b/main.py
@@ -23,6 +23,8 @@ from flask import Flask, render_template, request, redirect, g, session, abort,
 from PIL import Image, UnidentifiedImageError
 from werkzeug.utils import secure_filename
 from werkzeug.middleware.proxy_fix import ProxyFix
+from yt_dlp import YoutubeDL
+from yt_dlp.utils import DownloadError
 
 DB_VERSION = 2
 DATA_DIR = Path(os.environ["DATA_DIR"]) if "DATA_DIR" in os.environ else Path(".")
@@ -271,7 +273,6 @@ def upload_song():
         return redirect(request.referrer)
 
 def validate_song_form():
-    file = request.files["song"]
     title = request.form["title"]
     description = request.form["description"]
 
@@ -327,7 +328,8 @@ def update_song():
     except ValueError:
         abort(400)
 
-    file = request.files["song"]
+    file = request.files["song"] if "song" in request.files else None
+    yt_url = request.form["song-url"] if "song-url" in request.form else None
     title = request.form["title"]
     description = request.form["description"]
     tags = [t.strip() for t in request.form["tags"].split(",")]
@@ -341,9 +343,9 @@ def update_song():
         abort(401)
 
     error = False
-    if file:
+    if file or yt_url:
         with tempfile.NamedTemporaryFile(delete=False) as tmp_file:
-            passed = convert_song(tmp_file, file)
+            passed = convert_song(tmp_file, file, yt_url)
 
             if passed:
                 # Move file to permanent location
@@ -374,14 +376,15 @@ def update_song():
     return error
 
 def create_song():
-    file = request.files["song"]
+    file = request.files["song"] if "song" in request.files else None
+    yt_url = request.form["song-url"] if "song-url" in request.form else None
     title = request.form["title"]
     description = request.form["description"]
     tags = [t.strip() for t in request.form["tags"].split(",")]
     collaborators = [c.strip() for c in request.form["collabs"].split(",")]
 
     with tempfile.NamedTemporaryFile(delete=False) as tmp_file:
-        passed = convert_song(tmp_file, file)
+        passed = convert_song(tmp_file, file, yt_url)
 
         if not passed:
             return True
@@ -410,9 +413,20 @@ def create_song():
             flash_and_log(f"Successfully uploaded '{title}'", "success")
             return False
 
-def convert_song(tmp_file, request_file):
-    request_file.save(tmp_file)
-    tmp_file.close()
+def convert_song(tmp_file, request_file, yt_url):
+    if request_file:
+        # Get uploaded file
+        request_file.save(tmp_file)
+        tmp_file.close()
+    else:
+        # Import from YouTube
+        tmp_file.close()
+        os.unlink(tmp_file.name)  # Delete file so yt-dlp doesn't complain
+        try:
+            yt_import(tmp_file, yt_url)
+        except DownloadError:
+            flash_and_log(f"Failed to import from YouTube URL: {yt_url}")
+            return False
 
     result = subprocess.run(["mpck", tmp_file.name], stdout=subprocess.PIPE)
     res_stdout = result.stdout.decode()
@@ -439,6 +453,14 @@ def convert_song(tmp_file, request_file):
     flash_and_log("Invalid audio file", "error")
     return False
 
+def yt_import(tmp_file, yt_url):
+    ydl_opts = {
+        'format': 'm4a/bestaudio/best',
+        'outtmpl': tmp_file.name,
+    }
+    with YoutubeDL(ydl_opts) as ydl:
+        ydl.download([yt_url])
+
 @app.get("/delete-song/<int:songid>")
 def delete_song(songid):
 
index 3d48ce7ddd60b64076825bc8c0a68d0cbb93344e..4a8e1a9f95fdcdc8f24c399a38f12e8850fad9f7 100644 (file)
@@ -3,4 +3,5 @@ bleach[css]
 flask
 gunicorn
 pillow
+yt-dlp
 
index 28d5ed1c01237e4a9d62dd2d1fefeffd6c8596b4..130bc63a5baf9a3fb61b1b7b9b284b3e80d036dd 100644 (file)
@@ -42,7 +42,7 @@ textarea {
     margin: 10px;
 }
 
-input[type=text], input[type=password] {
+input[type=text], input[type=password], input[type=url] {
     background: var(--yellow);
     margin: 10px;
     font-family: sans-serif;
@@ -151,7 +151,11 @@ div.navbar {
 
 /* Upload/Edit Form */
 
-div.upload-form input[type=text] {
+div.upload-form {
+    margin-bottom: 10px;
+}
+
+div.upload-form input[type=text], div.upload-form input[type=url] {
     width: calc(100% - 20px);
     box-sizing: border-box;
 }
index 7b47df025b74291aa9d71281489f473292f1e96c..3299a72d547de63cd7b18249aefc2cea600981e0 100644 (file)
@@ -11,15 +11,27 @@ Most standard audio/video formats are supported - .wav, .mp3, .ogg, .mp4, etc.
 </p>
 
 {% if song %}
-<form action="/upload-song?songid={{ song.songid }}" method="post" enctype="multipart/form-data">
+<form action="/upload-song?songid={{ song.songid }}" method="post" enctype="multipart/form-data" onsubmit="onUpload()">
     <h2>Edit Song</h2>
 {% else %}
-<form action="/upload-song" method="post" enctype="multipart/form-data">
+<form action="/upload-song" method="post" enctype="multipart/form-data" onsubmit="onUpload()">
     <h2>Upload a New Song</h2>
 {% endif %}
     <div class="upload-form">
-        <label for="song">{% if song %}Replace {% endif %}Audio File</label><br>
-        <input type="file" name="song" id="file-select" {% if not song %}required{% endif %}>
+        <input type="radio" id="file" name="upload-type" value="file" onchange="selectUploadMethod()" checked />
+        <label for="file">Upload a song from my device</label><br/>
+    </div>
+    <div class="upload-form">
+        <input type="radio" id="yt" name="upload-type" value="yt" onchange="selectUploadMethod()" />
+        <label for="yt">Import a song from YouTube</label>
+    </div>
+    <div class="upload-form" id="audio-file">
+        <label for="song-file">{% if song %}Replace {% endif %}Audio File</label><br>
+        <input type="file" name="song-file" id="song-file" {% if not song %}required{% endif %}>
+    </div>
+    <div class="upload-form" id="yt-url" hidden>
+        <label for="song-url">YouTube URL</label><br>
+        <input type="url" name="song-url" id="song-url">
     </div>
     <div class="upload-form">
         <label for="title">Title</label><br>
@@ -39,16 +51,16 @@ Most standard audio/video formats are supported - .wav, .mp3, .ogg, .mp4, etc.
     </div>
     <div class="upload-form">
         {% if song %}
-        <input type="submit" value="Update" onclick="onUpload(event)">
+        <input type="submit" value="Update" />
         {% else %}
-        <input type="submit" value="Upload" onclick="onUpload(event)">
+        <input type="submit" value="Upload" />
         {% endif %}
         <p id="uploading" hidden>uploading...</p>
     </div>
 </form>
 
-<!-- Automatically set song name from file name -->
 <script>
+// Automatically set song name from file name
 document.getElementById("file-select").addEventListener("change", function(e) {
     var songTitle = document.getElementById("song-title");
     if (e.target.files[0] && !songTitle.value) {
@@ -57,10 +69,34 @@ document.getElementById("file-select").addEventListener("change", function(e) {
     }
 });
 
+// Show uploading text on submit
 function onUpload(event) {
     var uploading = document.getElementById("uploading")
     uploading.hidden = false;
 }
+
+// Toggle YouTube import/File upload
+function selectUploadMethod() {
+    if (document.getElementById("file").checked) {
+        // Show audio file upload button
+        document.getElementById("yt-url").hidden = true;
+        document.getElementById("song-url").required = false;
+
+        document.getElementById("audio-file").hidden = false;
+        document.getElementById("song-file").required = {% if song %}false{% else %}true{% endif %};
+    }
+    else {
+        // Show youtube import URL box
+        document.getElementById("yt-url").hidden = false;
+        document.getElementById("song-url").required = {% if song %}false{% else %}true{% endif %};
+
+        document.getElementById("audio-file").hidden = true;
+        document.getElementById("song-file").required = false;
+    }
+}
+
+document.addEventListener("DOMContentLoaded", selectUploadMethod());
+
 </script>
 
 {% endblock %}
index b27278e9266e61a5bcd7b06824d6d100731ae1be..eb412718a1d604e011bde01ec6508be1a05d4e17 100644 (file)
 
 <h2>Songs</h2>
 
-<!-- Upload New Song button -->
+<!-- Add Song button -->
 {% if session["userid"] == userid %}
 <div class="profile-action">
-    <a class="button" href="/edit-song">Upload New Song</a>
+    <a class="button" href="/edit-song">Add Song</a>
 </div>
 {% endif %}
 
index 2ff9a38cadfca3bad4bf1e936d134c0be71d014c..9d25fdfd0ebb0d331dced118a04671a2ae9d324a 100644 (file)
@@ -303,6 +303,22 @@ def test_upload_song_from_mp4(client):
     _create_user(client, "user", "password", login=True)
     _test_upload_song(client, b"Successfully uploaded &#39;song title&#39;", filename="sample-4s.mp4")
 
+def test_upload_song_from_youtube(client):
+    _create_user(client, "user", "password", login=True)
+    data = {
+        "song-url": "https://youtu.be/5e5Z6gZWiEs",
+        "title": "song title",
+        "description": "song description",
+        "tags": "tag",
+        "collabs": "collab",
+    }
+    response = client.post("/upload-song", data=data)
+    assert response.status_code == 302
+
+    response = client.get(f"/users/user")
+    assert response.status_code == 200
+    assert b"Successfully uploaded &#39;song title&#39;" in response.data
+
 ################################################################################
 # Edit Song
 ################################################################################
@@ -337,6 +353,22 @@ def test_update_song_success(client):
     with open("sample-6s.mp3", "rb") as expected_file:
         assert response.data == expected_file.read()
 
+def test_update_song_from_youtube(client):
+    _create_user_and_song(client)
+    data = {
+        "song-url": "https://youtu.be/5e5Z6gZWiEs",
+        "title": "song title",
+        "description": "song description",
+        "tags": "tag",
+        "collabs": "collab",
+    }
+    response = client.post("/upload-song?songid=1", data=data)
+    assert response.status_code == 302
+
+    response = client.get(f"/users/user")
+    assert response.status_code == 200
+    assert b"Successfully updated &#39;song title&#39;" in response.data
+
 def test_update_song_bad_title(client):
     _create_user_and_song(client)
     _test_upload_song(client, b"not a valid song title", error=True, songid=1, title="\r\n")