diff --git a/src/handler_api.c b/src/handler_api.c index 83375dc22..a0e4c9281 100644 --- a/src/handler_api.c +++ b/src/handler_api.c @@ -2067,7 +2067,7 @@ static error_t handleApiContentTrackExport(HttpConnection *connection, const cha if (end == p || value < 1 || (size_t)value > page_count) { fclose(src); - return ERROR_INVALID_PARAMETER; + return httpSendErrorResponse(connection, 400, "Invalid track selection"); } track_numbers[track_count++] = (int)value; p = end; @@ -2076,7 +2076,7 @@ static error_t handleApiContentTrackExport(HttpConnection *connection, const cha if (track_count == 0) { fclose(src); - return ERROR_INVALID_PARAMETER; + return httpSendErrorResponse(connection, 400, "Invalid track selection"); } if (create_taf_temp_dir(settings, tmpdir, sizeof(tmpdir)) != NO_ERROR) @@ -2409,6 +2409,27 @@ error_t handleApiContentDownload(HttpConnection *connection, const char_t *uri, char *path = (char *)uri + 1 + 7 + 1 + 8 + 1; char *pathAbsolute = custom_asprintf("%s%c%s", rootPath, PATH_SEPARATOR, path); + char tracks_param[TAF_TRACK_EXPORT_MAX * 4]; + if (queryGet(queryString, "tracks", tracks_param, sizeof(tracks_param))) + { + /* export from the TAF the tag points to (e.g. a lib:// source), same as /content/ does */ + tonie_info_t *tonieInfo = getTonieInfo(pathAbsolute, false, client_ctx->settings); + osFreeMem(pathAbsolute); + + error_t export_error = ERROR_NOT_FOUND; + if (tonieInfo->exists && tonieInfo->valid && + (tonieInfo->json._source_type == CT_SOURCE_NONE || tonieInfo->json._source_type == CT_SOURCE_TAF || tonieInfo->json._source_type == CT_SOURCE_TAP_CACHED)) + { + export_error = handleApiContentTrackExport(connection, tonieInfo->contentPath, tracks_param, client_ctx->settings); + } + else + { + TRACE_ERROR("No local TAF for track export of '%s'\r\n", path); + } + freeTonieInfo(tonieInfo); + return export_error; + } + char ruid[17]; contentJson_t contentJson; diff --git a/tests/py/test_track_export.py b/tests/py/test_track_export.py new file mode 100644 index 000000000..ef3c2fc20 --- /dev/null +++ b/tests/py/test_track_export.py @@ -0,0 +1,111 @@ +#!/usr/bin/env python3 +""" +Selected tracks of a TAF are exported as a zip of Ogg files (several tracks) +or as a single Ogg (one track), on both download routes the web UI uses: + + /content/?ogg=true&tracks=... file browser + /content/download/?...&tracks=... tonie card, tag with a lib:// source (#350) + +The TAF is encoded by the server from generated white noise (needs ffmpeg), +the zip is built with the external zip binary. + +Run via: make test_py TESTS=track_export +""" + +import http.client +import io +import json +import os +import unittest +import wave +import zipfile + +from test_hardening import BASE, BASE_DIR, Base + +TRACK_SECONDS = [2, 2, 2] +RATE = 4000 # low rate: the test upload helper only handles small bodies, the server resamples +TAF = "__trackexport.taf" +TAG_DIR, TAG_FILE = "A64E581D", "500304E0" + + +def white_noise_wav(seconds): + buf = io.BytesIO() + with wave.open(buf, "wb") as w: + w.setnchannels(1) + w.setsampwidth(1) + w.setframerate(RATE) + w.writeframes(os.urandom(RATE * seconds)) + return buf.getvalue() + + +def get(path): + conn = http.client.HTTPConnection(BASE.hostname, BASE.port, timeout=30) + try: + conn.request("GET", path) + resp = conn.getresponse() + return resp.status, resp.getheader("Content-Type", ""), resp.read() + finally: + conn.close() + + +class TrackExport(Base): + def setUp(self): + super().setUp() + sources = [] + for i, seconds in enumerate(TRACK_SECONDS): + name = f"__trackexport{i}.wav" + self.put_library(name, white_noise_wav(seconds)) + sources.append(name) + status, text = self.encode(sources, TAF) + self.assertEqual(status, 200, text) + + # a tag whose audio lives in the library, as set up by "assign to tag" in the web UI + self.tag_json = BASE_DIR / "data" / "content" / "default" / TAG_DIR / f"{TAG_FILE}.json" + self.tag_json.parent.mkdir(parents=True, exist_ok=True) + self.tag_json.write_text(json.dumps({"source": f"lib://{TAF}", "nocloud": True})) + + def tearDown(self): + self.tag_json.unlink(missing_ok=True) + super().tearDown() + + def assert_zip_of_tracks(self, path, count): + status, content_type, body = get(path) + self.assertEqual(status, 200, body[:200]) + self.assertEqual(content_type, "application/zip") + with zipfile.ZipFile(io.BytesIO(body)) as z: + self.assertIsNone(z.testzip()) + names = z.namelist() + self.assertEqual(len(names), count, names) + for name in names: + self.assertTrue(z.read(name).startswith(b"OggS"), name) + + def assert_single_ogg(self, path): + status, _, body = get(path) + self.assertEqual(status, 200, body[:200]) + self.assertTrue(body.startswith(b"OggS")) + # one track only, not the whole TAF + _, _, whole = get(f"/content/{TAF}?special=library&ogg=true") + self.assertLess(len(body), len(whole)) + + def test_content_path(self): + self.assert_zip_of_tracks(f"/content/{TAF}?special=library&ogg=true&tracks=2%2C3", 2) + self.assert_single_ogg(f"/content/{TAF}?special=library&ogg=true&tracks=2") + + def test_content_download_tag(self): + url = f"/content/download/{TAG_DIR}/{TAG_FILE}?skip_header=true&ogg=true" + self.assert_zip_of_tracks(url + "&tracks=2%2C3", 2) + self.assert_single_ogg(url + "&tracks=2") + + def test_content_invalid_track(self): + for query in ("tracks=9", "tracks=0", "tracks=abc", "tracks=,"): + status, _, _ = get(f"/content/{TAF}?special=library&ogg=true&{query}") + self.assertEqual(status, 400, query) + + def test_content_download_tag_invalid_track(self): + for query in ("tracks=9", "tracks=0", "tracks=abc", "tracks=,"): + status, _, _ = get(f"/content/download/{TAG_DIR}/{TAG_FILE}?skip_header=true&ogg=true&{query}") + self.assertEqual(status, 400, query) + + +if __name__ == "__main__": + unittest.main()