mirror of
https://github.com/Dvorinka/swingmusic-extended.git
synced 2026-06-03 20:13:02 +00:00
fix: folder endpoint returning same track for different files of the same trackhash
+ fix: chunked streaming. return instead of yield chunks
This commit is contained in:
+3
-3
@@ -108,14 +108,14 @@ class PairDeviceQuery(BaseModel):
|
||||
code: str = Field("", description="The code")
|
||||
|
||||
|
||||
@api.post("/pair")
|
||||
@api.get("/pair")
|
||||
@jwt_required(optional=True)
|
||||
def pair_with_code(body: PairDeviceQuery):
|
||||
def pair_with_code(query: PairDeviceQuery):
|
||||
"""
|
||||
Get an access token by sending a pair code. NOTE: A code can only be used once!
|
||||
"""
|
||||
global pair_token
|
||||
token = pair_token.get(body.code, None)
|
||||
token = pair_token.get(query.code, None)
|
||||
|
||||
if token:
|
||||
pair_token = {}
|
||||
|
||||
+171
-15
@@ -3,12 +3,16 @@ Contains all the track routes.
|
||||
"""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import time
|
||||
from typing import Literal
|
||||
|
||||
from flask import send_file, request, Response
|
||||
from flask_openapi3 import APIBlueprint, Tag
|
||||
from pydantic import BaseModel, Field
|
||||
from app.api.apischemas import TrackHashSchema
|
||||
from app.lib.trackslib import get_silence_paddings
|
||||
from app.lib.transcoder import start_transcoding
|
||||
|
||||
from app.store.tracks import TrackStore
|
||||
from app.utils.files import guess_mime_type
|
||||
@@ -17,10 +21,36 @@ bp_tag = Tag(name="File", description="Audio files")
|
||||
api = APIBlueprint("track", __name__, url_prefix="/file", abp_tags=[bp_tag])
|
||||
|
||||
|
||||
class TransCodeStore:
|
||||
map: dict[str, str] = {}
|
||||
|
||||
@classmethod
|
||||
def add_file(cls, trackhash: str, filepath: str):
|
||||
cls.map[trackhash] = filepath
|
||||
|
||||
@classmethod
|
||||
def remove_file(cls, trackhash: str):
|
||||
del cls.map[trackhash]
|
||||
|
||||
@classmethod
|
||||
def find(cls, trackhash: str):
|
||||
return cls.map.get(trackhash)
|
||||
|
||||
|
||||
class SendTrackFileQuery(BaseModel):
|
||||
filepath: str = Field(
|
||||
description="The filepath to play (if available)", default=None
|
||||
)
|
||||
quality: Literal["original", "1411", "800", "600", "320", "256", "128", "96"] = (
|
||||
Field(
|
||||
"320",
|
||||
description="The quality of the audio file. Options: original, 1411, 1024, 512, 320, 256, 128, 96",
|
||||
)
|
||||
)
|
||||
container: Literal["mp3", "aac", "flac", "webm", "ogg"] = Field(
|
||||
"flac",
|
||||
description="The container format of the audio file. Options: mp3, aac, flac, webm, ogg",
|
||||
)
|
||||
|
||||
|
||||
@api.get("/<trackhash>/legacy")
|
||||
@@ -29,6 +59,8 @@ def send_track_file_legacy(path: TrackHashSchema, query: SendTrackFileQuery):
|
||||
Get a playable audio file without Range support
|
||||
|
||||
Returns a playable audio file that corresponds to the given filepath. Falls back to track hash if filepath is not found.
|
||||
|
||||
NOTE: Does not support range requests or transcoding.
|
||||
"""
|
||||
trackhash = path.trackhash
|
||||
filepath = query.filepath
|
||||
@@ -37,7 +69,6 @@ def send_track_file_legacy(path: TrackHashSchema, query: SendTrackFileQuery):
|
||||
track = None
|
||||
tracks = TrackStore.get_tracks_by_filepaths([filepath])
|
||||
|
||||
|
||||
if len(tracks) > 0 and os.path.exists(filepath):
|
||||
track = tracks[0]
|
||||
else:
|
||||
@@ -66,10 +97,17 @@ def send_track_file(path: TrackHashSchema, query: SendTrackFileQuery):
|
||||
Get a playable audio file with Range headers support
|
||||
|
||||
Returns a playable audio file that corresponds to the given filepath. Falls back to track hash if filepath is not found.
|
||||
|
||||
Transcoding can be done by sending the quality and container query parameters.
|
||||
|
||||
**NOTES:**
|
||||
- Transcoded streams report incorrect duration during playback (idk why! FFMPEG gurus we need your help here).
|
||||
- The quality parameter is the desired bitrate in kbps.
|
||||
- The mp3 container is the best container for upto 320kbps (and has better duration reporting). The flac container allows for higher bitrates but it produces dramatically larger files (when transcoding from lossy formats).
|
||||
- You can get the transcoded bitrate by checking the X-Transcoded-Bitrate header on the first request's response.
|
||||
"""
|
||||
trackhash = path.trackhash
|
||||
filepath = query.filepath
|
||||
msg = {"msg": "File Not Found"}
|
||||
|
||||
# If filepath is provided, try to send that
|
||||
track = None
|
||||
@@ -91,13 +129,87 @@ def send_track_file(path: TrackHashSchema, query: SendTrackFileQuery):
|
||||
break
|
||||
|
||||
if track is not None:
|
||||
audio_type = guess_mime_type(filepath)
|
||||
return send_file_as_chunks(track.filepath, audio_type)
|
||||
if query.quality == "original":
|
||||
return send_file_as_chunks(track.filepath)
|
||||
|
||||
return msg, 404
|
||||
# prevent requesting over transcoding
|
||||
max_bitrate = track.bitrate
|
||||
requested_bitrate = int(query.quality)
|
||||
|
||||
if query.container != "flac":
|
||||
# drop to 320 for non-flac containers
|
||||
requested_bitrate = min(320, requested_bitrate)
|
||||
|
||||
quality = f"{min(max_bitrate, requested_bitrate)}k"
|
||||
return transcode_and_stream(trackhash, track.filepath, quality, query.container)
|
||||
|
||||
return {"msg": "File Not Found"}, 404
|
||||
|
||||
|
||||
def send_file_as_chunks(filepath: str, audio_type: str) -> Response:
|
||||
def transcode_and_stream(trackhash: str, filepath: str, bitrate: str, container: str):
|
||||
"""
|
||||
Initiates transcoding and returns the first chunk of the transcoded file.
|
||||
|
||||
The other chunks are streamed on subsequent requests and are rerouted to `send_file_as_chunks`.
|
||||
"""
|
||||
temp_file = TransCodeStore.find(trackhash)
|
||||
if temp_file is not None:
|
||||
return send_file_as_chunks(temp_file)
|
||||
|
||||
format_params = {
|
||||
"mp3": ["-c:a", "libmp3lame"],
|
||||
"aac": ["-c:a", "aac"],
|
||||
"webm": ["-c:a", "libopus"],
|
||||
"ogg": ["-c:a", "libvorbis"],
|
||||
"flac": ["-c:a", "flac"],
|
||||
"wav": ["-c:a", "pcm_s16le"],
|
||||
}
|
||||
|
||||
# Create a temporary file
|
||||
format = f".{container}" if container in format_params.keys() else ".flac"
|
||||
container_args = (
|
||||
format_params[container]
|
||||
if container in format_params.keys()
|
||||
else format_params["flac"]
|
||||
)
|
||||
temp_file = tempfile.NamedTemporaryFile(delete=False, suffix=format)
|
||||
temp_filename = temp_file.name
|
||||
temp_file.close()
|
||||
|
||||
TransCodeStore.add_file(trackhash, temp_filename)
|
||||
start_transcoding(filepath, temp_filename, bitrate, container_args)
|
||||
|
||||
chunk_size = 1024 * 512 # 0.5MB
|
||||
file_size = os.path.getsize(filepath)
|
||||
|
||||
def generate():
|
||||
# Poll for the output file
|
||||
while (
|
||||
not os.path.exists(temp_filename)
|
||||
or os.path.getsize(temp_filename) < chunk_size
|
||||
):
|
||||
print(f"Waiting for transcoding to complete... filename: {temp_filename}")
|
||||
time.sleep(0.1) # Wait for 100ms before checking again
|
||||
|
||||
with open(temp_filename, "rb") as file:
|
||||
file.seek(0)
|
||||
return file.read(chunk_size)
|
||||
|
||||
audio_type = guess_mime_type(temp_filename)
|
||||
response = Response(
|
||||
generate(),
|
||||
206,
|
||||
mimetype=audio_type,
|
||||
content_type=audio_type,
|
||||
direct_passthrough=True,
|
||||
)
|
||||
response.headers.add("Content-Range", f"bytes {0}-{chunk_size}/{file_size}")
|
||||
response.headers.add("Accept-Ranges", "bytes")
|
||||
response.headers.add("X-Transcoded-Bitrate", bitrate)
|
||||
return response
|
||||
|
||||
|
||||
def send_file_as_chunks(filepath: str) -> Response:
|
||||
"""
|
||||
Returns a Response object that streams the file in chunks.
|
||||
"""
|
||||
@@ -129,25 +241,69 @@ def send_file_as_chunks(filepath: str, audio_type: str) -> Response:
|
||||
file.seek(start)
|
||||
remaining_bytes = end - start + 1
|
||||
|
||||
while remaining_bytes > 0:
|
||||
retry_count = 0
|
||||
max_retries = 10 # 5 * 100ms = 500ms total wait time
|
||||
|
||||
while remaining_bytes > 0 or retry_count < max_retries:
|
||||
if retry_count == max_retries:
|
||||
print("💚 sending final chunk! ...")
|
||||
return (
|
||||
file.read(os.path.getsize(filepath) - file.tell()),
|
||||
file.tell(),
|
||||
True,
|
||||
)
|
||||
print("\n\n")
|
||||
print(f"file: {filepath}")
|
||||
print(f"start: {start}")
|
||||
print(f"end: {end}")
|
||||
print(f"filesize: {os.path.getsize(filepath)}")
|
||||
print(f"⭐ (O) Remaining bytes: {remaining_bytes}")
|
||||
print(f"⭐ Remaining bytes: {remaining_bytes}")
|
||||
print(f"⭐ Cursor position: {file.tell()}")
|
||||
# Read the chunk size or all the remaining bytes
|
||||
|
||||
print(f"💚 remaining_bytes: {remaining_bytes}")
|
||||
print(f"💚 retry_count: {retry_count}")
|
||||
|
||||
if remaining_bytes < chunk_size:
|
||||
time.sleep(0.25)
|
||||
retry_count += 1
|
||||
remaining_bytes = os.path.getsize(filepath) - file.tell()
|
||||
continue
|
||||
|
||||
chunk = file.read(min(chunk_size, remaining_bytes))
|
||||
yield chunk
|
||||
if chunk:
|
||||
remaining_bytes -= len(chunk)
|
||||
return chunk, file.tell(), False
|
||||
else:
|
||||
# If no data is read, wait for 100ms before retrying
|
||||
time.sleep(0.25)
|
||||
retry_count += 1
|
||||
|
||||
# Update the remaining bytes
|
||||
remaining_bytes -= len(chunk)
|
||||
# update remaining bytes
|
||||
remaining_bytes = os.path.getsize(filepath) - file.tell()
|
||||
print(f"▶ Remaining bytes: {remaining_bytes}")
|
||||
|
||||
return None, 0, True
|
||||
|
||||
data, position, is_final = generate_chunks()
|
||||
|
||||
audio_type = guess_mime_type(filepath)
|
||||
response = Response(
|
||||
generate_chunks(),
|
||||
206, # Partial Content status code
|
||||
response=data,
|
||||
status=206, # Partial Content status code
|
||||
mimetype=audio_type,
|
||||
content_type=audio_type,
|
||||
direct_passthrough=True,
|
||||
)
|
||||
response.headers.add("Content-Range", f"bytes {start}-{end}/{file_size}")
|
||||
response.headers.add("Accept-Ranges", "bytes")
|
||||
response.headers.add("Content-Length", str(end - start + 1))
|
||||
|
||||
bytes_to_add = chunk_size if not is_final else 0
|
||||
response.headers.add(
|
||||
"Content-Range",
|
||||
f"bytes {start}-{position}/{os.path.getsize(filepath) + bytes_to_add}",
|
||||
)
|
||||
response.headers.add("Accept-Ranges", "bytes")
|
||||
response.headers.add("Content-Length", str(len(data or [])))
|
||||
return response
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user