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:
cwilvx
2024-08-15 17:07:34 +03:00
parent cd992419c5
commit ca31054f48
18 changed files with 508 additions and 138 deletions
+3 -3
View File
@@ -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
View File
@@ -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