├── .devcontainer ├── Dockerfile ├── devcontainer.json └── requirements.txt ├── .github └── workflows │ ├── build_and_test.yml │ └── publish_release.yml ├── .gitignore ├── .vscode └── settings.json ├── Dockerfile ├── README.md ├── announcer.py ├── config.py ├── main.py ├── pytest.ini ├── requirements.txt └── test_main.py /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3 2 | 3 | WORKDIR /plexannouncer 4 | 5 | COPY .devcontainer/requirements.txt . 6 | 7 | RUN pip install --no-cache-dir -r requirements.txt 8 | 9 | EXPOSE 32500/tcp -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | // For format details, see https://aka.ms/devcontainer.json. For config options, see the README at: 2 | // https://github.com/microsoft/vscode-dev-containers/tree/v0.224.3/containers/docker-existing-dockerfile 3 | { 4 | "name": "PlexAnnouncer", 5 | 6 | // Sets the run context to one level up instead of the .devcontainer folder. 7 | "context": "..", 8 | 9 | // Update the 'dockerFile' property if you aren't using the standard 'Dockerfile' filename. 10 | "dockerFile": "Dockerfile", 11 | 12 | // Set *default* container specific settings.json values on container create. 13 | "settings": {}, 14 | 15 | // Add the IDs of extensions you want installed when the container is created. 16 | "extensions": [ 17 | "ms-python.python" 18 | ] 19 | 20 | // Use 'forwardPorts' to make a list of ports inside the container available locally. 21 | // "forwardPorts": [], 22 | 23 | // Uncomment the next line to run commands after the container is created - for example installing curl. 24 | // "postCreateCommand": "apt-get update && apt-get install -y curl", 25 | 26 | // Uncomment when using a ptrace-based debugger like C++, Go, and Rust 27 | // "runArgs": [ "--cap-add=SYS_PTRACE", "--security-opt", "seccomp=unconfined" ], 28 | 29 | // Uncomment to use the Docker CLI from inside the container. See https://aka.ms/vscode-remote/samples/docker-from-docker. 30 | // "mounts": [ "source=/var/run/docker.sock,target=/var/run/docker.sock,type=bind" ], 31 | 32 | // Uncomment to connect as a non-root user if you've added one. See https://aka.ms/vscode-remote/containers/non-root. 33 | // "remoteUser": "vscode" 34 | } 35 | -------------------------------------------------------------------------------- /.devcontainer/requirements.txt: -------------------------------------------------------------------------------- 1 | discord.py 2 | aiohttp 3 | requests 4 | 5 | flake8 6 | black 7 | pytest 8 | pytest-aiohttp 9 | pillow -------------------------------------------------------------------------------- /.github/workflows/build_and_test.yml: -------------------------------------------------------------------------------- 1 | name: Build and Tests 2 | 3 | on: 4 | push: 5 | branches: 6 | - dev 7 | - main 8 | 9 | jobs: 10 | build: 11 | name: Build and test 12 | runs-on: ubuntu-latest 13 | strategy: 14 | matrix: 15 | python-version: ["3.6", "3.7", "3.8", "3.9"] 16 | env: 17 | DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }} 18 | steps: 19 | - name: Check out the repo 20 | uses: actions/checkout@v3 21 | 22 | - name: Set up Python ${{ matrix.python-version }} 23 | uses: actions/setup-python@v3 24 | with: 25 | python-version: ${{ matrix.python-version }} 26 | 27 | - name: Install dependencies 28 | run: | 29 | python -m pip install --upgrade pip 30 | pip install -r .devcontainer/requirements.txt 31 | 32 | - name: Lint code 33 | run: | 34 | flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics 35 | flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics 36 | 37 | - name: Test code 38 | run: | 39 | pytest 40 | 41 | prod-build: 42 | name: Build and run prod 43 | needs: build 44 | runs-on: ubuntu-latest 45 | strategy: 46 | matrix: 47 | python-version: ["3.6", "3.7", "3.8", "3.9"] 48 | env: 49 | DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }} 50 | PLEX_SERVER_URL: https://app.plex.tv/desktop/#!/server/1234123412341234123412341234123412341234/ 51 | PLEX_WEBHOOK_TOKEN: test 52 | steps: 53 | - name: Check out the repo 54 | uses: actions/checkout@v3 55 | 56 | - name: Set up Python ${{ matrix.python-version }} 57 | uses: actions/setup-python@v3 58 | with: 59 | python-version: ${{ matrix.python-version }} 60 | 61 | - name: Install dependencies 62 | run: | 63 | python -m pip install --upgrade pip 64 | pip install -r requirements.txt 65 | 66 | - name: Run and exit 67 | run: | 68 | timeout --preserve-status 8s python main.py 69 | 70 | publish-dev: 71 | name: Push dev image to Docker Hub 72 | needs: prod-build 73 | runs-on: ubuntu-latest 74 | steps: 75 | - name: Check out the repo 76 | uses: actions/checkout@v3 77 | 78 | - name: Log in to Docker Hub 79 | uses: docker/login-action@v1 80 | with: 81 | username: ${{ secrets.DOCKER_USERNAME }} 82 | password: ${{ secrets.DOCKER_PASSWORD }} 83 | 84 | - name: Build and push dev image 85 | uses: docker/build-push-action@v2 86 | with: 87 | context: . 88 | push: true 89 | tags: tenasi/plexannouncer:dev -------------------------------------------------------------------------------- /.github/workflows/publish_release.yml: -------------------------------------------------------------------------------- 1 | name: Publish Release 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | push_release_to_registry: 9 | name: Push release image to Docker Hub 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Check out the repo 13 | uses: actions/checkout@v3 14 | with: 15 | ref: dev 16 | 17 | - name: Log in to Docker Hub 18 | uses: docker/login-action@v2 19 | with: 20 | username: ${{ secrets.DOCKER_USERNAME }} 21 | password: ${{ secrets.DOCKER_PASSWORD }} 22 | 23 | - name: Extract metadata (tags, labels) for Docker 24 | id: meta 25 | uses: docker/metadata-action@v4 26 | with: 27 | images: tenasi/plexannouncer 28 | flavor: | 29 | latest=${{ !github.event.release.prerelease }} 30 | tags: | 31 | type=schedule 32 | type=ref,event=tag 33 | 34 | - name: Build and push release image 35 | uses: docker/build-push-action@v2 36 | with: 37 | context: . 38 | push: true 39 | tags: ${{ steps.meta.outputs.tags }} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | .pytest_cache -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "python.formatting.provider": "black", 4 | "python.linting.flake8Enabled": true, 5 | "python.linting.flake8Args": [ 6 | "--max-line-length=88" 7 | ], 8 | "files.exclude": { 9 | "**/__pycache__": true, 10 | "**/.pytest_cache": true 11 | }, 12 | "python.testing.pytestArgs": [ 13 | "." 14 | ], 15 | "python.testing.unittestEnabled": false, 16 | "python.testing.pytestEnabled": true 17 | } -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:alpine 2 | 3 | WORKDIR /plexannouncer 4 | 5 | COPY main.py announcer.py config.py requirements.txt . 6 | 7 | RUN pip install --no-cache-dir -r requirements.txt 8 | 9 | EXPOSE 32500/tcp 10 | 11 | CMD [ "python", "./main.py" ] -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Plex Announcer 2 | 3 | A Discord bot that sends updates about newly added Plex media to a Discord channel using webhooks. 4 | 5 | ## Getting Started 6 | 7 | To get started you first have to setup a webhook within your discord server / channel settings and copy the webhook url. 8 | 9 | For more information about how you create webhooks and what they are check [here](https://support.discord.com/hc/en-us/articles/228383668-Intro-to-Webhooks). 10 | 11 | ### Usage 12 | 13 | Note that the container bust me accessible from your plex host. If you running both of them on the same server just enter `localhost` instead. 14 | 15 | #### With Env Variables 16 | 17 | Run the container and specify your PLEX_SERVER_URL, PLEX_WEBHOOK_TOKEN and DISCORD_WEBHOOK_URL as environment variables: 18 | ```bash 19 | docker run -p 32500:32500 -e PLEX_SERVER_URL="https://app.plex.tv/desktop#!/server/SERVER_ID" -e PLEX_WEBHOOK_TOKEN="SOME_RANDOM_TOKEN" -e DISCORD_WEBHOOK_URL="https://discord.com/api/webhooks/WEBHOOK_ID/WEBHOOK_TOKEN" tenasi/plexannouncer:latest 20 | ``` 21 | 22 | #### With Config File 23 | 24 | First create a config file somewhere and insert your plex server url, your Discord webhook url and some random token. 25 | ```json 26 | { 27 | "plex_server_url": "https://app.plex.tv/desktop#!/server/SERVERID", 28 | "plex_webhook_token": "RANDOM_TOKEN", 29 | "discord_webhook_url": "https://discord.com/api/webhooks/WEBHOOK_ID/WEBHOOK_TOKEN" 30 | } 31 | ``` 32 | 33 | Then run the container and point to your config folder: 34 | ```bash 35 | docker run -v /path/to/config/dir:/config -p 32500:32500 tenasi/plexannouncer:latest 36 | ``` 37 | 38 | #### Register the Webhook in Plex 39 | 40 | Register the bot in Plex under Settings/Webhooks with the link being: 41 | 42 | ``` 43 | http://IP:PORT/RANDOM_TOKEN 44 | ``` 45 | 46 | ## Sources 47 | 48 | * [GitHub](https://github.com/tenasi/plexannouncer) 49 | -------------------------------------------------------------------------------- /announcer.py: -------------------------------------------------------------------------------- 1 | import discord 2 | import urllib.parse 3 | import io 4 | import datetime 5 | import logging 6 | 7 | log = logging.getLogger("announcer") 8 | 9 | 10 | class Announcer(object): 11 | def __init__(self, urls: list, plex: str) -> None: 12 | self.plex = plex 13 | self.webhooks = list() 14 | for url in urls: 15 | log.debug(f"Creating webhook for {url}") 16 | id, token = self._parse_url(url) 17 | webhook = self._create_webhook(id, token) 18 | self.webhooks.append(webhook) 19 | 20 | def _parse_url(self, url): 21 | return ( 22 | url.replace("https://discord.com/api/webhooks/", "") 23 | .replace("https://discordapp.com/api/webhooks/", "") 24 | .split("/") 25 | ) 26 | 27 | def _create_webhook(self, id, token): 28 | return discord.Webhook.partial( 29 | id, 30 | token, 31 | adapter=discord.RequestsWebhookAdapter(), 32 | ) 33 | 34 | def announce_movie(self, metadata, thumbnail): 35 | """Handle new movies added to plex""" 36 | log.debug("Announcing new movie") 37 | # setup thumbnail object 38 | thumbnail = discord.File(io.BytesIO(thumbnail), "cover.jpg") 39 | # read key for identifing movie and create link to plex 40 | key = urllib.parse.quote_plus(metadata["key"]) 41 | # build discord embed message 42 | embed = discord.Embed() 43 | embed.title = f"{metadata['title']}" 44 | embed.set_thumbnail(url="attachment://cover.jpg") 45 | # add custom fields 46 | if "summary" in metadata: 47 | embed.description = metadata["summary"] 48 | if "duration" in metadata: 49 | embed.add_field( 50 | name="Duration", 51 | value=str(datetime.timedelta(0, 0, 0, metadata["duration"])), 52 | ) 53 | if "year" in metadata: 54 | embed.add_field(name="Year", value=metadata["year"]) 55 | if "rating" in metadata: 56 | embed.add_field(name="Rating", value=metadata["rating"]) 57 | # set hyperlink to movie on plex 58 | embed.url = f"{self.plex}/details?key={key}" 59 | embed.color = 0xE5A00D 60 | # send embed message to discord 61 | for webhook in self.webhooks: 62 | webhook.send(embed=embed, file=thumbnail) 63 | 64 | def announce_show(self, metadata, thumbnail): 65 | """Handle new tv shows added to plex""" 66 | log.debug("Announcing new show") 67 | # setup thumbnail object 68 | thumbnail = discord.File(io.BytesIO(thumbnail), "cover.jpg") 69 | # read key for identifing show and create link to plex 70 | key = urllib.parse.quote_plus(metadata["key"]) 71 | key = key.replace("%2Fchildren", "") 72 | # build discord embed message 73 | embed = discord.Embed() 74 | embed.title = f"{metadata['title']}" 75 | embed.set_thumbnail(url="attachment://cover.jpg") 76 | # add custom fields 77 | if "summary" in metadata: 78 | embed.description = metadata["summary"] 79 | if "duration" in metadata: 80 | embed.add_field( 81 | name="Duration", 82 | value=str(datetime.timedelta(0, 0, 0, metadata["duration"])), 83 | ) 84 | if "year" in metadata: 85 | embed.add_field(name="Year", value=metadata["year"]) 86 | if "rating" in metadata: 87 | embed.add_field(name="Rating", value=metadata["rating"]) 88 | # set hyperlink to show on plex 89 | embed.url = f"{self.plex}/details?key={key}" 90 | embed.color = 0xE5A00D 91 | # send embed message to discord 92 | for webhook in self.webhooks: 93 | webhook.send(embed=embed, file=thumbnail) 94 | 95 | def announce_episode(self, metadata, thumbnail): 96 | """Handle new episodes added to plex""" 97 | log.debug("Announcing new episode") 98 | # setup thumbnail object 99 | thumbnail = discord.File(io.BytesIO(thumbnail), "cover.jpg") 100 | # read key for identifing show and create link to plex 101 | key = urllib.parse.quote_plus(metadata["key"]) 102 | key = key.replace("%2Fchildren", "") 103 | # build discord embed message 104 | embed = discord.Embed() 105 | embed.title = f"{metadata['grandparentTitle']}: {metadata['title']}" 106 | embed.set_thumbnail(url="attachment://cover.jpg") 107 | # add custom fields 108 | if "summary" in metadata: 109 | embed.description = metadata["summary"] 110 | if "index" in metadata and "parentIndex" in metadata: 111 | embed.add_field(name="Season", value=str(metadata["parentIndex"])) 112 | embed.add_field(name="Episode", value=str(metadata["index"])) 113 | if "duration" in metadata: 114 | embed.add_field( 115 | name="Duration", 116 | value=str(datetime.timedelta(0, 0, 0, metadata["duration"])), 117 | ) 118 | if "year" in metadata: 119 | embed.add_field(name="Year", value=metadata["year"]) 120 | if "rating" in metadata: 121 | embed.add_field(name="Rating", value=metadata["rating"]) 122 | # set hyperlink to show on plex 123 | embed.url = f"{self.plex}/details?key={key}" 124 | embed.color = 0xE5A00D 125 | # send embed message to discord 126 | for webhook in self.webhooks: 127 | webhook.send(embed=embed, file=thumbnail) 128 | 129 | def announce_track(self, metadata, thumbnail): 130 | """Handle new music tracks added to plex""" 131 | log.debug("Announcing new track") 132 | # setup thumbnail object 133 | thumbnail = discord.File(io.BytesIO(thumbnail), "cover.jpg") 134 | # read key for identifing track and create link to plex 135 | key = urllib.parse.quote_plus(metadata["key"]) 136 | # build discord embed message 137 | embed = discord.Embed() 138 | # TODO 139 | log.error("Tracks are not full< implemented yet") 140 | # set hyperlink to track on plex 141 | embed.url = f"{self.plex}/details?key={key}" 142 | embed.color = 0xE5A00D 143 | # send embed message to discord 144 | for webhook in self.webhooks: 145 | webhook.send(embed=embed, file=thumbnail) 146 | -------------------------------------------------------------------------------- /config.py: -------------------------------------------------------------------------------- 1 | import re 2 | import os 3 | 4 | 5 | class ConfigError(Exception): 6 | pass 7 | 8 | 9 | class Config: 10 | def __init__(self) -> None: 11 | self.config = dict(os.environ) 12 | 13 | def _get_key(self, key: str, default: str = None): 14 | if key in self.config: 15 | return self.config[key] 16 | elif key.upper() in self.config: 17 | return self.config[key.upper()] 18 | elif key.lower() in self.config: 19 | return self.config[key.lower()] 20 | elif default is not None: 21 | return default 22 | raise ConfigError(f"{key.upper()} not specified") 23 | 24 | def get_plex_webhook_token(self): 25 | token = self._get_key("plex_webhook_token") 26 | if re.fullmatch(r"[a-zA-Z0-9-_]*", token) is None: 27 | raise ConfigError("Invalid plex webhook token") 28 | return token 29 | 30 | def get_plex_server_url(self): 31 | url = self._get_key("plex_server_url") 32 | if re.search(r"\/desktop\/?#!\/(server|media)\/[a-zA-Z0-9]*\/?$", url) is None: 33 | raise ConfigError("Invalid plex server url") 34 | if url.endswith("/"): 35 | url = url[:-1] 36 | return url 37 | 38 | def get_discord_webhook_urls(self): 39 | urls = self._get_key( 40 | "discord_webhook_urls", self._get_key("discord_webhook_url", "") 41 | ).split(",") 42 | for url in urls: 43 | if ( 44 | re.fullmatch( 45 | r"https://discord(app)?\.com/api/webhooks/[0-9]*/[a-zA-Z0-9-_]*$", 46 | url, 47 | ) 48 | is None 49 | ): 50 | raise ConfigError(f"Invalid discord webhook url: {url}") 51 | if not urls: 52 | raise ConfigError("Please specify at least one discord webhook url") 53 | return urls 54 | 55 | def get_allowed_libraries(self): 56 | allowed_libraries = self._get_key("updated_libraries", "") 57 | return [lib.strip() for lib in allowed_libraries.split(",") if lib.strip()] 58 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | import os 2 | import logging 3 | from aiohttp import web, hdrs 4 | 5 | from config import Config, ConfigError 6 | from announcer import Announcer 7 | 8 | log = logging.getLogger("main") 9 | 10 | 11 | async def handle(request): 12 | """Handle inbound request for web server""" 13 | log.info("Inbound request") 14 | # discard all requests not of type multipart/form-data 15 | if not request.content_type == "multipart/form-data": 16 | log.info("Request rejected. Invalid content type, possibly not from plex.") 17 | return web.Response() 18 | 19 | # try reading attached thumbnail 20 | try: 21 | reader = await request.multipart() 22 | metadata = None 23 | thumbnail = None 24 | while True: 25 | part = await reader.next() 26 | if part is None: 27 | break 28 | if part.headers[hdrs.CONTENT_TYPE] == "application/json": 29 | metadata = await part.json() 30 | continue 31 | thumbnail = await part.read(decode=False) 32 | except Exception: 33 | log.info("Request rejected. Error reading thumbnail.") 34 | return web.Response(status=400) 35 | 36 | # try reading event type 37 | try: 38 | event = metadata["event"] 39 | except KeyError: 40 | log.info("Request rejected. No event type specified, possibly not from plex.") 41 | return web.Response() 42 | 43 | # check if event is library.new event and handle it accordingly 44 | if event == "library.new": 45 | try: 46 | handle_library_new(metadata["Metadata"], thumbnail) 47 | except Exception as e: 48 | log.error("Error handling library.new event.") 49 | log.exception(e) 50 | return web.Response(status=400) 51 | else: 52 | log.info(f"Request rejected. Event type of {event}.") 53 | 54 | return web.Response() 55 | 56 | 57 | def handle_library_new(metadata, thumbnail): 58 | """Check added type and call designated handler method""" 59 | log.debug(metadata) 60 | library = metadata["librarySectionTitle"] 61 | if ALLOWED_LIBRARIES: 62 | if library not in ALLOWED_LIBRARIES: 63 | log.info(f"Ignoring library.new event from library {library}") 64 | return 65 | ptype = metadata["type"] 66 | if ptype == "movie": 67 | log.info("Handling new movie announcement.") 68 | announcer.announce_movie(metadata, thumbnail) 69 | elif ptype == "show": 70 | log.info("Handling new show announcement.") 71 | announcer.announce_show(metadata, thumbnail) 72 | elif ptype == "episode": 73 | log.info("Handling new show announcement.") 74 | announcer.announce_episode(metadata, thumbnail) 75 | elif ptype == "track": 76 | log.info("Handling new track announcement.") 77 | announcer.announce_track(metadata, thumbnail) 78 | else: 79 | log.error(f"ERROR: Unknown type {ptype}") 80 | 81 | 82 | if __name__ == "__main__": 83 | logging.basicConfig( 84 | format="%(asctime)s %(levelname)-8s %(message)s", 85 | level=os.environ.get("LOGLEVEL", "INFO"), 86 | datefmt="%Y-%m-%d %H:%M:%S", 87 | ) 88 | log.debug("Logger initialized") 89 | 90 | try: 91 | config = Config() 92 | announcer = Announcer( 93 | config.get_discord_webhook_urls(), config.get_plex_server_url() 94 | ) 95 | ALLOWED_LIBRARIES = config.get_allowed_libraries() 96 | PLEX_WEBHOOK_TOKEN = config.get_plex_webhook_token() 97 | except ConfigError as e: 98 | log.critical(e, exc_info=True) 99 | exit(-1) 100 | 101 | # set default port as specified in Dockerfile 102 | port = "32500" 103 | # get actual port mapping from docker context 104 | if os.getenv("TCP_PORT_32500"): 105 | port = os.getenv("TCP_PORT_32500") 106 | # running web server and discord webhook client 107 | log.info(f"Plex webhook URL: http://localhost:{port}/{PLEX_WEBHOOK_TOKEN}") 108 | app = web.Application() 109 | app.add_routes([web.post(f"/{PLEX_WEBHOOK_TOKEN}", handle)]) 110 | web.run_app(app, port=32500) 111 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | minversion = 6.0 3 | asyncio_mode = auto 4 | log_cli = true 5 | log_cli_level = 20 -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | discord.py 2 | aiohttp 3 | requests -------------------------------------------------------------------------------- /test_main.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import io 3 | import json 4 | from aiohttp import web, FormData 5 | from PIL import Image 6 | 7 | import main 8 | 9 | 10 | def thumbnail(): 11 | b = io.BytesIO() 12 | img = Image.new(mode="RGB", size=(1000, 1500), color=(255, 0, 255)) 13 | img.save(b, "JPEG") 14 | b.seek(0) 15 | return b 16 | 17 | 18 | @pytest.fixture 19 | def cli(loop, aiohttp_client): 20 | app = web.Application() 21 | app.router.add_post("/", main.handle) 22 | return loop.run_until_complete(aiohttp_client(app)) 23 | 24 | 25 | async def test_get_request(cli): 26 | resp = await cli.post("/") 27 | assert resp.status == 200 28 | 29 | 30 | async def test_no_event(cli): 31 | form = FormData() 32 | form.add_field("metadata", "{}", content_type="application/json") 33 | form.add_field("thumbnail", thumbnail()) 34 | resp = await cli.post("/", data=form) 35 | assert resp.status == 200 36 | 37 | 38 | async def test_unknown_event(cli): 39 | form = FormData() 40 | data = dict(event="unknown") 41 | form.add_field("metadata", json.dumps(data), content_type="application/json") 42 | form.add_field("thumbnail", thumbnail()) 43 | resp = await cli.post("/", data=form) 44 | assert resp.status == 200 45 | 46 | 47 | async def test_unknown_type(cli): 48 | form = FormData() 49 | main.ALLOWED_LIBRARIES = "" 50 | meta = dict(type="unknown", librarySectionTitle="Movies") 51 | data = dict(event="library.new", Metadata=meta) 52 | form.add_field("metadata", json.dumps(data), content_type="application/json") 53 | form.add_field("thumbnail", thumbnail()) 54 | resp = await cli.post("/", data=form) 55 | assert resp.status == 200 56 | --------------------------------------------------------------------------------