1
0
mirror of https://github.com/spl0k/supysonic.git synced 2024-12-22 17:06:17 +00:00

Merge branch 'issue202'

This commit is contained in:
Alban Féron 2020-11-22 15:17:47 +01:00
commit d6f1a11aca
No known key found for this signature in database
GPG Key ID: 8CE0313646D16165
4 changed files with 122 additions and 22 deletions

View File

@ -157,25 +157,50 @@ def stream_media():
except OSError: except OSError:
raise ServerError("Error while running the transcoding process") raise ServerError("Error while running the transcoding process")
if estimateContentLength == "true":
estimate = dst_bitrate * 1000 * res.duration // 8
else:
estimate = None
def transcode(): def transcode():
try:
while True: while True:
data = proc.stdout.read(8192) data = proc.stdout.read(8192)
if not data: if not data:
break break
yield data yield data
except BaseException:
# Make sure child processes are always killed def kill_processes():
if dec_proc != None: if dec_proc != None:
dec_proc.kill() dec_proc.kill()
proc.kill() proc.kill()
def handle_transcoding():
try:
sent = 0
for data in transcode():
sent += len(data)
yield data
except (Exception, SystemExit, KeyboardInterrupt):
# Make sure child processes are always killed
kill_processes()
raise
except GeneratorExit:
# Try to transcode/send more data if we're close to the end.
# The calling code have to support this as yielding more data
# after a GeneratorExit would normally raise a RuntimeError.
# Hopefully this generator is only used by the cache which
# handles this.
if estimate and sent >= estimate * 0.95:
yield from transcode()
else:
kill_processes()
raise raise
finally: finally:
if dec_proc != None: if dec_proc != None:
dec_proc.wait() dec_proc.wait()
proc.wait() proc.wait()
resp_content = cache.set_generated(cache_key, transcode) resp_content = cache.set_generated(cache_key, handle_transcoding)
logger.info( logger.info(
"Transcoding track {0.id} for user {1.id}. Source: {2} at {0.bitrate}kbps. Dest: {3} at {4}kbps".format( "Transcoding track {0.id} for user {1.id}. Source: {2} at {0.bitrate}kbps. Dest: {3} at {4}kbps".format(
@ -183,10 +208,8 @@ def stream_media():
) )
) )
response = Response(resp_content, mimetype=dst_mimetype) response = Response(resp_content, mimetype=dst_mimetype)
if estimateContentLength == "true": if estimate is not None:
response.headers.add( response.headers.add("Content-Length", estimate)
"Content-Length", dst_bitrate * 1000 * res.duration // 8
)
else: else:
response = send_file(res.path, mimetype=dst_mimetype, conditional=True) response = send_file(res.path, mimetype=dst_mimetype, conditional=True)

View File

@ -159,7 +159,7 @@ class Cache(object):
self._make_space(size, key=key) self._make_space(size, key=key)
os.replace(f.name, self._filepath(key)) os.replace(f.name, self._filepath(key))
self._record_file(key, size) self._record_file(key, size)
except Exception: except BaseException:
f.close() f.close()
with contextlib.suppress(OSError): with contextlib.suppress(OSError):
os.remove(f.name) os.remove(f.name)
@ -183,9 +183,21 @@ class Cache(object):
... print(x) ... print(x)
""" """
with self.set_fileobj(key) as f: with self.set_fileobj(key) as f:
for data in gen_function(): gen = gen_function()
try:
for data in gen:
f.write(data) f.write(data)
yield data yield data
except GeneratorExit:
# Try to stop the generator but check it still wants to yield data.
# If it does allow caching of this data without forwarding it
try:
f.write(gen.throw(GeneratorExit))
for data in gen:
f.write(data)
except StopIteration:
# We stopped just at the end of the generator
pass
def get(self, key): def get(self, key):
"""Return the path to the file where the cached data is stored""" """Return the path to the file where the cached data is stored"""

View File

