Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)).

<!-- 0.24.0 -->

Expand Down
23 changes: 1 addition & 22 deletions src/usdb_syncer/webserver/templates/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ <h1 class="text-2xl md:text-4xl font-bold mb-2 md:mb-6 text-blue-200">
}
}

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;
Expand All @@ -111,27 +111,6 @@ <h1 class="text-2xl md:text-4xl font-bold mb-2 md:mb-6 text-blue-200">
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();
}
});
Expand Down
9 changes: 9 additions & 0 deletions src/usdb_syncer/webserver/templates/like_button.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{% if song_id in liked_songs %}
<button hx-swap="outerHTML" hx-delete="/fragments/songs/{{ song_id }}/like">
❤ {{ like_counter[song_id] }}
</button>
{% else %}
<button hx-swap="outerHTML" hx-put="/fragments/songs/{{ song_id }}/like">
🤍 {{ like_counter[song_id] }}
</button>
{% endif %}
31 changes: 31 additions & 0 deletions src/usdb_syncer/webserver/templates/play_or_download_button.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
{% if song.sync_meta %}
<button
class="play-button text-lg md:text-2xl"
song_id="{{ song.song_id }}"
preview_offset="{{ song.sync_meta.meta_tags.preview if song.sync_meta and song.sync_meta.meta_tags and song.sync_meta.meta_tags.preview else 0 }}"
title="Play"
>
</button>
{% elif allow_downloading %}
{% if song.status|string in ['Pending', 'Downloading'] %}
<span
class="text-lg md:text-2xl animate-pulse"
title="Downloading"
hx-swap="outerHTML"
hx-get="/fragments/songs/{{ song.song_id }}/download"
hx-trigger="every 3s"
>
</span>
{% else %}
<button
class="text-lg md:text-2xl"
title="Request download"
hx-swap="outerHTML"
hx-post="/fragments/songs/{{ song.song_id }}/download"
>
</button>
{% endif %}
{% endif %}
26 changes: 4 additions & 22 deletions src/usdb_syncer/webserver/templates/songs_rows.html
Original file line number Diff line number Diff line change
@@ -1,40 +1,22 @@
{% for song in songs %}
<tr class="hover:bg-blue-800 odd:bg-slate-700 even:bg-slate-800 h-10">
<td class="px-1 md:px-4 py-1 text-center">
{% if song.sync_meta %}
<button
class="play-button text-lg md:text-2xl"
song_id="{{ song.song_id }}"
preview_offset="{{ song.sync_meta.meta_tags.preview if song.sync_meta and song.sync_meta.meta_tags and song.sync_meta.meta_tags.preview else 0 }}"
title="Play"
>
</button>
{% elif allow_downloading %}
<button
class="download-button text-lg md:text-2xl"
song_id="{{ song.song_id }}"
title="Request download"
>
</button>
{% endif %}
{% include 'play_or_download_button.html' %}
</td>
<td class="px-1 md:px-4">{{ song.artist }}</td>
<td class="px-1 md:px-4">{{ song.title }}</td>
<td class="px-1 md:px-4">{{ song.year if song.year }}</td>
<td class="px-1 md:px-4">
<button hx-post="/api/like/{{ song.song_id }}">
{% if song.song_id in liked_songs %} ❤ {% else %} 🤍 {% endif %} {{ like_counter[song.song_id] }}
</button>
{% with %} {% set song_id = song.song_id %} {% include 'like_button.html' %}
{% endwith %}
</td>
</tr>
{% endfor %} {% if songs|length >= 100 %}
<!-- Infinite scroll trigger -->
<tr>
<td colspan="4" class="px-1 md:px-4 py-1 text-center">
<div
hx-get="/api/songs?offset={{ offset + songs|length }}&limit=100&search={{ search }}&sort_by={{ sort_by }}&sort_order={{ sort_order }}"
hx-get="/fragments/songs?offset={{ offset + songs|length }}&limit=100&search={{ search }}&sort_by={{ sort_by }}&sort_order={{ sort_order }}"
hx-trigger="intersect once"
hx-target="#songs-tbody"
hx-swap="beforeend"
Expand Down
121 changes: 81 additions & 40 deletions src/usdb_syncer/webserver/webserver.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ def _index(
)


def _api_songs(
def _fragments_songs(
show_nonlocal_songs: bool,
allow_downloading: bool,
like_counter: Counter[SongId],
Expand All @@ -124,10 +124,7 @@ def _api_songs(
)


def _api_mp3() -> 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")
Expand All @@ -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]:
Expand All @@ -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__)
Expand All @@ -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/<int:selected_id>/audio")
def api_audio(selected_id: int) -> flask.Response:
return _api_audio(selected_id)

@app.route("/api/songs/<int:selected_id>/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/<int:selected_id>/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/<int:selected_id>")
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/<int:selected_id>/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/<int:selected_id>/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/<int:selected_id>/like")
def fragments_like(selected_id: int) -> flask.Response:
return _fragments_like(session_id, like_counter, selected_id, like=True)

@app.delete("/fragments/songs/<int:selected_id>/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:
Expand Down