diff --git a/.dockerignore b/.dockerignore index 2b000fe..fba494e 100644 --- a/.dockerignore +++ b/.dockerignore @@ -10,6 +10,9 @@ # Data /data/ +# OpenAPI +/openapi/schema.html + # Tracked, but not needed /.devcontainer/ /.github/ diff --git a/.gitattributes b/.gitattributes index b1fe66c..6b8c328 100644 --- a/.gitattributes +++ b/.gitattributes @@ -2,5 +2,7 @@ * linguist-vendored # Treat docs as documentation /docs/** -linguist-vendored linguist-documentation +# Treat openapi as documentation +/openapi/** -linguist-vendored linguist-documentation # Unmark files in src, so that they are included in language stats /src/** -linguist-vendored diff --git a/.github/workflows/image.yaml b/.github/workflows/image.yaml index fdafe82..2b9174c 100644 --- a/.github/workflows/image.yaml +++ b/.github/workflows/image.yaml @@ -11,6 +11,7 @@ name: Image # Run only on changes to relevant files paths: - .github/workflows/image.yaml + - openapi/** - scripts/** - src/** - .dockerignore @@ -24,6 +25,7 @@ name: Image # Run only on changes to relevant files paths: - .github/workflows/image.yaml + - openapi/** - scripts/** - src/** - .dockerignore diff --git a/.gitignore b/.gitignore index dee2f58..ea4f6f5 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,6 @@ # Data /data/* !/data/.gitkeep + +# OpenAPI +/openapi/schema.html diff --git a/Dockerfile b/Dockerfile index 32495b2..c364660 100644 --- a/Dockerfile +++ b/Dockerfile @@ -42,6 +42,9 @@ RUN useradd --create-home app && \ COPY scripts/shell.sh scripts/shell.sh SHELL ["/app/scripts/shell.sh"] +# Copy OpenAPI schema +COPY openapi/ openapi/ + # Copy source COPY src/ src/ diff --git a/docker-compose.yaml b/docker-compose.yaml index cf1ad8b..8fb0b06 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -4,8 +4,8 @@ services: context: ./ network: host environment: - - "FUSION__SERVER__HOST=${FUSION__SERVER__HOST:-0.0.0.0}" - - "FUSION__SERVER__PORT=${FUSION__SERVER__PORT:-9000}" + - "FUSION__SERVER__SRT__PORT=${FUSION__SERVER__SRT__PORT:-9000}" + - "FUSION__SERVER__HTTP__PORT=${FUSION__SERVER__HTTP__PORT:-9001}" - "FUSION__STATE__STORE__PATH=${FUSION__STATE__STORE__PATH:-data/state.json}" - "FUSION__STATE__CACHE__TTL=${FUSION__STATE__CACHE__TTL:-60}" - "FUSION__STREAMCAST__ICY__HOST=${FUSION__STREAMCAST__ICY__HOST:-localhost}" @@ -13,6 +13,10 @@ services: - "FUSION__STREAMCAST__ICY__USER=${FUSION__STREAMCAST__ICY__USER:-source}" - "FUSION__STREAMCAST__ICY__PASSWORD=${FUSION__STREAMCAST__ICY__PASSWORD:-password}" - "FUSION__STREAMCAST__ICY__MOUNT=${FUSION__STREAMCAST__ICY__MOUNT:-radio.mp3}" + - "FUSION__EMITUNES__HTTP__SCHEME=${FUSION__EMITUNES__HTTP__SCHEME:-http}" + - "FUSION__EMITUNES__HTTP__HOST=${FUSION__EMITUNES__HTTP__HOST:-localhost}" + - "FUSION__EMITUNES__HTTP__PORT=${FUSION__EMITUNES__HTTP__PORT:-42000}" + - "FUSION__EMITUNES__HTTP__PATH=${FUSION__EMITUNES__HTTP__PATH:-}" network_mode: host volumes: - data:/app/data/ diff --git a/docs/docs/02-Usage.md b/docs/docs/02-Usage.md index a877691..6ff579d 100644 --- a/docs/docs/02-Usage.md +++ b/docs/docs/02-Usage.md @@ -38,3 +38,23 @@ ffmpeg \ -f ogg \ srt://127.0.0.1:9000 ``` + +## Managing playlists + +You can manage currently used playlist using the `/playlist` endpoint. +You can use the following HTTP methods: + +- `GET` to retrieve the current playlist data +- `PUT` to update the current playlist data + +For example, to change the playlist to use a different one, +you can use [`curl`](https://curl.se) +to send a `PUT` request to the `/playlist` endpoint: + +```sh +curl \ + --request PUT \ + --header "Content-Type: application/json" \ + --data '{"id": "123e4567-e89b-12d3-a456-426614174000"}' \ + http://localhost:9001/playlist +``` diff --git a/docs/docs/03-Configuration.md b/docs/docs/03-Configuration.md index c0cd637..c31a37e 100644 --- a/docs/docs/03-Configuration.md +++ b/docs/docs/03-Configuration.md @@ -7,12 +7,12 @@ title: Configuration You can configure the app at runtime using various environment variables: -- `FUSION__SERVER__HOST` - - host to listen for live audio - (default: `0.0.0.0`) -- `FUSION__SERVER__PORT` - - port to listen for live audio +- `FUSION__SERVER__SRT__PORT` + port to listen for SRT connections (default: `9000`) +- `FUSION__SERVER__HTTP__PORT` - + port to listen for HTTP connections + (default: `9001`) - `FUSION__STATE__STORE__PATH` - path to the file to store the state (default: `data/state.json`) @@ -34,3 +34,15 @@ You can configure the app at runtime using various environment variables: - `FUSION__STREAMCAST__ICY__MOUNT` - mount point of the ICY API of the streamcast service to send the audio to (default: `radio.mp3`) +- `FUSION__EMITUNES__HTTP__SCHEME` - + scheme of the HTTP API of the emitunes service + (default: `http`) +- `FUSION__EMITUNES__HTTP__HOST` - + host of the HTTP API of the emitunes service + (default: `localhost`) +- `FUSION__EMITUNES__HTTP__PORT` - + port of the HTTP API of the emitunes service + (default: `42000`) +- `FUSION__EMITUNES__HTTP__PATH` - + path of the HTTP API of the emitunes service + (default: ``) diff --git a/flake.nix b/flake.nix index b62d739..fcff1d0 100644 --- a/flake.nix +++ b/flake.nix @@ -42,6 +42,7 @@ trunk = pkgs.trunk-io; copier = pkgs.copier; liquidsoap = pkgs.liquidsoap; + redocly = pkgs.redocly; ffmpeg = pkgs.ffmpeg; tini = pkgs.tini; su-exec = pkgs.su-exec; @@ -75,6 +76,7 @@ trunk copier liquidsoap + redocly ffmpeg ]; @@ -88,6 +90,7 @@ packages = [ liquidsoap + redocly tini su-exec ]; diff --git a/openapi/schema.yaml b/openapi/schema.yaml new file mode 100644 index 0000000..ab7b101 --- /dev/null +++ b/openapi/schema.yaml @@ -0,0 +1,89 @@ +info: + title: fusion app + description: Audio streaming with Liquidsoap 🧼 +openapi: 3.1.0 +servers: + - url: / +paths: + /ping: + get: + summary: Ping + description: Do nothing. + responses: + "204": + description: Request fulfilled, nothing follows + headers: + Cache-Control: + schema: + type: string + example: no-cache + /playlist: + get: + summary: Get playlist data + description: Get the current playlist data. + responses: + "200": + description: Request fulfilled, document follows + content: + application/json: + schema: + $ref: "#/components/schemas/GetPlaylistResponse" + "404": + description: Request failed, resource not found + content: + text/plain: + schema: + type: string + example: Playlist data not found. + put: + summary: Update playlist data + description: Update the current playlist data. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/PutPlaylistRequest" + responses: + "200": + description: Request fulfilled, document follows + content: + application/json: + schema: + $ref: "#/components/schemas/PutPlaylistResponse" + "400": + description: Bad request syntax + content: + text/plain: + schema: + type: string + example: Request body is not a valid JSON object. +components: + schemas: + GetPlaylistResponse: + type: object + properties: + id: + type: string + format: uuid + example: 123e4567-e89b-12d3-a456-426614174000 + required: + - id + PutPlaylistRequest: + type: object + properties: + id: + type: string + format: uuid + example: 123e4567-e89b-12d3-a456-426614174000 + required: + - id + PutPlaylistResponse: + type: object + properties: + id: + type: string + format: uuid + example: 123e4567-e89b-12d3-a456-426614174000 + required: + - id diff --git a/src/api/app.liq b/src/api/app.liq new file mode 100644 index 0000000..af8fa2c --- /dev/null +++ b/src/api/app.liq @@ -0,0 +1,19 @@ +let api.app = {} + +def api.app.register(~method, ~path, handler) = + def handle(request) = + try + handler(request) + catch _ do + api.responses.text( + status=api.codes.internalservererror, + body= + "Internal Server Error" + ) + end + end + + harbor.http.register.simple( + port=config.server.http.port, method=method, path, handle + ) +end diff --git a/src/api/codes.liq b/src/api/codes.liq new file mode 100644 index 0000000..187a9d9 --- /dev/null +++ b/src/api/codes.liq @@ -0,0 +1,8 @@ +let api.codes = {} + +let api.codes.ok = 200 +let api.codes.created = 201 +let api.codes.nocontent = 204 +let api.codes.badrequest = 400 +let api.codes.notfound = 404 +let api.codes.internalservererror = 500 diff --git a/src/api/index.liq b/src/api/index.liq new file mode 100644 index 0000000..7aec781 --- /dev/null +++ b/src/api/index.liq @@ -0,0 +1,8 @@ +let api = {} + +%include "codes.liq" +%include "responses.liq" +%include "app.liq" +%include "ping/index.liq" +%include "playlist/index.liq" +%include "schema/index.liq" diff --git a/src/api/ping/controller.liq b/src/api/ping/controller.liq new file mode 100644 index 0000000..cdba103 --- /dev/null +++ b/src/api/ping/controller.liq @@ -0,0 +1,8 @@ +let api.ping.controller = {} + +def api.ping.controller.ping(_) = + api.ping.service.ping() + api.responses.empty(headers=[("Cache-Control", "no-cache")]) +end + +api.app.register(method="GET", path="/ping", api.ping.controller.ping) diff --git a/src/api/ping/index.liq b/src/api/ping/index.liq new file mode 100644 index 0000000..86c9a2a --- /dev/null +++ b/src/api/ping/index.liq @@ -0,0 +1,4 @@ +let api.ping = {} + +%include "service.liq" +%include "controller.liq" diff --git a/src/api/ping/service.liq b/src/api/ping/service.liq new file mode 100644 index 0000000..783ab73 --- /dev/null +++ b/src/api/ping/service.liq @@ -0,0 +1,5 @@ +let api.ping.service = {} + +def api.ping.service.ping() = + () +end diff --git a/src/api/playlist/controller.liq b/src/api/playlist/controller.liq new file mode 100644 index 0000000..271da66 --- /dev/null +++ b/src/api/playlist/controller.liq @@ -0,0 +1,28 @@ +let api.playlist.controller = {} + +def api.playlist.controller.get(_) = + try + response = api.playlist.service.get() + api.responses.json( + status=api.codes.ok, + body=api.playlist.models.getresponse.serialize(response) + ) + catch error : [api.playlist.errors.notfound] do + api.responses.text(status=api.codes.notfound, body=error.message) + end +end + +def api.playlist.controller.put(request) = + try + req = api.playlist.models.putrequest.deserialize(request.body()) + res = api.playlist.service.put(req) + api.responses.json( + status=api.codes.ok, body=api.playlist.models.putresponse.serialize(res) + ) + catch error : [error.json] do + api.responses.text(status=api.codes.badrequest, body=error.message) + end +end + +api.app.register(method="GET", path="/playlist", api.playlist.controller.get) +api.app.register(method="PUT", path="/playlist", api.playlist.controller.put) diff --git a/src/api/playlist/errors.liq b/src/api/playlist/errors.liq new file mode 100644 index 0000000..97c16a4 --- /dev/null +++ b/src/api/playlist/errors.liq @@ -0,0 +1,4 @@ +let api.playlist.errors = {} + +let api.playlist.errors.notfound = + error.register("api.playlist.errors.notfound") diff --git a/src/api/playlist/index.liq b/src/api/playlist/index.liq new file mode 100644 index 0000000..fe28edf --- /dev/null +++ b/src/api/playlist/index.liq @@ -0,0 +1,6 @@ +let api.playlist = {} + +%include "errors.liq" +%include "models.liq" +%include "service.liq" +%include "controller.liq" diff --git a/src/api/playlist/models.liq b/src/api/playlist/models.liq new file mode 100644 index 0000000..5d1e4c2 --- /dev/null +++ b/src/api/playlist/models.liq @@ -0,0 +1,52 @@ +let api.playlist.models = {} + +def api.playlist.models.getresponse = + def create(~id) = + {id=id} + end + + def serialize(model) = + json.stringify(model) + end + + def deserialize(data) = + let json.parse (model : {id: string}) = data + model + end + + {create=create, serialize=serialize, deserialize=deserialize} +end + +def api.playlist.models.putrequest = + def create(~id) = + {id=id} + end + + def serialize(model) = + json.stringify(model) + end + + def deserialize(data) = + let json.parse (model : {id: string}) = data + model + end + + {create=create, serialize=serialize, deserialize=deserialize} +end + +def api.playlist.models.putresponse = + def create(~id) = + {id=id} + end + + def serialize(model) = + json.stringify(model) + end + + def deserialize(data) = + let json.parse (model : {id: string}) = data + model + end + + {create=create, serialize=serialize, deserialize=deserialize} +end diff --git a/src/api/playlist/service.liq b/src/api/playlist/service.liq new file mode 100644 index 0000000..3b448d8 --- /dev/null +++ b/src/api/playlist/service.liq @@ -0,0 +1,24 @@ +let api.playlist.service = {} + +def api.playlist.service.get() = + playlist = state().playlist + null.case( + playlist, + fun () -> + error.raise( + api.playlist.errors.notfound, + "Playlist data not found." + ), + fun (p) -> api.playlist.models.getresponse.create(id=p.id) + ) +end + +def api.playlist.service.put(request) = + playlist = {id=request.id} + + s = state() + let s.playlist = playlist + state := s + + api.playlist.models.putresponse.create(id=playlist.id) +end diff --git a/src/api/responses.liq b/src/api/responses.liq new file mode 100644 index 0000000..6f81ecf --- /dev/null +++ b/src/api/responses.liq @@ -0,0 +1,58 @@ +let api.responses = {} + +def api.responses.http(~status, ~headers=[], ~body) = + http.response(status_code=status, headers=headers, data=body) +end + +def api.responses.empty(~headers=[]) = + api.responses.http( + status=api.codes.nocontent, + headers=list.append(headers, [("Content-Length", "0")]), + body="" + ) +end + +def api.responses.text(~status, ~headers=[], ~body) = + api.responses.http( + status=status, + headers= + list.append( + headers, + [ + ("Content-Type", "text/plain"), + ("Content-Length", string(string.length(body))) + ] + ), + body=body + ) +end + +def api.responses.html(~status, ~headers=[], ~body) = + api.responses.http( + status=status, + headers= + list.append( + headers, + [ + ("Content-Type", "text/html"), + ("Content-Length", string(string.length(body))) + ] + ), + body=body + ) +end + +def api.responses.json(~status, ~headers=[], ~body) = + api.responses.http( + status=status, + headers= + list.append( + headers, + [ + ("Content-Type", "application/json"), + ("Content-Length", string(string.length(body))) + ] + ), + body=body + ) +end diff --git a/src/api/schema/controller.liq b/src/api/schema/controller.liq new file mode 100644 index 0000000..25a9818 --- /dev/null +++ b/src/api/schema/controller.liq @@ -0,0 +1,8 @@ +let api.schema.controller = {} + +def api.schema.controller.get(_) = + schema = api.schema.service.get() + api.responses.html(status=api.codes.ok, body=schema) +end + +api.app.register(method="GET", path="/schema", api.schema.controller.get) diff --git a/src/api/schema/errors.liq b/src/api/schema/errors.liq new file mode 100644 index 0000000..e79dbcb --- /dev/null +++ b/src/api/schema/errors.liq @@ -0,0 +1,3 @@ +let api.schema.errors = {} + +let api.schema.errors.generate = error.register("api.schema.errors.generate") diff --git a/src/api/schema/index.liq b/src/api/schema/index.liq new file mode 100644 index 0000000..07c0d31 --- /dev/null +++ b/src/api/schema/index.liq @@ -0,0 +1,5 @@ +let api.schema = {} + +%include "errors.liq" +%include "service.liq" +%include "controller.liq" diff --git a/src/api/schema/service.liq b/src/api/schema/service.liq new file mode 100644 index 0000000..39affee --- /dev/null +++ b/src/api/schema/service.liq @@ -0,0 +1,35 @@ +let api.schema.service = {} + +def api.schema.service.get = + source = "openapi/schema.yaml" + target = "openapi/schema.html" + + cached = ref("") + + def handle() = + current = file.contents(source) + + if + not file.exists(target) or current != cached() + then + if + process.test( + process.quote.command( + args=["build-docs", "--output", target, source], "redocly" + ) + ) + then + cached := current + else + error.raise( + api.schema.errors.generate, + "Failed to generate schema." + ) + end + end + + file.contents(target) + end + + handle +end diff --git a/src/config/index.liq b/src/config/index.liq index 34855f2..3f8c641 100644 --- a/src/config/index.liq +++ b/src/config/index.liq @@ -1,6 +1,6 @@ let config = {} %include "emitunes/index.liq" +%include "server/index.liq" %include "state/index.liq" %include "streamcast/index.liq" -%include "server.liq" diff --git a/src/config/server.liq b/src/config/server.liq deleted file mode 100644 index 5004e85..0000000 --- a/src/config/server.liq +++ /dev/null @@ -1,6 +0,0 @@ -let config.server = {} - -let config.server.host = - utils.env.safeget(default="0.0.0.0", "FUSION__SERVER__HOST") -let config.server.port = - utils.parse.int(utils.env.safeget(default="9000", "FUSION__SERVER__PORT")) diff --git a/src/config/server/http.liq b/src/config/server/http.liq new file mode 100644 index 0000000..200e7ad --- /dev/null +++ b/src/config/server/http.liq @@ -0,0 +1,6 @@ +let config.server.http = {} + +let config.server.http.port = + utils.parse.int( + utils.env.safeget(default="9001", "FUSION__SERVER__HTTP__PORT") + ) diff --git a/src/config/server/index.liq b/src/config/server/index.liq new file mode 100644 index 0000000..7151774 --- /dev/null +++ b/src/config/server/index.liq @@ -0,0 +1,4 @@ +let config.server = {} + +%include "http.liq" +%include "srt.liq" diff --git a/src/config/server/srt.liq b/src/config/server/srt.liq new file mode 100644 index 0000000..2305b62 --- /dev/null +++ b/src/config/server/srt.liq @@ -0,0 +1,6 @@ +let config.server.srt = {} + +let config.server.srt.port = + utils.parse.int( + utils.env.safeget(default="9000", "FUSION__SERVER__SRT__PORT") + ) diff --git a/src/inputs.liq b/src/inputs.liq index fe6c383..4d644b5 100644 --- a/src/inputs.liq +++ b/src/inputs.liq @@ -24,9 +24,6 @@ let inputs.playlist = let inputs.live = buffer( input.srt( - bind_address=config.server.host, - port=config.server.port, - read_timeout=null(), - write_timeout=null() + port=config.server.srt.port, read_timeout=null(), write_timeout=null() ) ) diff --git a/src/main.liq b/src/main.liq index 972a132..6fe4a58 100644 --- a/src/main.liq +++ b/src/main.liq @@ -5,3 +5,4 @@ %include "inputs.liq" %include "streams.liq" %include "outputs.liq" +%include "api/index.liq" diff --git a/src/state.liq b/src/state.liq index 771d979..ff7996e 100644 --- a/src/state.liq +++ b/src/state.liq @@ -1,6 +1,6 @@ def state = def serialize((value:{playlist: {id: string}?})) = - json.stringify(compact=true, value) + json.stringify(value) end def deserialize((value:string)) =