Implemented GUI for syng #2

Merged
christofsteel merged 18 commits from gui into main 2023-12-18 19:48:28 +01:00
12 changed files with 484 additions and 163 deletions
Showing only changes of commit f339c35c5a - Show all commits

View file

@ -0,0 +1 @@
# Syng

View file

@ -1,5 +1,5 @@
JSON
====
.. automodule:: syng.json
.. automodule:: syng.jsonencoder
:members:

View file

@ -22,7 +22,9 @@ mutagen = "^1.46.0"
aiocmd = "^0.1.5"
pillow = "^9.3.0"
yt-dlp = "*"
pyqrcodeng = "^1.3.6"
customtkinter = "^5.2.1"
qrcode = "^7.4.2"
pymediainfo = "^6.1.0"
[build-system]
requires = ["poetry-core"]

View file

@ -44,17 +44,18 @@ from traceback import print_exc
from typing import Any
from typing import Optional
import pyqrcodeng as pyqrcode
import qrcode
import socketio
from PIL import Image
from . import json
from . import jsonencoder
from .entry import Entry
from .sources import configure_sources
from .sources import Source
sio: socketio.AsyncClient = socketio.AsyncClient(json=json)
sio: socketio.AsyncClient = socketio.AsyncClient(json=jsonencoder)
logger: logging.Logger = logging.getLogger(__name__)
sources: dict[str, Source] = {}
@ -87,15 +88,13 @@ class State:
:type waiting_room: list[Entry]
:param recent: A copy of all played songs this session.
:type recent: list[Entry]
:param room: The room on the server this playback client is connected to.
:type room: str
:param secret: The passcode of the room. If a playback client reconnects to
:param config: Various configuration options for the client:
* `server` (`str`): The url of the server to connect to.
* `room` (`str`): The room on the server this playback client is connected to.
* `secret` (`str`): The passcode of the room. If a playback client reconnects to
a room, this must be identical. Also, if a webclient wants to have
admin privileges, this must be included.
:type secret: str
:param key: An optional key, if registration on the server is limited.
:type key: Optional[str]
:param config: Various configuration options for the client:
* `key` (`Optional[str]`) An optional key, if registration on the server is limited.
* `preview_duration` (`Optional[int]`): The duration in seconds the
playback client shows a preview for the next song. This is accounted for
in the calculation of the ETA for songs later in the queue.
@ -116,10 +115,6 @@ class State:
queue: list[Entry] = field(default_factory=list)
waiting_room: list[Entry] = field(default_factory=list)
recent: list[Entry] = field(default_factory=list)
room: str = ""
server: str = ""
secret: str = ""
key: Optional[str] = None
config: dict[str, Any] = field(default_factory=default_config)
@ -200,12 +195,12 @@ async def handle_connect() -> None:
"queue": state.queue,
"waiting_room": state.waiting_room,
"recent": state.recent,
"room": state.room,
"secret": state.secret,
# "room": state.config["room"],
# "secret": state.config["secret"],
"config": state.config,
}
if state.key:
data["registration-key"] = state.key
if state.config["key"]:
data["registration-key"] = state.config["key"] # TODO: unify
await sio.emit("register-client", data)
@ -304,7 +299,7 @@ async def handle_play(data: dict[str, Any]) -> None:
@sio.on("client-registered")
async def handle_client_registered(data: dict[str, Any]) -> None:
"""
Handle the "client-registered" massage.
Handle the "client-registered" message.
If the registration was successfull (`data["success"]` == `True`), store
the room code in the global :py:class:`State` and print out a link to join
@ -325,9 +320,12 @@ async def handle_client_registered(data: dict[str, Any]) -> None:
"""
if data["success"]:
logging.info("Registered")
print(f"Join here: {state.server}/{data['room']}")
print(pyqrcode.create(f"{state.server}/{data['room']}").terminal(quiet_zone=1))
state.room = data["room"]
print(f"Join here: {state.config['server']}/{data['room']}")
qr = qrcode.QRCode(box_size=20, border=2)
qr.add_data(f"{state.config['server']}/{data['room']}")
qr.make()
qr.print_ascii()
state.config["room"] = data["room"]
await sio.emit("sources", {"sources": list(sources.keys())})
if state.current_source is None: # A possible race condition can occur here
await sio.emit("get-first")
@ -373,30 +371,14 @@ async def handle_request_config(data: dict[str, Any]) -> None:
await sio.emit("config", {"source": data["source"], "config": config})
async def aiomain() -> None:
async def start_client(config: dict[str, Any]) -> None:
"""
Async main function.
Parses the arguments, reads a config file and sets default values. Then
connects to a specified server.
If no secret is given, a random secret will be generated and presented to
the user.
Initialize the client and connect to the server.
:param config: Config options for the client
:type config: dict[str, Any]
:rtype: None
"""
parser: ArgumentParser = ArgumentParser()
parser.add_argument("--room", "-r")
parser.add_argument("--secret", "-s")
parser.add_argument("--config-file", "-C", default="syng-client.json")
parser.add_argument("--key", "-k", default=None)
parser.add_argument("server")
args = parser.parse_args()
with open(args.config_file, encoding="utf8") as file:
config = load(file)
sources.update(configure_sources(config["sources"]))
if "config" in config:
@ -407,28 +389,60 @@ async def aiomain() -> None:
)
state.config |= config["config"] | {"last_song": last_song}
state.key = args.key if args.key else None
if args.room:
state.room = args.room
if args.secret:
state.secret = args.secret
else:
state.secret = "".join(
if not ("secret" in state.config and state.config["secret"]):
state.config["secret"] = "".join(
secrets.choice(string.ascii_letters + string.digits) for _ in range(8)
)
print(f"Generated secret: {state.secret}")
print(f"Generated secret: {state.config['secret']}")
state.server = args.server
if not ("key" in state.config and state.config["key"]):
state.config["key"] = ""
await sio.connect(args.server)
await sio.connect(state.config["server"])
await sio.wait()
async def aiomain() -> None:
"""
Async main function.
Parses the arguments, reads a config file and sets default values. Then
connects to a specified server.
If no secret is given, a random secret will be generated and presented to
the user.
"""
pass
def main() -> None:
"""Entry point for the syng-client script."""
asyncio.run(aiomain())
parser: ArgumentParser = ArgumentParser()
parser.add_argument("--room", "-r")
parser.add_argument("--secret", "-s")
parser.add_argument("--config-file", "-C", default="syng-client.json")
parser.add_argument("--key", "-k", default=None)
parser.add_argument("--server", "-S")
args = parser.parse_args()
with open(args.config_file, encoding="utf8") as file:
config = load(file)
if "config" not in config:
config["config"] = {}
config["config"] |= {"key": args.key}
if args.room:
config["config"] |= {"room": args.room}
if args.secret:
config["config"] |= {"secret": args.secret}
if args.server:
config["config"] |= {"server": args.server}
asyncio.run(start_client(config))
if __name__ == "__main__":

243
syng/gui.py Normal file
View file

@ -0,0 +1,243 @@
import builtins
from functools import partial
from json import load
import customtkinter
import qrcode
import secrets
import string
from tkinter import filedialog
from syng.client import default_config
from .sources import available_sources
class SyngGui(customtkinter.CTk):
def loadConfig(self):
filedialog.askopenfilename()
def __init__(self):
super().__init__(className="Syng")
self.wm_title("Syng")
tabview = customtkinter.CTkTabview(self)
tabview.pack(side="top")
tabview.add("General")
for source in available_sources:
tabview.add(source)
tabview.set("General")
fileframe = customtkinter.CTkFrame(self)
fileframe.pack(side="bottom")
loadbutton = customtkinter.CTkButton(
fileframe,
text="load",
command=self.loadConfig,
)
loadbutton.pack(side="left")
frm = customtkinter.CTkFrame(tabview.tab("General"))
frm.grid(ipadx=10)
self.qrlabel = customtkinter.CTkLabel(frm, text="")
self.qrlabel.grid(column=0, row=0)
optionframe = customtkinter.CTkFrame(frm)
optionframe.grid(column=1, row=0)
customtkinter.CTkLabel(optionframe, text="Server", justify="left").grid(
column=0, row=0, padx=5, pady=5
)
self.serverTextbox = customtkinter.CTkTextbox(
optionframe, wrap="none", height=1
)
self.serverTextbox.grid(column=1, row=0)
self.serverTextbox.bind("<KeyRelease>", self.updateQr)
customtkinter.CTkLabel(optionframe, text="Room", justify="left").grid(
column=0, row=1
)
self.roomTextbox = customtkinter.CTkTextbox(optionframe, wrap="none", height=1)
self.roomTextbox.grid(column=1, row=1)
self.roomTextbox.bind("<KeyRelease>", self.updateQr)
customtkinter.CTkLabel(optionframe, text="Secret", justify="left").grid(
column=0, row=3
)
self.secretTextbox = customtkinter.CTkTextbox(
optionframe, wrap="none", height=1
)
secret = "".join(
secrets.choice(string.ascii_letters + string.digits) for _ in range(8)
)
self.secretTextbox.grid(column=1, row=3)
customtkinter.CTkLabel(
optionframe, text="Waiting room policy", justify="left"
).grid(column=0, row=4)
self.waitingRoomPolicy = customtkinter.CTkOptionMenu(
optionframe, values=["forced", "optional", "none"]
)
self.waitingRoomPolicy.set("none")
self.waitingRoomPolicy.grid(column=1, row=4)
customtkinter.CTkLabel(
optionframe, text="Time of last Song", justify="left"
).grid(column=0, row=5)
self.last_song = customtkinter.CTkTextbox(optionframe, wrap="none", height=1)
self.last_song.grid(column=1, row=5)
customtkinter.CTkLabel(
optionframe, text="Preview Duration", justify="left"
).grid(column=0, row=6)
self.preview_duration = customtkinter.CTkTextbox(
optionframe, wrap="none", height=1
)
self.preview_duration.grid(column=1, row=6)
customtkinter.CTkButton(optionframe, text="Start", command=self.start).grid(
column=0, row=7, columnspan=2, pady=10
)
with open("syng-client.json") as cfile:
loaded_config = load(cfile)
config = {"sources": {}, "config": {}}
self.source_config_elements = {}
for source_name, source in available_sources.items():
self.source_config_elements[source_name] = {}
config["sources"][source_name] = {}
sourcefrm = customtkinter.CTkFrame(tabview.tab(source_name))
sourcefrm.grid(ipadx=10)
for row, (name, (typ, desc, default)) in enumerate(
source.config_schema.items()
):
if name in loaded_config["sources"][source_name]:
config["sources"][source_name][name] = loaded_config["sources"][
source_name
][name]
else:
config["sources"][source_name][name] = default
label = customtkinter.CTkLabel(
sourcefrm, text=f"{desc} ({name})", justify="right"
)
label.grid(column=0, row=row)
match typ:
case builtins.bool:
self.source_config_elements[source_name][
name
] = customtkinter.CTkSwitch(sourcefrm, text="")
self.source_config_elements[source_name][name].grid(
column=1, row=row
)
if config["sources"][source_name][name]:
self.source_config_elements[source_name][name].select()
else:
self.source_config_elements[source_name][name].deselect()
case builtins.list:
self.source_config_elements[source_name][
name
] = customtkinter.CTkTextbox(sourcefrm, wrap="none", height=1)
self.source_config_elements[source_name][name].grid(
column=1, row=row
)
self.source_config_elements[source_name][name].insert(
"0.0", ",".join(config["sources"][source_name][name])
)
case _:
self.source_config_elements[source_name][
name
] = customtkinter.CTkTextbox(sourcefrm, wrap="none", height=1)
self.source_config_elements[source_name][name].grid(
column=1, row=row
)
self.source_config_elements[source_name][name].insert(
"0.0", config["sources"][source_name][name]
)
if source_name in loaded_config["sources"]:
config["sources"][source_name] |= loaded_config["sources"][source_name]
if "config" in loaded_config:
config["config"] = default_config() | loaded_config["config"]
self.serverTextbox.insert("0.0", config["config"]["server"])
self.roomTextbox.insert("0.0", config["config"]["room"])
self.secretTextbox.insert("0.0", config["config"]["secret"])
self.waitingRoomPolicy.set(str(config["config"]["waiting_room_policy"]).lower())
if config["config"]["last_song"]:
self.last_song.insert("0.0", config["config"]["last_song"])
self.preview_duration.insert("0.0", config["config"]["preview_duration"])
self.updateQr()
def start(self):
config = {}
config["server"] = self.serverTextbox.get("0.0", "end").strip()
config["room"] = self.roomTextbox.get("0.0", "end").strip()
config["secret"] = self.secretTextbox.get("0.0", "end").strip()
config["waiting_room_policy"] = self.waitingRoomPolicy.get().strip()
config["last_song"] = self.last_song.get("0.0", "end").strip()
try:
config["preview_duration"] = int(
self.preview_duration.get("0.0", "end").strip()
)
except ValueError:
config["preview_duration"] = 0
sources = {}
for source_name, config_elements in self.source_config_elements.items():
sources[source_name] = {}
for option, config_element in config_elements.items():
if isinstance(config_element, customtkinter.CTkSwitch):
sources[source_name][option] = (
True if config_element.get() == 1 else False
)
elif isinstance(config_element, customtkinter.CTkTextbox):
match available_sources[source_name].config_schema[option][0]:
case builtins.list:
sources[source_name][option] = [
value.strip()
for value in config_element.get("0.0", "end")
.strip()
.split(",")
]
case builtins.str:
sources[source_name][option] = config_element.get(
"0.0", "end"
).strip()
else:
raise RuntimeError("IDK")
syng_config = {"sources": sources, "config": config}
print(syng_config)
def changeQr(self, data: str):
qr = qrcode.QRCode(box_size=20, border=2)
qr.add_data(data)
qr.make()
qr.print_ascii()
image = qr.make_image().convert("RGB")
tkQrcode = customtkinter.CTkImage(light_image=image, size=(280, 280))
self.qrlabel.configure(image=tkQrcode)
def updateQr(self, _evt=None):
server = self.serverTextbox.get("0.0", "end").strip()
server += "" if server.endswith("/") else "/"
room = self.roomTextbox.get("0.0", "end").strip()
print(server + room)
self.changeQr(server + room)
def main():
SyngGui().mainloop()
if __name__ == "__main__":
main()

View file

@ -32,14 +32,14 @@ from typing import Optional
import socketio
from aiohttp import web
from . import json
from . import jsonencoder
from .entry import Entry
from .queue import Queue
from .sources import available_sources
from .sources import Source
sio = socketio.AsyncServer(
cors_allowed_origins="*", logger=True, engineio_logger=False, json=json
cors_allowed_origins="*", logger=True, engineio_logger=False, json=jsonencoder
)
app = web.Application()
sio.attach(app)
@ -127,7 +127,6 @@ class State:
:type client: Client
"""
secret: str
queue: Queue
waiting_room: list[Entry]
recent: list[Entry]
@ -329,7 +328,7 @@ async def handle_update_config(sid: str, data: dict[str, Any]) -> None:
if is_admin:
try:
config = json.loads(data["config"])
config = jsonencoder.loads(data["config"])
await sio.emit(
"update_config",
DEFAULT_CONFIG | config,
@ -707,13 +706,17 @@ async def handle_register_client(sid: str, data: dict[str, Any]) -> None:
)
return
room: str = data["room"] if "room" in data and data["room"] else gen_id()
room: str = (
data["config"]["room"]
if "room" in data["config"] and data["config"]["room"]
else gen_id()
)
async with sio.session(sid) as session:
session["room"] = room
if room in clients:
old_state: State = clients[room]
if data["secret"] == old_state.secret:
if data["config"]["secret"] == old_state.client.config["secret"]:
logger.info("Got new client connection for %s", room)
old_state.sid = sid
old_state.client = Client(
@ -738,7 +741,6 @@ async def handle_register_client(sid: str, data: dict[str, Any]) -> None:
initial_recent = [Entry(**entry) for entry in data["recent"]]
clients[room] = State(
secret=data["secret"],
queue=Queue(initial_entries),
waiting_room=initial_waiting_room,
recent=initial_recent,
@ -902,7 +904,7 @@ async def handle_register_admin(sid: str, data: dict[str, Any]) -> bool:
room = session["room"]
state = clients[room]
is_admin: bool = data["secret"] == state.secret
is_admin: bool = data["secret"] == state.client.config["secret"]
async with sio.session(sid) as session:
session["admin"] = is_admin
return is_admin

76
syng/sources/filebased.py Normal file
View file

@ -0,0 +1,76 @@
"""Module for an abstract filebased Source."""
import asyncio
import os
from typing import Any, Optional
from pymediainfo import MediaInfo
from .source import Source
class FileBasedSource(Source):
"""A source for indexing and playing songs from a local folder.
Config options are:
-``dir``, dirctory to index and server from.
"""
config_schema = Source.config_schema | {
"extensions": (
list,
"List of filename extensions (mp3+cdg, mp4, ...)",
"mp3+cdg",
)
}
def __init__(self, config: dict[str, Any]):
"""Initialize the file module."""
super().__init__(config)
self.extensions: list[str] = (
config["extensions"] if "extensions" in config else ["mp3+cdg"]
)
self.extra_mpv_arguments = ["--scale=oversample"]
def has_correct_extension(self, path: str) -> bool:
"""Check if a `path` has a correct extension.
For A+B type extensions (like mp3+cdg) only the latter halve is checked
:return: True iff path has correct extension.
:rtype: bool
"""
return os.path.splitext(path)[1][1:] in [
ext.split("+")[-1] for ext in self.extensions
]
def get_video_audio_split(self, path: str) -> tuple[str, Optional[str]]:
extension_of_path = os.path.splitext(path)[1][1:]
splitted_extensions = [ext.split("+") for ext in self.extensions if "+" in ext]
splitted_extensions_dict = {
video: audio for [audio, video] in splitted_extensions
}
if extension_of_path in splitted_extensions_dict:
audio_path = (
os.path.splitext(path)[0]
+ "."
+ splitted_extensions_dict[extension_of_path]
)
return (path, audio_path)
return (path, None)
async def get_duration(self, path: str) -> int:
def _get_duration(file: str) -> int:
print(file)
info: str | MediaInfo = MediaInfo.parse(file)
if isinstance(info, str):
return 180
return info.audio_tracks[0].to_data()["duration"] // 1000
video_path, audio_path = self.get_video_audio_split(path)
check_path = audio_path if audio_path is not None else video_path
duration = await asyncio.to_thread(_get_duration, check_path)
return duration

View file

@ -1,39 +1,41 @@
"""Module for the files Source."""
import asyncio
import os
from typing import Any
from typing import Any, Optional
from typing import Tuple
import mutagen
from ..entry import Entry
from .source import available_sources
from .source import Source
from .filebased import FileBasedSource
class FilesSource(Source):
class FilesSource(FileBasedSource):
"""A source for indexing and playing songs from a local folder.
Config options are:
-``dir``, dirctory to index and server from.
-``dir``, dirctory to index and serve from.
"""
source_name = "files"
config_schema = FileBasedSource.config_schema | {
"dir": (str, "Directory to index", ".")
}
def __init__(self, config: dict[str, Any]):
"""Initialize the file module."""
super().__init__(config)
self.source_name = "files"
self.dir = config["dir"] if "dir" in config else "."
self.extra_mpv_arguments = ["--scale=oversample"]
async def get_file_list(self) -> list[str]:
"""Collect all ``cdg`` files in ``dir``."""
"""Collect all files in ``dir``, that have the correct filename extension"""
def _get_file_list() -> list[str]:
file_list = []
for path, _, files in os.walk(self.dir):
for file in files:
if file.endswith(".cdg"):
if self.has_correct_extension(file):
file_list.append(os.path.join(path, file)[len(self.dir) :])
return file_list
@ -41,35 +43,27 @@ class FilesSource(Source):
async def get_missing_metadata(self, entry: Entry) -> dict[str, Any]:
"""
Return the duration for the mp3 file.
Return the duration for the entry file.
:param entry: The entry with the associated mp3 file
:param entry: An entry
:type entry: Entry
:return: A dictionary containing the duration in seconds in the
``duration`` key.
:rtype: dict[str, Any]
"""
def mutagen_wrapped(file: str) -> int:
meta_infos = mutagen.File(file).info
return int(meta_infos.length)
duration = await self.get_duration(os.path.join(self.dir, entry.ident))
audio_file_name: str = os.path.join(self.dir, entry.ident[:-3] + "mp3")
return {"duration": duration}
duration = await asyncio.to_thread(mutagen_wrapped, audio_file_name)
return {"duration": int(duration)}
async def do_buffer(self, entry: Entry) -> Tuple[str, str]:
async def do_buffer(self, entry: Entry) -> Tuple[str, Optional[str]]:
"""
No buffering needs to be done, since the files are already on disk.
We just return the cdg file name and the inferred mp3 file name
We just return the file names.
"""
video_file_name: str = os.path.join(self.dir, entry.ident)
audio_file_name: str = os.path.join(self.dir, entry.ident[:-3] + "mp3")
return video_file_name, audio_file_name
return self.get_video_audio_split(os.path.join(self.dir, entry.ident))
available_sources["files"] = FilesSource

View file

@ -12,15 +12,15 @@ from typing import cast
from typing import Optional
from typing import Tuple
import mutagen
from minio import Minio
from .filebased import FileBasedSource
from ..entry import Entry
from .source import available_sources
from .source import Source
class S3Source(Source):
class S3Source(FileBasedSource):
"""A source for playing songs from a s3 compatible storage.
Config options are:
@ -31,14 +31,21 @@ class S3Source(Source):
- ``index_file``: If the file does not exist, saves the paths of
files from the s3 instance to this file. If it exists, loads
the list of files from this file.
- ``extensions``: List of filename extensions. Index only files with these one
of these extensions (Default: ["cdg"])
"""
source_name = "s3"
config_schema = FileBasedSource.config_schema | {
"endpoint": (str, "Endpoint of the s3", ""),
"access_key": (str, "Access Key of the s3", ""),
"secret_key": (str, "Secret Key of the s3", ""),
"secure": (bool, "Use SSL", True),
"bucket": (str, "Bucket of the s3", ""),
"tmp_dir": (str, "Folder for temporary download", "/tmp/syng"),
}
def __init__(self, config: dict[str, Any]):
"""Create the source."""
super().__init__(config)
self.source_name = "s3"
if "endpoint" in config and "access_key" in config and "secret_key" in config:
self.minio: Minio = Minio(
@ -52,12 +59,6 @@ class S3Source(Source):
config["tmp_dir"] if "tmp_dir" in config else "/tmp/syng"
)
self.extensions = (
[f".{ext}" for ext in config["extensions"]]
if "extensions" in config
else [".cdg"]
)
self.index_file: Optional[str] = (
config["index_file"] if "index_file" in config else None
)
@ -83,7 +84,7 @@ class S3Source(Source):
file_list = [
obj.object_name
for obj in self.minio.list_objects(self.bucket, recursive=True)
if os.path.splitext(obj.object_name)[1] in self.extensions
if self.has_correct_extension(obj.object_name)
]
if self.index_file is not None and not os.path.isfile(self.index_file):
with open(self.index_file, "w", encoding="utf8") as index_file_handle:
@ -103,20 +104,13 @@ class S3Source(Source):
:rtype: dict[str, Any]
"""
def mutagen_wrapped(file: str) -> int:
meta_infos = mutagen.File(file).info
return int(meta_infos.length)
await self.ensure_playable(entry)
audio_file_name: Optional[str] = self.downloaded_files[entry.ident].audio
file_name: Optional[str] = self.downloaded_files[entry.ident].video
if audio_file_name is None:
duration: int = 180
else:
duration = await asyncio.to_thread(mutagen_wrapped, audio_file_name)
duration = await self.get_duration(file_name)
return {"duration": int(duration)}
return {"duration": duration}
async def do_buffer(self, entry: Entry) -> Tuple[str, Optional[str]]:
"""
@ -132,56 +126,31 @@ class S3Source(Source):
:rtype: Tuple[str, Optional[str]]
"""
if os.path.splitext(entry.ident)[1] == ".cdg":
cdg_filename: str = os.path.basename(entry.ident)
path_to_files: str = os.path.dirname(entry.ident)
cdg_path: str = os.path.join(path_to_files, cdg_filename)
target_file_cdg: str = os.path.join(self.tmp_dir, cdg_path)
ident_mp3: str = entry.ident[:-3] + "mp3"
target_file_mp3: str = target_file_cdg[:-3] + "mp3"
os.makedirs(os.path.dirname(target_file_cdg), exist_ok=True)
cdg_task: asyncio.Task[Any] = asyncio.create_task(
video_path, audio_path = self.get_video_audio_split(entry.ident)
video_dl_path: str = os.path.join(self.tmp_dir, video_path)
os.makedirs(os.path.dirname(video_dl_path), exist_ok=True)
video_dl_task: asyncio.Task[Any] = asyncio.create_task(
asyncio.to_thread(
self.minio.fget_object,
self.bucket,
entry.ident,
target_file_cdg,
self.minio.fget_object, self.bucket, entry.ident, video_dl_path
)
)
audio_task: asyncio.Task[Any] = asyncio.create_task(
if audio_path is not None:
audio_dl_path: Optional[str] = os.path.join(self.tmp_dir, audio_path)
audio_dl_task: asyncio.Task[Any] = asyncio.create_task(
asyncio.to_thread(
self.minio.fget_object,
self.bucket,
ident_mp3,
target_file_mp3,
self.minio.fget_object, self.bucket, audio_path, audio_dl_path
)
)
else:
audio_dl_path = None
audio_dl_task = asyncio.create_task(asyncio.sleep(0))
await cdg_task
await audio_task
return target_file_cdg, target_file_mp3
video_filename: str = os.path.basename(entry.ident)
path_to_file: str = os.path.dirname(entry.ident)
await video_dl_task
await audio_dl_task
video_path: str = os.path.join(path_to_file, video_filename)
target_file_video: str = os.path.join(self.tmp_dir, video_path)
os.makedirs(os.path.dirname(target_file_video), exist_ok=True)
video_task: asyncio.Task[Any] = asyncio.create_task(
asyncio.to_thread(
self.minio.fget_object,
self.bucket,
entry.ident,
target_file_video,
)
)
await video_task
return target_file_video, None
return video_dl_path, audio_dl_path
available_sources["s3"] = S3Source

View file

@ -19,6 +19,7 @@ from typing import Any
from typing import Optional
from typing import Tuple
from typing import Type
from abc import ABC, abstractmethod
from ..entry import Entry
from ..result import Result
@ -65,7 +66,7 @@ class DLFilesEntry:
buffer_task: Optional[asyncio.Task[Tuple[str, Optional[str]]]] = None
class Source:
class Source(ABC):
"""Parentclass for all sources.
A new source should subclass this, and at least implement
@ -103,6 +104,11 @@ class Source:
- ``source_name``, the string used to identify the source
"""
source_name: str = ""
config_schema: dict[str, tuple[type | list[type], str, Any]] = {
"enabled": (bool, "Enable this source", False)
}
def __init__(self, config: dict[str, Any]):
"""
Create and initialize a new source.
@ -114,7 +120,6 @@ class Source:
source for documentation.
:type config: dict[str, Any]
"""
self.source_name: str = ""
self.downloaded_files: defaultdict[str, DLFilesEntry] = defaultdict(
DLFilesEntry
)
@ -144,6 +149,8 @@ class Source:
[f"--audio-file={audio}"] if audio else []
)
print(f"File is {video=} and {audio=}")
mpv_process = asyncio.create_subprocess_exec(
"mpv",
*args,
@ -207,6 +214,7 @@ class Source:
results.append(result)
return results
@abstractmethod
async def do_buffer(self, entry: Entry) -> Tuple[str, Optional[str]]:
"""
Source specific part of buffering.
@ -223,7 +231,7 @@ class Source:
:returns: A Tuple of the locations for the video and the audio file.
:rtype: Tuple[str, Optional[str]]
"""
raise NotImplementedError
...
async def buffer(self, entry: Entry) -> None:
"""
@ -399,6 +407,7 @@ class Source:
:return: The part of the config, that should be sended to the server.
:rtype: dict[str, Any] | list[dict[str, Any]]
"""
print("xzy")
if not self._index:
self._index = []
print(f"{self.source_name}: generating index")

View file

@ -50,12 +50,23 @@ class YoutubeSource(Source):
``yt-dlp``. Default is False.
"""
source_name = "youtube"
config_schema = Source.config_schema | {
"channels": (list, "A list channels to search in", []),
"tmp_dir": (str, "Folder for temporary download", "/tmp/syng"),
"max_res": (int, "Maximum resolution to download", 720),
"start_streaming": (
bool,
"Start streaming if download is not complete",
False,
),
}
# pylint: disable=too-many-instance-attributes
def __init__(self, config: dict[str, Any]):
"""Create the source."""
super().__init__(config)
self.source_name = "youtube"
self.innertube_client: innertube.InnerTube = innertube.InnerTube(client="WEB")
self.channels: list[str] = config["channels"] if "channels" in config else []