From c8a316bead524de0f1dfbad060d797bbb06f99b2 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 27 Mar 2026 04:23:52 +0000 Subject: [PATCH 1/3] Add music playlist tool supporting Apple Music and Bandcamp Flask web app that lets you create playlists mixing Apple Music and Bandcamp track links. Auto-fetches metadata (title, artist, artwork) via the iTunes API and Bandcamp page scraping. Renders embedded players for both platforms inline via
toggles. https://claude.ai/code/session_01N3VQJAzvuX9U8mtzCxdQ6z --- .../__pycache__/app.cpython-311.pyc | Bin 0 -> 11755 bytes .../__pycache__/metadata.cpython-311.pyc | Bin 0 -> 9300 bytes .../__pycache__/models.cpython-311.pyc | Bin 0 -> 3820 bytes music_playlist/app.py | 174 +++++++++ music_playlist/instance/playlists.db | Bin 0 -> 16384 bytes music_playlist/metadata.py | 210 +++++++++++ music_playlist/models.py | 57 +++ music_playlist/requirements.txt | 5 + music_playlist/static/style.css | 347 ++++++++++++++++++ music_playlist/templates/base.html | 33 ++ music_playlist/templates/edit_playlist.html | 27 ++ music_playlist/templates/index.html | 26 ++ music_playlist/templates/new_playlist.html | 20 + music_playlist/templates/playlist.html | 159 ++++++++ 14 files changed, 1058 insertions(+) create mode 100644 music_playlist/__pycache__/app.cpython-311.pyc create mode 100644 music_playlist/__pycache__/metadata.cpython-311.pyc create mode 100644 music_playlist/__pycache__/models.cpython-311.pyc create mode 100644 music_playlist/app.py create mode 100644 music_playlist/instance/playlists.db create mode 100644 music_playlist/metadata.py create mode 100644 music_playlist/models.py create mode 100644 music_playlist/requirements.txt create mode 100644 music_playlist/static/style.css create mode 100644 music_playlist/templates/base.html create mode 100644 music_playlist/templates/edit_playlist.html create mode 100644 music_playlist/templates/index.html create mode 100644 music_playlist/templates/new_playlist.html create mode 100644 music_playlist/templates/playlist.html diff --git a/music_playlist/__pycache__/app.cpython-311.pyc b/music_playlist/__pycache__/app.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3bb1981ee01190a6203d6795f8be317f04308abd GIT binary patch literal 11755 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 G(EkR0I<%z# literal 0 HcmV?d00001 diff --git a/music_playlist/__pycache__/metadata.cpython-311.pyc b/music_playlist/__pycache__/metadata.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5c0e50a9a6d052d8abd4536e6a4647219636fa8d GIT binary patch literal 9300 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 literal 0 HcmV?d00001 diff --git a/music_playlist/__pycache__/models.cpython-311.pyc b/music_playlist/__pycache__/models.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..316a663f225d26dcea36e33e354b06a77d5e85fd GIT binary patch literal 3820 zcmcIn&2JmW6<>ZYzbQ&~OvxXulh)qami(nUN>U+~WhG1+!HJa=A_6Z+?$}ys$(3i9 zu&Fi%3%GzF1keYFQV9q|CzXzc#)lsI7Zg33z#fPJ3K;0gHyQFNr@pt#r6f{PszXNH z-@cuB@9oUI+4p<;TR0rxp#9+am;AuTasR~!yLpog%qnw&~Qod7BSg>kgba;ZhEXOF1P+%0=93_OfB&X!r@V0G$|Il82ZC*VaeA`R|`}>>@ zwr1ns2(;OS4zLT`b|f+#wdD>EG4Rl3kM6unK`8|54{t=);V3+99fwq0iazF2-BJt! z+{O4?O1h}Xn$G;QO-Y-0;$Xp9D(BL)D9(`L%ZK22OmH%9Aur!A5XR>VijvMK)e3cI_|pP*5=T*bqd=e>Zp2PH=N^w zhdE`5xizJ5kGb>d9AQC;Xjv-H;-pzn$5n}0wUE~ZFFgj%X#$}ap$~w$6q09NQ%{2p zace}mtKmF<@#FBol$s+01&z>w8)|M=Av(EmtEf+@`3qMrjHs%fP3L9@as^G!iuT40 zwV|oVKeBB1vWVg{DCbn0qKNdpx{{P*}VFI1+tXD zfF!1M7NSH+_GYV9N{_=Agyg~Zb3 z@;kMw^NG6uqT#<-8fgT>i$e=TOP^IHS0-1rAE9>rzG4$^pqo4NKwZG5pvnqu;_AGvY9cECok)G)z zw%p<2=m8$yu&t!dT{tF%fpZg51Xw0YN)Zj}HI&yc{1t=eEGLxOB)8DvgOv1S9gl>N2!tW4p4JIwpevlZSmL!uA>3JL&M!=QRO9v>$IIij&*z1D;EEBrQo4ZzH@PsmL@ULW;#z$9tMXU1!u(`CJZOXmOJj{tWHGsr zT*_4B6?x6IoG<5LI9U%38ll0`&E3}B%a6*BY80(68{x})TW8Svk`cND*7gFvOMaVN z%YHLn9e*~0GrwlUuffb?@rr-Nzk2#R;ag$t#y9=d{%5C`{PozN5gRNe8_}-C;zDsX zUhS=YI$x|u2aM=I>BH^rzS`7=iUY$&bQlKg(xLo^wG37Sf)Nx-qnqv!@^>d#5yH5P z3qGsbiQQGw+Ki~(SJKvhQPhp0#1q9lpJ1_P6*gu>cSre0{ynQM8>XY64ngL$zXEK! z91cf=JMnz?ufd&pzWevW6D_6w{N>{>OU_Miw*#i!3G?*php({z_4eTT N_zL@9?=fhu{eP=C3=jYS literal 0 HcmV?d00001 diff --git a/music_playlist/app.py b/music_playlist/app.py new file mode 100644 index 000000000..e41fad4aa --- /dev/null +++ b/music_playlist/app.py @@ -0,0 +1,174 @@ +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) + + +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"], + 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 0000000000000000000000000000000000000000..7088f5a264d673d8c1bf2af5dc9dab9e64dad3ca GIT binary patch literal 16384 zcmeI%O>fgM7zc2tofwN$wp+DRjkqKdLhB~(WSuu6x{P@#VqBuM3CLR76vyG^h$bQN z!T2tG1diC5lCf;;PW87GCw`tfv42huR?kkyLZ$S5F6XhLdt{4P7I{G_A*7>^HGR~j z-L~q)J?9R2_UT7&^Ec`C_Q|7Pn?Lml1p*L&00bZa0SG_<0uX?}e=2bKq`ThV-nPC! zR`E1Tm5gT>#Z|pF;>-&f4ZZy_qgOHN*c&3Deh{)F#%XX8(qJ+kKc)7@GK;@vqEOL4 z@}afl~zJ!*V(;x?^r7R;HJ09n-Rs?w4C3LO7%IH z7g2jWrbjxTrpcX|WnKs+@i$Whf2}v32bwALkC_QBa=jr9+1t=GUUUE0^00Izz00bZa0SG_<0uZPK@c%zv1PDL?0uX=z1Rwwb2tWV= K5P-ny3;Y3G< 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") + + # 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, + "source": "apple", + } + + +# --------------------------------------------------------------------------- +# Bandcamp +# --------------------------------------------------------------------------- + +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 + + 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 "" + 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 %} From 58b1ae26bd90383b8fdb471ab16f169c3dbb2f95 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 27 Mar 2026 04:23:56 +0000 Subject: [PATCH 2/3] Add .gitignore entries for bytecode and SQLite db https://claude.ai/code/session_01N3VQJAzvuX9U8mtzCxdQ6z --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..b76f70b85 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +*.pyc +__pycache__/ +instance/ From 3f6fafd0c414c20435b9c92e73df096c23e5d784 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 27 Mar 2026 04:26:05 +0000 Subject: [PATCH 3/3] Add playlist runtime calculation Fetches track duration from iTunes API (trackTimeMillis) and Bandcamp JSON-LD (ISO 8601 duration field). Stores duration_seconds on Track, displays it next to each track, and shows total runtime in a summary bar at the bottom of the playlist. https://claude.ai/code/session_01N3VQJAzvuX9U8mtzCxdQ6z --- .../__pycache__/app.cpython-311.pyc | Bin 11755 -> 12410 bytes .../__pycache__/metadata.cpython-311.pyc | Bin 9300 -> 10483 bytes .../__pycache__/models.cpython-311.pyc | Bin 3820 -> 3918 bytes music_playlist/app.py | 15 ++++++++++ music_playlist/metadata.py | 18 ++++++++++++ music_playlist/models.py | 1 + music_playlist/static/style.css | 26 ++++++++++++++++++ music_playlist/templates/playlist.html | 13 ++++++++- 8 files changed, 72 insertions(+), 1 deletion(-) diff --git a/music_playlist/__pycache__/app.cpython-311.pyc b/music_playlist/__pycache__/app.cpython-311.pyc index 3bb1981ee01190a6203d6795f8be317f04308abd..547c7adff2496196c49f225e56f642e57eb91554 100644 GIT binary patch delta 2699 zcmah~U2Gdg5Z=ABV<+)X94E08*NuPTB$xh$DiP2^3k|gVsDU;`l(t}w?4`NXvE4m8 zl;Yr2>O+buNLeH*ehUvpg$h&|i3bD{s)TqzsFf-aom7cR@PK$pRd}FJ%-q>=QzCJ? z{r2{|ncdl$o!LJR-hR>dsn^>?;JMWBgZ^Xqj4w03e zt>-BrpF^`B7CcGL!{_HUf*gp|ga=t0YiAvYrHi5S5{N*v9~S7Rk_t&?`e?2N_>u|lq(W@3gmp35==MzP1X?B2a)Y%G@J zF{?KUT+bffIBF8T#Ye~=c(!iSEv7uUnJJPf>)C?Y!1S5v98;X8t2&cOCrz1a(`x5$CsEadh5-wTbDj8C98g&eEY2a*mf& zL*wi4lV%ME^tdS=N5YdXX7!^*Ep23qhxulp`ovM!TM|!JoNkHI4Z__xux1!|0~4&O z<Z4>?wat|Zy5dPK}9T9`A;qq=El-9c};iD*q2aP|1@F>Dggk1<&N9*3> z9vnV~fX46!1Q!DCKylP}P}f;@VV50mug3I3Jpg+_@#)HqR)}=6@>}o-r32#W_E32b z&Whnxnpd@W*%HViEu$4Q+ak-}x>--b1jZfCH2t=4ef?)ZLPaS%bAI=SPQUhxzfY`B zM1|b32SWQyM|U$u56jLiw^Ib#-WI)0LGgO1N2FuE%Kp%OGIavcpl*egfzm70H!;iGjyvq?1!NeUe$?K*jGkeoLe)n10!rYCW_f{Q=V3j zaVTJfqnAucXJ&&ju4YuuA4e9?f#SX+@Rou5g(tc`5=V_jm9cbztK&!b5g2Eo8G9rV zODL66^c%XqGx|~Z-SCyf(ACc2Yn{W3f$-UT&O{cyfu)eg+i;Tr{Dm<0=-*wHvF>}N zGCB{kMT5@v2_}c)S#^h&<(A~~f#Lg55$&#@-wXE9HQkn>X`NYV%cnqS`TSPP6$@C$ zt-7T=NSqa2iTC03ew~nPIv^ggIf^%2rmJcG89Th zwvXOnWG4X(%!lPF-mK>Jbe?NiUHJ_B`J%GDzXy|ahR1BS21m**!)Kis#b+U$?aLB55Q+exa+G|3vnA>`I6_ zl+ZZ?ZMtTd4TDq*NO^mxbA#w~TQJHWbzReKz;vrh=w>5T;UVg#9_n3?ioOjAL|~LZ z+Wd@CYgNfi{bdV0D}{~GD($4fooDePY{#nGW*P=Vr0C;+No~fqIvuIdeflF?}&L(pT3*(fea|ZbHd#2+aTcVIwFBFtSE>&1y9Y6t(ZAEKh$@cTB z4i5?OTaH1}!M}G5ji40Uk07K1FcV(I;42%krIm|`Y%2L)O3mfjQ4r|ky-klvoX4GC z^@L!(Y_Dz)EauOrHVMOHWFxb~k3|q#Z@7Y;bqkoq%K7Xd?jsz5ksIar{k?qA<$+jM zT<=R!G{9;Bv0+>ae-Gengg1L*tEj!~R;Zez}m?MOxt0d2)M4aD}(kOp0QJ)cpOIVv53|0`vqcs;9DXS zBAKqKO0Hv6Nhx`S4zQDG534L&_l%|Hqh>1WuyMZb_t=G%YOeWhzt8w095Tay_g{hX zoe4y(sHunX=)QqZwlDD2=KfMBcv!)>g}JwgCvz|rrdlom1GRNNR&!8*IT;m%>4BV0N4PoT8fc1T*qJZwtr z4fvG<#wL_)d&b~EAt70-6g!RF&X(E~-2{0NjwMuDZmTmOxvQgW)FpbO62e)K_<(2Q zA44PAJEgL7EP)mMCSuGOr}knY_X%2;AUn z-CN+?M9=5MDmp{l;}LG`9iORRJu{kfA~Yeq4xs7b(UJA#0=|jtEIw)R3j`&kE^H9g8E2o%Y0QK5{-o z3n}F&!wZG4ckZ3ipM6GBY8d#C_&dqywhrK-UMCIwO8U1YWTyDm;J5BOL(UU6{au^k LgtOtUhXDQuXVbD? diff --git a/music_playlist/__pycache__/metadata.cpython-311.pyc b/music_playlist/__pycache__/metadata.cpython-311.pyc index 5c0e50a9a6d052d8abd4536e6a4647219636fa8d..cf31ba62f72e87c2acd198820f27ff7326ebc873 100644 GIT binary patch delta 2552 zcmZ`)Z%iD=6`$F=y}#`J+1uN_;|^F3r*O5gu??2D?qooTHyZ3o%cTP(Sjfowq@LT0rrGoE*>qD`SP}{77MHs-PD>2K%WZT)=K>2nP}&ZI0xOLUAUm=O>CY znr^xD;vAXCr@i!-!l2u76lRQch<+g)J9GlvAtxF10a)%FF|HQL70WektF}CKT^668 zzL?ka5XH#|{bnHU8-)#Qr~c{M_W}dBb5jss9{e_!8t^V6GCQzNKXk~UJ21Y1Or&!f z9z_PGn3GZ(XQEpiTn+SED`6AYL^Q{X$iy_{lxdeYusDEjfy7&NIdlO6RpLsHlGEfg z=K>FR=+JmZ7eK~zGA!{Xueq)`*(So7JlrRoopvuAJ)0wXUL77iqaHodbx^HGMx82L z$*X6_4v%#AjH(dYqG}Y4+?1+!*22I4nT58qV;z0RIxc8?(?jXL`f%jIa5UXVf9;nS zd*Rg62-{vir!dDz(768%f{pmgU)y@RjT=b0}NTi`lv_i~D=0iW9jh{p9}MOZl1nwOMlV1rXK`0(=a=GFq2<;eWe- z5uZ z(%5E$P;vfnsA8;KTz;Y2pssdT8`{g6O)0+8|H^P>c&TTpXSI9X*}j$uUB>(bnDA|S zH>~;YCzz!*ReT3KAwBqMHFD5#sMJ7zCC70CZIPOSf}y~c?Pdz}wA2(9v;e!lT4M=_ zekLolB8mP0lS6r41SEv)OKw!TQKh>HngNx9cjp<`_p;F@0N%^HG+s=m~jm zBC5r-cmeNvt6?)t2>lfNj?tTPnh$HSIHF-i4wXcst*#pE5N$hTu%pNS;K)66SUEEi zF(U)$xtk@)lqT)M!S>B!%~OgR?GJQJO7*eUXhwG#BAYT%OnEg*|0PDxBzC^5k{6?y z{9|+_AE_&$HJJ#m#v($z1j5X$?Y@c$5`6( zrBo?Hg+V3R=%k5wi(4^W5H*$d$0LGb`n6UqWtW;ZnvMU1C+8r1bYJ856YTk>6_No$ zQ~=98l<6PHJUdEG(GMD1@F4w9<5zk60a7{9l_KLz$ff6V1YS9BzjDH!L^?)TW+fx^ zQlh|ioUZzl$2J2Q}Z#<;-XSpYo-Rj83RCKcbyF#Q2zb`NvfC zKxX4WrkXglkvLWH{C6$V+|He36)L%_Lg~!RKXng;huqxzZh5Gce_zFbf8E#j^gi^r zee!AE@pmT%WHoH<$7%{ItX2|cDj5KPtZWD2Df(Wk$}a=w9{r^CQdqLw*{oKa$Y#kP zlO3nIlrqeMZaFUM-2|!^B6AGN48j1AALnML@+8d|B?k6UEce&aAq#r#%0L zn6rAKze+z${cgAuyH*ZwAt0;WHrWlAu@`qR5g@CFZ%c0+vxmDOMt|S-WXD+y>Kt2$ Q(N$-SJ_2WGa?jEK12>~$Hvj+t delta 1572 zcmZ`(&1)M+6rWj1tFPUaR?@DtlI4|bC$bWpxQ+|8lO`mwaBAw>ZVqlki;+F+IBsQ` z(Yk>$Y6!GV4>>p)NCJ*QP)I4g6m&~VdnhzCZ6Pf_lqH8k|A45Nf)6EhMv9c$#1H28 zKK9L<_ukH%?U}7}$``U60E~X|{CFwVbwf$OtsA-Z*F?*Yf@}#NdI3OENAgu}Z^WI=$7t?h3G`fp#Re#bW@%m_G0Aoe4b zzzu*RMA!hE?y9`PBrKw6Qx}*XU|wR&Ym@5&lB+WDlK=@4iO491w7ViZORuj7|ABNv zeg}$lM?UHoE9Eu3VC8-Ej503d6}qmB_MTvA;8!`EWZ-y<*uGrG?>Qc;G;g7b!i*G# za)CFPz&$G8*M~x5kbWL6sS%?gdnK^}THq1uM`BCRGtJz}bVEZ*r_NC(-KJA&yxynZ zg7mxiOKGJV^aEREzRzgc-uAmBinn=5pXAy=BqvJboMCp@6f!;Ut;p^MIzz%oL z1k1cDO|rY&5&(-rAHZ%1-UjRNx=FNf={V zGYlA~zbEsqFpATsNkdibY}kVa3YUJgD#yoY|2nwpG|$QXEcvl=DQ<>uYx zTUR0SRb%#n=V6wkZ5bIPwkHvdXgE%^T8wv6KuJ2-rH#CJbLxegQ>cpo*7#14X_UMN zS+Q)jsOpDc<=8$M?H4q?J1Ea=r^_5^ou%&+teb$n<*^|FRn0z3r_<&EortVGCh_tn z5nmJCP1I2aWt+p(LvN*jrAytPq>i)n^Ey7u7|t+oyypt1#|m$r$79rIny^6o%|mq7 z9Lzcbwj8ly74gE7-=@WBYb6^E8Fx7!2 zJjr;4&j6jt=u`=LX(Gos%fxH(YsJvutJKJZ(luoWpZfb3EK z=J1)=5F~mn`xx%juk_@E(MZ>xnXT{)ei%A?WIQW8$m-+AJr719!=J=c6C!vh>Jz5x zVKwoo)z!Ur(z&r(fhCyCvm3ErR6@ITX8O#U8Q5G$GVQH>p*+o>ei})%t{!HIG zcu|cw-nltcUYMK11!lx$`siT%6mPHNny(CF{x0Hq4kZr!fp9$b+Uolj&U3>uhh`SM zx2;sJ(!pFh@F~;zb*rq<+1&l!Lr}bPtN|FS4L8|dn1}R2|49ENWR9)|;JoH;@fY9{ J{igqw{{R3PPvZap diff --git a/music_playlist/__pycache__/models.cpython-311.pyc b/music_playlist/__pycache__/models.cpython-311.pyc index 316a663f225d26dcea36e33e354b06a77d5e85fd..33cdc466cbed627b779cfbec97f9710512e61457 100644 GIT binary patch delta 230 zcmaDOdrppbIWI340}ynw9n0Ljk=Krm=^69Hhe?~)vu$T$W?I8GIg?X+@&-1}$*G*e zjKUK?O7n_3RHz)IGGKz~!POqF)xj<-%)P|G^l^4aW ruZUY;U=f+z&AXG)b+Rj8o~0}!*9QhnqQj}j<0DA?3l_;DWuPkoj5$Gh delta 194 zcmX>n_ePd?IWI340}!ldJ(gL%k=KrmX&v*#he?~)vu$UZJdaaoas{W1x-duu2&9T+ zi9%Qm3~RWTF)=W#24V<^5}Wu@no)f6MGk3pi4@)zhA7F+hFl4ZjJlI2aR)O>O@79m xIXRKXo3VBCS{_Zt$?te~FuF`$&X;E?!^ri40h8!(>hbsp690llvPc dict: 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: @@ -100,6 +102,7 @@ def fetch_apple_music(url: str) -> dict: "album": album, "artwork_url": artwork, "embed_url": embed, + "duration_seconds": duration, "source": "apple", } @@ -108,6 +111,18 @@ def fetch_apple_music(url: str) -> dict: # 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/" @@ -129,6 +144,7 @@ def fetch_bandcamp(url: str) -> dict: # ---- 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: @@ -142,6 +158,7 @@ def fetch_bandcamp(url: str) -> dict: 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 @@ -192,6 +209,7 @@ def fetch_bandcamp(url: str) -> dict: "album": album or "", "artwork_url": og_image or "", "embed_url": embed, + "duration_seconds": duration, "source": "bandcamp", } diff --git a/music_playlist/models.py b/music_playlist/models.py index 2a72cad12..1261d656e 100644 --- a/music_playlist/models.py +++ b/music_playlist/models.py @@ -38,6 +38,7 @@ class Track(db.Model): album = db.Column(db.String(300), default="") artwork_url = db.Column(db.String(500), default="") embed_url = db.Column(db.String(500), default="") + duration_seconds = db.Column(db.Integer, nullable=True) position = db.Column(db.Integer, default=0) added_at = db.Column(db.DateTime, default=datetime.utcnow) note = db.Column(db.Text, default="") diff --git a/music_playlist/static/style.css b/music_playlist/static/style.css index 849c3393b..1ad1c6e3e 100644 --- a/music_playlist/static/style.css +++ b/music_playlist/static/style.css @@ -339,6 +339,32 @@ details[open] .embed-toggle summary::before, .embed-toggle[open] summary::before { content: '▼ '; } .embed-container { margin-top: 0.75rem; } +/* ===== Track duration ===== */ +.track-duration { + margin-left: 0.5rem; + color: var(--text-muted); + font-size: 0.78rem; + font-variant-numeric: tabular-nums; +} + +/* ===== Runtime bar ===== */ +.runtime-bar { + display: flex; + align-items: center; + gap: 1rem; + flex-wrap: wrap; + margin-top: 1.25rem; + padding: 0.75rem 1rem; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + font-size: 0.85rem; + color: var(--text-muted); +} +.runtime-bar__count { font-weight: 500; color: var(--text); } +.runtime-bar__total strong { color: var(--text); } +.runtime-bar__note { font-size: 0.78rem; opacity: 0.7; } + /* ===== Responsive ===== */ @media (max-width: 520px) { .site-header { padding: 0.75rem 1rem; } diff --git a/music_playlist/templates/playlist.html b/music_playlist/templates/playlist.html index 068898d03..21f4fbac4 100644 --- a/music_playlist/templates/playlist.html +++ b/music_playlist/templates/playlist.html @@ -65,7 +65,7 @@

