commit 2fa04fe86e61be03fc39313862a1ec366eb0757a Author: clonbg Date: Fri Jan 13 14:17:15 2023 +0100 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d828456 --- /dev/null +++ b/.gitignore @@ -0,0 +1,124 @@ +.vscode/ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# celery beat schedule file +celerybeat-schedule + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..a3cab0a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,27 @@ +FROM ubuntu:18.04 as builder + +ARG VERSION="3.1.49_ubuntu_18.04" +RUN apt-get update &&\ + apt-get install --no-install-recommends --yes wget && \ + apt-get clean && \ + rm --force --recursive /var/lib/apt/lists +RUN mkdir /opt/acestream && \ + wget --no-check-certificate -O- "https://download.acestream.media/linux/acestream_3.1.49_ubuntu_18.04_x86_64.tar.gz" | \ + tar -xz -C /opt/acestream + +# actual image +FROM ubuntu:18.04 +LABEL maintainer="Jack Liar " +RUN apt-get update --yes && \ + apt-get install --no-install-recommends --yes \ + apt-utils python-setuptools python-m2crypto python-apsw libpython2.7 libssl1.0.0 net-tools libxslt1.1 && \ + apt-get clean && \ + rm --force --recursive /var/lib/apt/lists +COPY --from=builder /opt/acestream /opt/acestream + +EXPOSE 6878 + +ENV ACESTREAM_ROOT="/opt/acestream" +ENV LD_LIBRARY_PATH="${ACESTREAM_ROOT}/lib" + +CMD ["/opt/acestream/acestreamengine", "--client-console"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..4f916a1 --- /dev/null +++ b/LICENSE @@ -0,0 +1,20 @@ +The MIT License (MIT) + +Copyright (c) 2019 Jack Liar(https://github.com/JackLiar) + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..58c5266 --- /dev/null +++ b/README.md @@ -0,0 +1,84 @@ +# Docker Ace Stream server +An [Ace Stream](http://www.acestream.org/) server Docker image & Python3 client to stream. +- [Overview](#overview) +- [Building](#building) +- [Usage](#usage) +- [Reference](#reference) + +## Overview +What this provides: +- Dockerized Ace Stream server (version `3.1.35`) running under Ubuntu 18.04. +- Bash script to start server and present HTTP API endpoint to host. +- Python playback script [`playstream.py`](playstream.py) instructing server to: + - Commence streaming of a given content ID. + - ...and optionally start a compatible media player (such as [iina(OS X only)](https://iina.io/)) to view stream. + +Since a single HTTP endpoint exposed from the Docker container controls the server _and_ provides the output stream, this provides one of the easier methods for playback of Ace Streams on traditionally unsupported operating systems such as OS X. + +## Building +To build Docker image: +```sh +$ ./build.sh +``` + +## Usage +Start the server via: +```sh +$ ./run.sh +``` + +Server will now be available from `http://127.0.0.1:6878`: +```sh +$ curl http://127.0.0.1:6878/webui/api/service?method=get_version&format=jsonp&callback= +# {"result": {"code": 3013500, "platform": "linux", "version": "3.1.35"}, "error": null} +``` + +A program ID can be started with [`playstream.py`](playstream.py): +```sh +$ ./playstream.py --help +usage: playstream.py [-h] --content-id HASH [--player PLAYER] + [--server HOSTNAME] [--port PORT] [--multi-players] [-d] + +Instructs server to commence a given content ID. Will execute a local media +player once playback has started. + +optional arguments: + -h, --help show this help message and exit + --content-id HASH content ID to stream + --player PLAYER media player to execute once stream active + --server HOSTNAME server hostname, defaults to 127.0.0.1 + --port PORT server HTTP API port, defaults to 6878 + --multi-players play stream in multiple players mode, defaults to False + -d, --debug run client in debug mode +``` + +For example, to stream `CONTENT_ID` and send playback to `iina` when ready: +```sh +$ ./playstream.py \ + --content-id CONTENT_ID \ + --player /usr/bin/vlc \ + +INFO 2019-05-12 18:45:52,190 playstream.py 47 Client starts. +INFO 2019-05-12 18:45:52,202 playstream.py 91 acestream engine version: 3.1.35 +INFO 2019-05-12 18:45:52,202 playstream.py 93 acestream engine version code: 3013500 +INFO 2019-05-12 18:45:52,202 playstream.py 188 Acestream server is available +Status: | Peers: 0 | Down: 0KB/s | Up: 0KB/s +Status: prebuf | Peers: 0 | Down: 0KB/s | Up: 0KB/s +Status: prebuf | Peers: 4 | Down: 24KB/s | Up: 0KB/s +Status: prebuf | Peers: 4 | Down: 540KB/s | Up: 0KB/s +Status: dl | Peers: 4 | Down: 887KB/s | Up: 0KB/s +Status: dl | Peers: 4 | Down: 957KB/s | Up: 0KB/s +Status: dl | Peers: 4 | Down: 887KB/s | Up: 0KB/s +Status: dl | Peers: 4 | Down: 870KB/s | Up: 1KB/s +Status: dl | Peers: 4 | Down: 828KB/s | Up: 2KB/s +INFO 2019-05-10 19:31:23,590 playstream3.py 57 Client exit. +``` + +Send Ctrl + C to exit. + +## Reference +- [Ace Stream Wiki (English)](http://wiki.acestream.org/wiki/index.php/%D0%97%D0%B0%D0%B3%D0%BB%D0%B0%D0%B2%D0%BD%D0%B0%D1%8F_%D1%81%D1%82%D1%80%D0%B0%D0%BD%D0%B8%D1%86%D0%B0/en). +- Binary downloads: http://wiki.acestream.org/wiki/index.php/Download. +- Ubuntu install notes: http://wiki.acestream.org/wiki/index.php/Install_Ubuntu. +- HTTP API usage: http://wiki.acestream.org/wiki/index.php/Engine_HTTP_API. +- `playstream.py` routines inspired by: https://github.com/magnetikonline/docker-acestream-server. diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..f4ce8a9 --- /dev/null +++ b/build.sh @@ -0,0 +1,9 @@ +#!/bin/bash -e + +DIRNAME=$(dirname "$0") +DOCKER_IMAGE_NAME="jackwzh/acestream-server" + +docker build \ + --tag "$DOCKER_IMAGE_NAME" \ + --force-rm \ + "$DIRNAME" diff --git a/playstream.py b/playstream.py new file mode 100755 index 0000000..265913c --- /dev/null +++ b/playstream.py @@ -0,0 +1,279 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +''' +# File: /playstream3.py +# Created Date: Monday April 29th 2019 +# ----- +# Last Modified: Sunday May 12th 2019 7:22:55 pm +''' + +from __future__ import absolute_import +from __future__ import division +from __future__ import print_function + +import argparse +import hashlib +import json +import logging +import re +import signal +import subprocess +import sys +import time +import urllib.request +from typing import Dict, Tuple + + +class Client(object): + + def __init__(self, server_host, server_port, multi_players=False): + self.server_host = server_host + self.server_port = server_port + + self.engine_version = "" + self.engine_version_code = 0 + + self.multi_players = multi_players + self.poll_time = 2 + + self.running = False + + def __enter__(self): + + def stop(sig_num, stack_frame): + self.running = False + + self.running = True + logging.info("Client starts.") + signal.signal(signal.SIGINT, stop) + return self + + def __exit__(self, exc_type=None, exc_value=None, traceback=None): + if any([exc_type, exc_value, traceback]): + logging.error(repr(exc_type)) + logging.error(repr(exc_value)) + logging.error(repr(traceback)) + logging.info("Client exit.") + return True + + def _api_request(self, url: str) -> Dict: + """Send request to acestream server and return response json dict + + Args: + url: api url + Returns: + dict: response json data + """ + response = urllib.request.urlopen(url) + return json.loads(response.read().decode()) + + def _check_server_availability(self) -> bool: + """Check server availability before start streaming + + Returns: + bool: wether server is avaliable + """ + url = "http://{}:{}/webui/api/service?method=get_version&format=jsonp&callback=".format( + self.server_host, self.server_port) + try: + response_dic = self._api_request(url) + except: + logging.exception("Check server availability failed!") + return False + else: + if response_dic.get("error"): + return False + self.engine_version = response_dic.get("result").get("version") + self.engine_version_code = int( + response_dic.get("result").get("code")) + + logging.info("acestream engine version: {}".format( + self.engine_version)) + logging.info("acestream engine version code: {}".format( + self.engine_version_code)) + + return True + + def start_streaming(self, content_id: str) -> Tuple[str, str]: + """Start streaming content ID + + Args: + content_id: acestream content ID + Returns: + playback_url: playback url for media player to stream + stat_url: stat url for client to get stat info of acestream engine + """ + if self.multi_players: + # generate a player id to support multi players playing + player_id = hashlib.sha1(content_id.encode()).hexdigest() + url = 'http://{}:{}/ace/getstream?format=json&id={}&pid={}'.format( + self.server_host, self.server_port, content_id, player_id) + else: + url = 'http://{}:{}/ace/getstream?format=json&id={}'.format( + self.server_host, self.server_port, content_id) + + try: + response_dic = self._api_request(url) + except: + logging.exception( + "Parsing server http response failed while starting streaming!" + ) + return "", "" + else: + playback_url = response_dic.get('response').get("playback_url") + stat_url = response_dic.get('response').get("stat_url") + return playback_url, stat_url + + def _start_media_player(self, media_player: str, + playback_url: str) -> bool: + """Start media to get stream from acestream server + + Args: + media_player: media player cli program name + playback_url: acestream server playback url + Return: + bool: whether media player stared successfully + """ + # change this if the predefined command is not suitable for your player + cmd = [media_player, playback_url] + + try: + process = subprocess.run(cmd) + process.check_returncode() + except subprocess.CalledProcessError: + logging.exception("{} didn't exit normally!".format(media_player)) + return False + return True + + def _monitor_stream_status(self, stat_url: str) -> None: + """Keep monitor stream stat status + + Args: + stat_url: acestream server stat url + """ + + def stream_stats_message(response: dict) -> str: + return 'Status: {} | Peers: {:>3} | Down: {:>4}KB/s | Up: {:>4}KB/s'.format( + response.get('response', { + 'status': 0 + }).get('status', ""), + response.get('response', { + 'peers': 0 + }).get('peers', 0), + response.get('response', { + 'speed_down': 0 + }).get('speed_down', 0), + response.get('response', { + 'speed_up': 0 + }).get('speed_up', 0)) + + while (self.running): + print(stream_stats_message(self._api_request(stat_url))) + + time.sleep(self.poll_time) + + def run(self, content_id: str, media_player: str) -> bool: + """A simplified api for running whole process easily + + Args: + content_id: acestream content ID + media_player: media player to play the stream + Returns: + bool: whether client run successfully + """ + if not self._check_server_availability(): + logging.error( + "Server is not available. Please check server status") + return False + logging.info("Acestream server is available") + + playback_url, stat_url = self.start_streaming(content_id) + if not playback_url or not stat_url: + return False + logging.debug("Server playback url: {}".format(playback_url)) + logging.debug("Server stat url: {}".format(stat_url)) + + if not self._start_media_player(media_player, playback_url): + return False + + self._monitor_stream_status(stat_url) + + +DEFAULT_SERVER_HOSTNAME = '127.0.0.1' +DEFAULT_SERVER_PORT = 6878 +DEFAULT_MEDIA_PLAYER = "iina" +SERVER_POLL_TIME = 2 +SERVER_STATUS_STREAM_ACTIVE = 'dl' +FORMAT = '%(levelname)s %(asctime)-15s %(filename)s %(lineno)-8s %(message)s' + + +def parse_args() -> argparse.Namespace: + """Parse comand line arguments + + Returns: + argparse.Namespace: command line args + """ + # create parser + parser = argparse.ArgumentParser( + description='Instructs server to commence a given content ID. ' + 'Will execute a local media player once playback has started.') + + parser.add_argument( + '--content-id', + help='content ID to stream', + metavar='HASH', + required=True, + ) + + parser.add_argument( + '--player', + help='media player to execute once stream active', + default=DEFAULT_MEDIA_PLAYER, + ) + + parser.add_argument( + '--server', + default=DEFAULT_SERVER_HOSTNAME, + help='server hostname, defaults to %(default)s', + metavar='HOSTNAME', + ) + + parser.add_argument( + '--port', + default=DEFAULT_SERVER_PORT, + help='server HTTP API port, defaults to %(default)s', + ) + + parser.add_argument( + '--multi-players', + action="store_true", + help='play stream in multiple players mode, defaults to %(default)s', + ) + + parser.add_argument( + '-d', + '--debug', + action="store_true", + help='run client in debug mode', + ) + + args = parser.parse_args() + + if not re.match(r'^[a-f0-9]{40}$', args.content_id): + # if content id is not a valid hash, quit program + logging.error('Invalid content ID: [{}]'.format(args.content_id)) + sys.exit(1) + + return args + + +if __name__ == "__main__": + args = parse_args() + + if args.debug: + logging.basicConfig(format=FORMAT, level=logging.DEBUG) + else: + logging.basicConfig(format=FORMAT, level=logging.INFO) + + with Client(args.server, args.port) as client: + client.run(args.content_id, args.player) diff --git a/run.sh b/run.sh new file mode 100755 index 0000000..2f723f6 --- /dev/null +++ b/run.sh @@ -0,0 +1,13 @@ +#!/bin/bash -e + +DOCKER_IMAGE_NAME="jackwzh/acestream-server" +SERVER_HTTP_PORT="6878" + +docker run \ + --name ace-server \ + --publish "$SERVER_HTTP_PORT:$SERVER_HTTP_PORT" \ + -p 8621:8621 \ + --rm \ + --mount type=tmpfs,target=/dev/disk/by-id,tmpfs-mode=660,tmpfs-size=4k \ + --mount type=tmpfs,target=/root/.ACEStream,tmpfs-mode=660,tmpfs-size=8192m \ + "$DOCKER_IMAGE_NAME"