@ -10,6 +10,7 @@
import unittest import unittest
import sys import sys
from flask import current_app
from pony.orm import db_session from pony.orm import db_session
from supysonic.db import Folder, Track from supysonic.db import Folder, Track
@ -22,7 +23,6 @@ from .apitestbase import ApiTestBase
class TranscodingTestCase(ApiTestBase): class TranscodingTestCase(ApiTestBase):
def setUp(self): def setUp(self):
super(TranscodingTestCase, self).setUp() super(TranscodingTestCase, self).setUp()
self._patch_client()
with db_session: with db_session:
folder = FolderManager.add("Folder", "tests/assets/folder") folder = FolderManager.add("Folder", "tests/assets/folder")
@ -52,8 +52,10 @@ class TranscodingTestCase(ApiTestBase):
) )
def test_direct_transcode(self): def test_direct_transcode(self):
rv = self._stream(maxBitRate=96, estimateContentLength="true") rv = self._stream(maxBitRate=96, estimateContentLength="true")
self.assertIn("tests/assets/folder/silence.mp3", rv.data) self.assertIn(b"tests/assets/folder/silence.mp3", rv.data)
self.assertTrue(rv.data.endswith("96")) self.assertTrue(rv.data.endswith(b"96"))
self.assertIn("Content-Length", rv.headers)
self.assertEqual(rv.content_length, 48000) # 4s at 96kbps
@unittest.skipIf( @unittest.skipIf(
sys.platform == "win32", sys.platform == "win32",
@ -61,10 +63,69 @@ class TranscodingTestCase(ApiTestBase):
) )
def test_decode_encode(self): def test_decode_encode(self):
rv = self._stream(format="cat") rv = self._stream(format="cat")
self.assertEqual(rv.data, "Pushing out some mp3 data...") self.assertEqual(rv.data, b"Pushing out some mp3 data...")
rv = self._stream(format="md5") rv = self._stream(format="md5")
self.assertTrue(rv.data.startswith("dbb16c0847e5d8c3b1867604828cb50b")) self.assertTrue(rv.data.startswith(b"dbb16c0847e5d8c3b1867604828cb50b"))
@unittest.skipIf(
sys.platform == "win32",
"Can't test transcoding on Windows because of a lack of simple commandline tools",
)
def test_mostly_transcoded_cached(self):
# See https://github.com/spl0k/supysonic/issues/202
rv = self._stream(maxBitRate=96, estimateContentLength="true", format="rnd")
read = 0
it = iter(rv.response)
# Read up to the estimated length
while read < 48000:
read += len(next(it))
rv.response.close()
rv.close()
key = "{}-96.rnd".format(self.trackid)
with self.app_context():
self.assertTrue(current_app.transcode_cache.has(key))
self.assertEqual(current_app.transcode_cache.size, 52000)
@unittest.skipIf(
sys.platform == "win32",
"Can't test transcoding on Windows because of a lack of simple commandline tools",
)
def test_partly_transcoded_cached(self):
rv = self._stream(maxBitRate=96, estimateContentLength="true", format="rnd")
# read one check of data then close the connection
next(iter(rv.response))
rv.response.close()
rv.close()
key = "{}-96.rnd".format(self.trackid)
with self.app_context():
self.assertFalse(current_app.transcode_cache.has(key))
self.assertEqual(current_app.transcode_cache.size, 0)
@unittest.skipIf(
sys.platform == "win32",
"Can't test transcoding on Windows because of a lack of simple commandline tools",
)
def test_last_chunk_close_transcoded_cached(self):
rv = self._stream(maxBitRate=96, estimateContentLength="true", format="rnd")
read = 0
it = iter(rv.response)
# Read up to the last chunk of data but keep the generator "alive"
while read < 52000:
read += len(next(it))
rv.response.close()
rv.close()
key = "{}-96.rnd".format(self.trackid)
with self.app_context():
self.assertTrue(current_app.transcode_cache.has(key))
self.assertEqual(current_app.transcode_cache.size, 52000)
if __name__ == "__main__": if __name__ == "__main__":

View File

@ -25,6 +25,7 @@ class TestConfig(DefaultConfig):
MIMETYPES = {"mp3": "audio/mpeg", "weirdextension": "application/octet-stream"} MIMETYPES = {"mp3": "audio/mpeg", "weirdextension": "application/octet-stream"}
TRANSCODING = { TRANSCODING = {
"transcoder_mp3_mp3": "echo -n %srcpath %outrate", "transcoder_mp3_mp3": "echo -n %srcpath %outrate",
"transcoder_mp3_rnd": "dd if=/dev/urandom bs=1kB count=52 status=none",
"decoder_mp3": "echo -n Pushing out some mp3 data...", "decoder_mp3": "echo -n Pushing out some mp3 data...",
"encoder_cat": "cat -", "encoder_cat": "cat -",
"encoder_md5": "md5sum", "encoder_md5": "md5sum",
@ -100,6 +101,9 @@ class TestBase(unittest.TestCase):
self.client.get = patch_method(self.client.get) self.client.get = patch_method(self.client.get)
self.client.post = patch_method(self.client.post) self.client.post = patch_method(self.client.post)
def app_context(self, *args, **kwargs):
return self.__app.app_context(*args, **kwargs)
def request_context(self, *args, **kwargs): def request_context(self, *args, **kwargs):
return self.__app.test_request_context(*args, **kwargs) return self.__app.test_request_context(*args, **kwargs)