add a track

{% if track.source == 'apple' %}Apple Music{% else %}Bandcamp{% endif %} - {% if track.artist %}

{{ track.artist }}{% if track.album %} — {{ track.album }}{% endif %}

{% endif %} + {% if track.artist %}

{{ track.artist }}{% if track.album %} — {{ track.album }}{% endif %}{% if track.duration_seconds %}{{ track.duration_seconds | duration }}{% endif %}

{% endif %} {% if track.note %}

"{{ track.note }}"

{% endif %} {% if track.embed_url %} @@ -104,6 +104,17 @@

add a track

{% endfor %} +{% set timed_tracks = playlist.tracks | selectattr("duration_seconds") | list %} +{% if timed_tracks | length > 0 %} +{% set total_seconds = timed_tracks | sum(attribute="duration_seconds") %} +
+ {{ playlist.tracks | length }} track{% if playlist.tracks | length != 1 %}s{% endif %} + total runtime: {{ total_seconds | duration }} + {% if timed_tracks | length < playlist.tracks | length %} + ({{ playlist.tracks | length - timed_tracks | length }} track{% if playlist.tracks | length - timed_tracks | length != 1 %}s{% endif %} without duration data) + {% endif %} +
+{% endif %} {% else %}

No tracks yet. Add your first one above!

{% endif %}