diff --git a/CHANGELOG.md b/CHANGELOG.md index c68faf9d..5896393c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ ## Features -- Webserver settings are now saved and the webserver may be automatically started on app launch. +- Webserver settings are now saved and the webserver may be automatically started on app launch. It also got an improved API that makes it easy for external apps to interact with the Syncer (like [this mod for Melody Mania](https://steamcommunity.com/sharedfiles/filedetails/?id=3181930801)). diff --git a/src/usdb_syncer/webserver/templates/index.html b/src/usdb_syncer/webserver/templates/index.html index 46242fe1..85c3a3df 100644 --- a/src/usdb_syncer/webserver/templates/index.html +++ b/src/usdb_syncer/webserver/templates/index.html @@ -89,7 +89,7 @@

} } - const audio = new Audio("/api/mp3?song_id=" + songId); + const audio = new Audio("/api/songs/" + songId + "/audio"); audio.addEventListener("loadedmetadata", function () { if (previewOffset > 0) { audio.currentTime = previewOffset; @@ -111,27 +111,6 @@

currentAudio = null; }; - event.preventDefault(); - } else if (event.target.classList.contains("download-button")) { - const button = event.target; - const songId = button.getAttribute("song_id"); - - fetch("/api/download?song_id=" + songId) - .then((response) => { - if (response.ok) { - button.setAttribute("title", "Downloading"); - button.classList.add("animate-pulse"); - button.disabled = true; - } else { - response.text().then((text) => { - alert("Error: " + text); - }); - } - }) - .catch((error) => { - alert("Error: " + error); - }); - event.preventDefault(); } }); diff --git a/src/usdb_syncer/webserver/templates/like_button.html b/src/usdb_syncer/webserver/templates/like_button.html new file mode 100644 index 00000000..5f34a547 --- /dev/null +++ b/src/usdb_syncer/webserver/templates/like_button.html @@ -0,0 +1,9 @@ +{% if song_id in liked_songs %} + +{% else %} + +{% endif %} diff --git a/src/usdb_syncer/webserver/templates/play_or_download_button.html b/src/usdb_syncer/webserver/templates/play_or_download_button.html new file mode 100644 index 00000000..05591578 --- /dev/null +++ b/src/usdb_syncer/webserver/templates/play_or_download_button.html @@ -0,0 +1,31 @@ +{% if song.sync_meta %} + +{% elif allow_downloading %} + {% if song.status|string in ['Pending', 'Downloading'] %} + + ⤓ + + {% else %} + + {% endif %} +{% endif %} diff --git a/src/usdb_syncer/webserver/templates/songs_rows.html b/src/usdb_syncer/webserver/templates/songs_rows.html index 26bd5fd3..6310db8a 100644 --- a/src/usdb_syncer/webserver/templates/songs_rows.html +++ b/src/usdb_syncer/webserver/templates/songs_rows.html @@ -1,32 +1,14 @@ {% for song in songs %} - {% if song.sync_meta %} - - {% elif allow_downloading %} - - {% endif %} + {% include 'play_or_download_button.html' %} {{ song.artist }} {{ song.title }} {{ song.year if song.year }} - + {% with %} {% set song_id = song.song_id %} {% include 'like_button.html' %} + {% endwith %} {% endfor %} {% if songs|length >= 100 %} @@ -34,7 +16,7 @@
flask.Response: - song_id = flask.request.args.get("song_id", type=int) - if not song_id: - return flask.abort(400, "song_id parameter is required") +def _api_audio(song_id: int) -> flask.Response: song = UsdbSong.get(SongId(song_id)) if not song: return flask.abort(404, "Song not found") @@ -137,16 +134,13 @@ def _api_mp3() -> flask.Response: return flask.send_file(audio_path, mimetype="audio/mp3") -def _api_download() -> flask.Response: - song_id = flask.request.args.get("song_id", type=int) - if not song_id: - return flask.abort(400, "song_id parameter is required") +def _download(song_id: int, start: bool) -> UsdbSong: song = UsdbSong.get(SongId(song_id)) if not song: return flask.abort(404, "Song not found") - if song.status.can_be_downloaded(): + if start and song.status.can_be_downloaded(): DownloadManager.download([song], utils.ProgressProxy("")) - return flask.make_response("", 200) + return song def _get_liked_songs(session_id: str) -> set[SongId]: @@ -156,7 +150,34 @@ def _get_liked_songs(session_id: str) -> set[SongId]: return set(map(SongId.parse, cookie[len(session_id) + 1 :].split(","))) -def _create_app( +def _fragments_like( + session_id: str, like_counter: Counter[SongId], selected_id: int, like: bool +) -> flask.Response: + song_id = SongId(selected_id) + liked_songs = _get_liked_songs(session_id) + if like and song_id not in liked_songs: + liked_songs.add(song_id) + like_counter[song_id] += 1 + elif not like and song_id in liked_songs: + liked_songs.remove(song_id) + like_counter[song_id] -= 1 + resp = flask.make_response( + flask.render_template( + "like_button.html", + song_id=song_id, + like_counter=like_counter, + liked_songs=liked_songs, + ) + ) + resp.set_cookie( + "liked_songs", + f"{session_id}:{','.join(map(str, liked_songs))}", + max_age=_COOKIE_MAX_AGE, + ) + return resp + + +def _create_app( # noqa: C901 title: str, *, show_nonlocal_songs: bool, allow_downloading: bool ) -> flask.Flask: app = flask.Flask(__name__) @@ -169,41 +190,61 @@ def index() -> str: title, show_nonlocal_songs, allow_downloading, like_counter, session_id ) - @app.route("/api/songs") - def api_songs() -> str: - return _api_songs( + @app.route("/fragments/songs") + def fragments_songs() -> str: + return _fragments_songs( show_nonlocal_songs, allow_downloading, like_counter, session_id ) - @app.route("/api/mp3") - def api_mp3() -> flask.Response: - return _api_mp3() + @app.route("/api/songs") + def api_songs() -> list[UsdbSong]: + return _get_songs(flask.request, show_nonlocal_songs, like_counter) + + @app.route("/api/songs//audio") + def api_audio(selected_id: int) -> flask.Response: + return _api_audio(selected_id) + + @app.route("/api/songs//download") + def api_get_download(selected_id: int) -> dict[str, str]: + if not allow_downloading: + return flask.abort(403, "Downloading is not allowed") + return {"status": str(_download(selected_id, start=False).status)} - @app.route("/api/download") - def api_download() -> flask.Response: + @app.post("/api/songs//download") + def api_post_download(selected_id: int) -> dict[str, str]: if not allow_downloading: return flask.abort(403, "Downloading is not allowed") - return _api_download() - - @app.post("/api/like/") - def like(selected_id: int) -> flask.Response: - song_id = SongId(selected_id) - liked_songs = _get_liked_songs(session_id) - if song_id in liked_songs: - liked_songs.remove(song_id) - like_counter[song_id] -= 1 - icon = "🤍" - else: - liked_songs.add(song_id) - like_counter[song_id] += 1 - icon = "❤" - resp = flask.make_response(f"{icon} {like_counter[song_id]}") - resp.set_cookie( - "liked_songs", - f"{session_id}:{','.join(map(str, liked_songs))}", - max_age=_COOKIE_MAX_AGE, + return {"status": str(_download(selected_id, start=True).status)} + + @app.route("/fragments/songs//download") + def fragments_get_download(selected_id: int) -> str: + if not allow_downloading: + return flask.abort(403, "Downloading is not allowed") + song = _download(selected_id, start=False) + return flask.render_template( + "play_or_download_button.html", + allow_downloading=allow_downloading, + song=song, + ) + + @app.post("/fragments/songs//download") + def fragments_post_download(selected_id: int) -> str: + if not allow_downloading: + return flask.abort(403, "Downloading is not allowed") + song = _download(selected_id, start=True) + return flask.render_template( + "play_or_download_button.html", + allow_downloading=allow_downloading, + song=song, ) - return resp + + @app.put("/fragments/songs//like") + def fragments_like(selected_id: int) -> flask.Response: + return _fragments_like(session_id, like_counter, selected_id, like=True) + + @app.delete("/fragments/songs//like") + def fragments_unlike(selected_id: int) -> flask.Response: + return _fragments_like(session_id, like_counter, selected_id, like=False) @app.before_request def before_request() -> None: