diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..b76f70b85 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +*.pyc +__pycache__/ +instance/ diff --git a/music_playlist/__pycache__/app.cpython-311.pyc b/music_playlist/__pycache__/app.cpython-311.pyc new file mode 100644 index 000000000..547c7adff Binary files /dev/null and b/music_playlist/__pycache__/app.cpython-311.pyc differ diff --git a/music_playlist/__pycache__/metadata.cpython-311.pyc b/music_playlist/__pycache__/metadata.cpython-311.pyc new file mode 100644 index 000000000..cf31ba62f Binary files /dev/null and b/music_playlist/__pycache__/metadata.cpython-311.pyc differ diff --git a/music_playlist/__pycache__/models.cpython-311.pyc b/music_playlist/__pycache__/models.cpython-311.pyc new file mode 100644 index 000000000..33cdc466c Binary files /dev/null and b/music_playlist/__pycache__/models.cpython-311.pyc differ diff --git a/music_playlist/app.py b/music_playlist/app.py new file mode 100644 index 000000000..bd45d907e --- /dev/null +++ b/music_playlist/app.py @@ -0,0 +1,189 @@ +import os +from flask import Flask, render_template, request, redirect, url_for, jsonify, abort, flash +from slugify import slugify + +from models import db, Playlist, Track +from metadata import fetch_metadata, detect_source + +app = Flask(__name__) +app.config["SQLALCHEMY_DATABASE_URI"] = os.environ.get( + "DATABASE_URL", "sqlite:///playlists.db" +) +app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False +app.config["SECRET_KEY"] = os.environ.get("SECRET_KEY", "dev-secret-change-me") +db.init_app(app) + + +def _format_duration(seconds): + """Convert total seconds to H:MM:SS or M:SS string.""" + if seconds is None: + return "" + seconds = int(seconds) + h, rem = divmod(seconds, 3600) + m, s = divmod(rem, 60) + if h: + return f"{h}:{m:02d}:{s:02d}" + return f"{m}:{s:02d}" + +app.jinja_env.filters["duration"] = _format_duration + + +with app.app_context(): + db.create_all() + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _unique_slug(name: str) -> str: + base = slugify(name) or "playlist" + slug, n = base, 1 + while Playlist.query.filter_by(slug=slug).first(): + slug = f"{base}-{n}" + n += 1 + return slug + + +# --------------------------------------------------------------------------- +# Routes: playlists +# --------------------------------------------------------------------------- + +@app.route("/") +def index(): + playlists = Playlist.query.order_by(Playlist.created_at.desc()).all() + return render_template("index.html", playlists=playlists) + + +@app.route("/new", methods=["GET", "POST"]) +def new_playlist(): + if request.method == "POST": + name = request.form.get("name", "").strip() + description = request.form.get("description", "").strip() + if not name: + flash("Playlist name is required.", "error") + return render_template("new_playlist.html") + slug = _unique_slug(name) + playlist = Playlist(slug=slug, name=name, description=description) + db.session.add(playlist) + db.session.commit() + return redirect(url_for("view_playlist", slug=slug)) + return render_template("new_playlist.html") + + +@app.route("/p/") +def view_playlist(slug): + playlist = Playlist.query.filter_by(slug=slug).first_or_404() + return render_template("playlist.html", playlist=playlist) + + +@app.route("/p//edit", methods=["GET", "POST"]) +def edit_playlist(slug): + playlist = Playlist.query.filter_by(slug=slug).first_or_404() + if request.method == "POST": + name = request.form.get("name", "").strip() + description = request.form.get("description", "").strip() + if not name: + flash("Playlist name is required.", "error") + else: + playlist.name = name + playlist.description = description + db.session.commit() + flash("Playlist updated.", "success") + return redirect(url_for("view_playlist", slug=slug)) + return render_template("edit_playlist.html", playlist=playlist) + + +@app.route("/p//delete", methods=["POST"]) +def delete_playlist(slug): + playlist = Playlist.query.filter_by(slug=slug).first_or_404() + db.session.delete(playlist) + db.session.commit() + flash("Playlist deleted.", "success") + return redirect(url_for("index")) + + +# --------------------------------------------------------------------------- +# Routes: tracks +# --------------------------------------------------------------------------- + +@app.route("/p//add", methods=["POST"]) +def add_track(slug): + playlist = Playlist.query.filter_by(slug=slug).first_or_404() + url = request.form.get("url", "").strip() + note = request.form.get("note", "").strip() + + if not url: + flash("Please enter a song URL.", "error") + return redirect(url_for("view_playlist", slug=slug)) + + if not detect_source(url): + flash("URL must be from music.apple.com or bandcamp.com.", "error") + return redirect(url_for("view_playlist", slug=slug)) + + try: + meta = fetch_metadata(url) + except Exception as exc: + flash(f"Could not fetch track info: {exc}", "error") + return redirect(url_for("view_playlist", slug=slug)) + + max_pos = db.session.query(db.func.max(Track.position)).filter_by( + playlist_id=playlist.id + ).scalar() or 0 + + track = Track( + playlist_id=playlist.id, + url=url, + source=meta["source"], + title=meta["title"], + artist=meta["artist"], + album=meta["album"], + artwork_url=meta["artwork_url"], + embed_url=meta["embed_url"], + duration_seconds=meta.get("duration_seconds"), + position=max_pos + 1, + note=note, + ) + db.session.add(track) + db.session.commit() + return redirect(url_for("view_playlist", slug=slug)) + + +@app.route("/p//track//delete", methods=["POST"]) +def delete_track(slug, track_id): + playlist = Playlist.query.filter_by(slug=slug).first_or_404() + track = Track.query.filter_by(id=track_id, playlist_id=playlist.id).first_or_404() + db.session.delete(track) + db.session.commit() + return redirect(url_for("view_playlist", slug=slug)) + + +@app.route("/p//track//note", methods=["POST"]) +def update_note(slug, track_id): + playlist = Playlist.query.filter_by(slug=slug).first_or_404() + track = Track.query.filter_by(id=track_id, playlist_id=playlist.id).first_or_404() + track.note = request.form.get("note", "").strip() + db.session.commit() + return redirect(url_for("view_playlist", slug=slug)) + + +# --------------------------------------------------------------------------- +# API: metadata preview (used by JS before submission) +# --------------------------------------------------------------------------- + +@app.route("/api/preview") +def api_preview(): + url = request.args.get("url", "").strip() + if not url: + return jsonify({"error": "No URL provided"}), 400 + if not detect_source(url): + return jsonify({"error": "Unsupported URL"}), 400 + try: + meta = fetch_metadata(url) + return jsonify(meta) + except Exception as exc: + return jsonify({"error": str(exc)}), 500 + + +if __name__ == "__main__": + app.run(debug=True, port=5000) diff --git a/music_playlist/instance/playlists.db b/music_playlist/instance/playlists.db new file mode 100644 index 000000000..7088f5a26 Binary files /dev/null and b/music_playlist/instance/playlists.db differ diff --git a/music_playlist/metadata.py b/music_playlist/metadata.py new file mode 100644 index 000000000..08eed1c0b --- /dev/null +++ b/music_playlist/metadata.py @@ -0,0 +1,228 @@ +""" +Fetch track metadata from Apple Music and Bandcamp URLs. +""" + +import json +import re +import urllib.parse + +import requests +from bs4 import BeautifulSoup + +HEADERS = { + "User-Agent": ( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/120.0.0.0 Safari/537.36" + ) +} + + +def detect_source(url: str) -> str | None: + """Return 'apple', 'bandcamp', or None.""" + parsed = urllib.parse.urlparse(url) + host = parsed.netloc.lower() + if "music.apple.com" in host: + return "apple" + if "bandcamp.com" in host: + return "bandcamp" + return None + + +# --------------------------------------------------------------------------- +# Apple Music +# --------------------------------------------------------------------------- + +def _apple_music_ids(url: str) -> tuple[str | None, str | None]: + """Extract (album_id, track_id) from an Apple Music URL.""" + # https://music.apple.com/us/album/song-name/ALBUM_ID?i=TRACK_ID + # https://music.apple.com/us/song/name/TRACK_ID + parsed = urllib.parse.urlparse(url) + qs = urllib.parse.parse_qs(parsed.query) + track_id = qs.get("i", [None])[0] + + path_parts = parsed.path.rstrip("/").split("/") + numeric = [p for p in path_parts if p.isdigit()] + + if track_id: + album_id = numeric[0] if numeric else None + return album_id, track_id + + # /song/ path + if "song" in path_parts and numeric: + return None, numeric[-1] + + # album-only link – use last numeric segment as album_id + if numeric: + return numeric[-1], None + + return None, None + + +def fetch_apple_music(url: str) -> dict: + album_id, track_id = _apple_music_ids(url) + lookup_id = track_id or album_id + if not lookup_id: + raise ValueError(f"Could not extract track/album ID from URL: {url}") + + api_url = f"https://itunes.apple.com/lookup?id={lookup_id}" + resp = requests.get(api_url, timeout=10) + resp.raise_for_status() + data = resp.json() + + results = data.get("results", []) + if not results: + raise ValueError("iTunes API returned no results") + + # Prefer a track (wrapperType == 'track') over a collection + track = next( + (r for r in results if r.get("wrapperType") == "track"), results[0] + ) + + title = track.get("trackName") or track.get("collectionName", "Unknown") + artist = track.get("artistName", "") + album = track.get("collectionName", "") + artwork = track.get("artworkUrl100", "").replace("100x100", "600x600") + millis = track.get("trackTimeMillis") + duration = int(millis / 1000) if millis else None + + # Build embed URL + if track_id and album_id: + embed = f"https://embed.music.apple.com/us/album/{album_id}?i={track_id}" + elif track_id: + embed = f"https://embed.music.apple.com/us/song/{track_id}" + elif album_id: + embed = f"https://embed.music.apple.com/us/album/{album_id}" + else: + embed = "" + + return { + "title": title, + "artist": artist, + "album": album, + "artwork_url": artwork, + "embed_url": embed, + "duration_seconds": duration, + "source": "apple", + } + + +# --------------------------------------------------------------------------- +# Bandcamp +# --------------------------------------------------------------------------- + +def _parse_iso_duration(s: str) -> int | None: + """Parse ISO 8601 duration like PT3M45S into total seconds.""" + if not s: + return None + m = re.match(r'PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?', s) + if not m: + return None + h, mins, secs = (int(x) if x else 0 for x in m.groups()) + total = h * 3600 + mins * 60 + secs + return total if total > 0 else None + + +def _bandcamp_embed_url(url: str, track_id: str | None, album_id: str | None) -> str: + if track_id: + return f"https://bandcamp.com/EmbeddedPlayer/track={track_id}/size=large/bgcol=ffffff/linkcol=0687f5/tracklist=false/artwork=small/" + if album_id: + return f"https://bandcamp.com/EmbeddedPlayer/album={album_id}/size=large/bgcol=ffffff/linkcol=0687f5/artwork=small/" + return "" + + +def fetch_bandcamp(url: str) -> dict: + resp = requests.get(url, headers=HEADERS, timeout=15) + resp.raise_for_status() + soup = BeautifulSoup(resp.text, "html.parser") + + # ---- Open Graph fallback values ---- + og_title = (soup.find("meta", property="og:title") or {}).get("content", "") + og_image = (soup.find("meta", property="og:image") or {}).get("content", "") + og_site = (soup.find("meta", property="og:site_name") or {}).get("content", "") + + # ---- Try JSON-LD (most reliable) ---- + title, artist, album = og_title, og_site, "" + track_id = album_id = None + duration = None + + for script in soup.find_all("script", type="application/ld+json"): + try: + ld = json.loads(script.string or "") + if isinstance(ld, list): + ld = ld[0] + schema_type = ld.get("@type", "") + if schema_type in ("MusicRecording", "MusicAlbum"): + title = ld.get("name", title) + by_artist = ld.get("byArtist", {}) + artist = by_artist.get("name", artist) if isinstance(by_artist, dict) else artist + in_album = ld.get("inAlbum", {}) + album = in_album.get("name", "") if isinstance(in_album, dict) else "" + duration = _parse_iso_duration(ld.get("duration", "")) + except (json.JSONDecodeError, AttributeError): + pass + + # ---- Extract numeric IDs from page data-tralbum or inline JS ---- + # Bandcamp embeds IDs in a data attribute on the player div + player_div = soup.find("div", {"id": "trackInfo"}) or soup.find( + "div", {"data-tralbum": True} + ) + if player_div and player_div.get("data-tralbum"): + try: + tralbum = json.loads(player_div["data-tralbum"]) + track_id = str(tralbum.get("id", "")) or None + except (json.JSONDecodeError, KeyError): + pass + + # Fallback: scan inline +{% endblock %}