diff --git a/.gitignore b/.gitignore index 82f9275..55497e0 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,8 @@ __pycache__/ *.py[cod] *$py.class +logs/ +.vscode/ # C extensions *.so diff --git a/comet/logger.py b/comet/logger.py new file mode 100644 index 0000000..17f9796 --- /dev/null +++ b/comet/logger.py @@ -0,0 +1,68 @@ +import os +import sys +from datetime import datetime +from pathlib import Path + +from loguru import logger + + +def setup_logger(level): + """Setup the logger""" + logs_dir_path = "logs" + os.makedirs(logs_dir_path, exist_ok=True) + timestamp = datetime.now().strftime("%Y%m%d-%H%M") + log_filename = Path(logs_dir_path) / f"comet-{timestamp}.log" + + logger.level("PROGRAM", no=36, color="", icon="☄️ ") + logger.level("DEBRID", no=38, color="", icon="👻") + logger.level("SCRAPER", no=40, color="", icon="🌠") + + # set the default info and debug level icons + logger.level("INFO", icon="📰", color="") + logger.level("DEBUG", icon="🕸️", color="") + logger.level("WARNING", icon="⚠️ ", color="") + + # Log format to match the old log format, but with color + log_format = ( + "{time:YYYY-MM-DD} {time:HH:mm:ss} | " + "{level.icon} {level: <9} | " + "{module}.{function} - {message}" + ) + + logger.configure(handlers=[ + { + "sink": sys.stderr, + "level": "DEBUG", + "format": log_format, + "backtrace": False, + "diagnose": False, + "enqueue": True, + }, + { + "sink": log_filename, + "level": level, + "format": log_format, + "rotation": "1 hour", + "retention": "1 hour", + "compression": None, + "backtrace": False, + "diagnose": True, + "enqueue": True, + }, + ]) + + +def clean_old_logs(): + """Remove old log files based on retention settings.""" + try: + logs_dir_path = Path("logs") + for log_file in logs_dir_path.glob("comet-*.log"): + # remove files older than 1 hour so that logs dont get messy + if (datetime.now() - datetime.fromtimestamp(log_file.stat().st_mtime)).total_seconds() / 3600 > 1: + log_file.unlink() + logger.log("PROGRAM", f"Old log file {log_file.name} removed.") + except Exception as e: + logger.error(f"Failed to clean old logs: {e}") + +setup_logger("DEBUG") +clean_old_logs() diff --git a/comet/main.py b/comet/main.py index 5391b0c..6892d52 100644 --- a/comet/main.py +++ b/comet/main.py @@ -1,14 +1,25 @@ -import aiohttp, asyncio, bencodepy, hashlib, re, base64, json, dotenv, os -from fastapi import FastAPI, Request +import asyncio +import base64 +import hashlib +import json +import os +import re +from typing import Optional + +import aiohttp +import bencodepy +from fastapi import FastAPI, HTTPException, Request from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import RedirectResponse +from pydantic import ValidationError +from RTN import parse -dotenv.load_dotenv() +from comet.logger import logger +from comet.models import ConfigModel, settings, video_extensions downloadLinks = {} # temporary before sqlite cache db is implemented -app = FastAPI(docs_url=None) - +app = FastAPI() app.add_middleware( CORSMiddleware, allow_origins=["*"], @@ -17,31 +28,29 @@ app.add_middleware( allow_headers=["*"], ) + def configChecking(b64config: str): try: - config = json.loads(base64.b64decode(b64config).decode()) + decoded_config = base64.b64decode(b64config).decode() + config = json.loads(decoded_config) + logger.info("Configuration decoded successfully") - if config["debridService"] not in ["realdebrid"]: - return False - - if not isinstance(config["debridApiKey"], str): - return False - - if not isinstance(config["indexers"], list): - return False - - if not isinstance(config["maxResults"], (int)): - return False - - return config - except: + validated_config = ConfigModel(**config) + logger.debug(validated_config) + logger.info("Configuration validated successfully") + return validated_config.model_dump() + except ValidationError as e: + logger.error("Validation error in configChecking: %s", str(e)) + return False + except Exception as e: + logger.error("Error in configChecking: %s", str(e)) return False @app.get("/manifest.json") -@app.get("/{b64config}/manifest.json") -async def manifest(b64config: str): - if not configChecking(b64config): - return +@app.get("/{config_str}/manifest.json") +async def manifest(request: Request, config_str: str = None): + if config_str and not configChecking(config_str): + raise HTTPException(status_code=400, detail="Invalid configuration") return { "id": "stremio.comet.fast", @@ -67,12 +76,12 @@ async def manifest(b64config: str): } async def getJackett(session: aiohttp.ClientSession, indexers: list, query: str): - response = await session.get(f"{os.getenv('JACKETT_URL')}/api/v2.0/indexers/all/results?apikey={os.getenv('JACKETT_KEY')}&Query={query}&Tracker[]={'&Tracker[]='.join(indexer for indexer in indexers)}") + response = await session.get(f"{settings.JACKETT_URL}/api/v2.0/indexers/all/results?apikey={settings.JACKETT_KEY}&Query={query}&Tracker[]={'&Tracker[]='.join(indexer for indexer in indexers)}") return response async def getTorrentHash(session: aiohttp.ClientSession, url: str): try: - timeout = aiohttp.ClientTimeout(total=int(os.getenv("GET_TORRENT_TIMEOUT"))) + timeout = aiohttp.ClientTimeout(total=settings.GET_TORRENT_TIMEOUT) response = await session.get(url, allow_redirects=False, timeout=timeout) if response.status == 200: torrentData = await response.read() @@ -87,217 +96,242 @@ async def getTorrentHash(session: aiohttp.ClientSession, url: str): match = re.search("btih:([a-zA-Z0-9]+)", location) if not match: return - + hash = match.group(1).upper() return hash - except: - pass + except Exception as e: + logger.error(f"Failed to get torrent hash: {e}") @app.get("/stream/{type}/{id}.json") -@app.get("/{b64config}/stream/{type}/{id}.json") -async def stream(request: Request, b64config: str, type: str, id: str): - config = configChecking(b64config) - if not config: - return +@app.get("/{config_str}/stream/{type}/{id}.json") +async def stream(request: Request, type: str, id: str, config_str: str = None): + if config_str: + config = configChecking(config_str) + if not config: + raise HTTPException(status_code=400, detail="Invalid configuration") + else: + config = None # Handle the case where config_str is not provided async with aiohttp.ClientSession() as session: - checkDebrid = await session.get("https://api.real-debrid.com/rest/1.0/user", headers={ - "Authorization": f"Bearer {config['debridApiKey']}" - }) - checkDebrid = await checkDebrid.text() - if not '"type": "premium"' in checkDebrid: - return { - "streams": [ - { - "name": f"[⚠️] Comet", - "title": f"Invalid Real-Debrid account.", - "url": f"https://comet.fast" - } - ] + if config and not await is_premium_account(session, config.debridApiKey): + return invalid_account_response() + + if type == "series": + id, season, episode = parse_series_id(id) + + name = await get_metadata_name(session, id) + name = translate_name(name) + + logger.debug(f"Start of Jackett search for {name}") + + if type == "series": + torrents = await search_torrents(session, config.indexers if config else [], name, type, season, episode) + else: + torrents = await search_torrents(session, config.indexers if config else [], name, type) + logger.debug(f"{len(torrents)} torrents found for {name}") + + if not torrents: + return {"streams": []} + + torrentHashes = await get_torrent_hashes(session, torrents) + logger.debug(f"{len(torrentHashes)} info hashes found for {name}") + + if not torrentHashes: + return {"streams": []} + + files = await get_available_files(session, config.debridApiKey if config else "", torrentHashes, type, season, episode) + logger.debug(f"{len(files)} cached files found on Real-Debrid for {name}") + + if not files: + return {"streams": []} + + results = generate_stream_results(files, config.maxResults if config else 0, request.url, config_str) + return {"streams": results} + +async def is_premium_account(session, debridApiKey: str) -> bool: + checkDebrid = await session.get("https://api.real-debrid.com/rest/1.0/user", headers={ + "Authorization": f"Bearer {debridApiKey}" + }) + checkDebrid = await checkDebrid.text() + return '"type": "premium"' in checkDebrid + +def invalid_account_response(): + return { + "streams": [ + { + "name": f"[⚠️] Comet", + "title": f"Invalid Real-Debrid account.", + "url": f"https://comet.fast" } + ] + } + +def parse_series_id(id: str): + info = id.split(":") + return info[0], info[1].zfill(2), info[2].zfill(2) + +async def get_metadata_name(session, id: str) -> str: + getMetadata = await session.get(f"https://v3.sg.media-imdb.com/suggestion/a/{id}.json") + metadata = await getMetadata.json() + return metadata["d"][0]["l"] + +def translate_name(name: str) -> str: + toChange = { + 'ā': 'a', 'ă': 'a', 'ą': 'a', 'ć': 'c', 'č': 'c', 'ç': 'c', + 'ĉ': 'c', 'ċ': 'c', 'ď': 'd', 'đ': 'd', 'è': 'e', 'é': 'e', + 'ê': 'e', 'ë': 'e', 'ē': 'e', 'ĕ': 'e', 'ę': 'e', 'ě': 'e', + 'ĝ': 'g', 'ğ': 'g', 'ġ': 'g', 'ģ': 'g', 'ĥ': 'h', 'î': 'i', + 'ï': 'i', 'ì': 'i', 'í': 'i', 'ī': 'i', 'ĩ': 'i', 'ĭ': 'i', + 'ı': 'i', 'ĵ': 'j', 'ķ': 'k', 'ĺ': 'l', 'ļ': 'l', 'ł': 'l', + 'ń': 'n', 'ň': 'n', 'ñ': 'n', 'ņ': 'n', 'ʼn': 'n', 'ó': 'o', + 'ô': 'o', 'õ': 'o', 'ö': 'o', 'ø': 'o', 'ō': 'o', 'ő': 'o', + 'œ': 'oe', 'ŕ': 'r', 'ř': 'r', 'ŗ': 'r', 'š': 's', 'ş': 's', + 'ś': 's', 'ș': 's', 'ß': 'ss', 'ť': 't', 'ţ': 't', 'ū': 'u', + 'ŭ': 'u', 'ũ': 'u', 'û': 'u', 'ü': 'u', 'ù': 'u', 'ú': 'u', + 'ų': 'u', 'ű': 'u', 'ŵ': 'w', 'ý': 'y', 'ÿ': 'y', 'ŷ': 'y', + 'ž': 'z', 'ż': 'z', 'ź': 'z', 'æ': 'ae', 'ǎ': 'a', 'ǧ': 'g', + 'ə': 'e', 'ƒ': 'f', 'ǐ': 'i', 'ǒ': 'o', 'ǔ': 'u', 'ǚ': 'u', + 'ǜ': 'u', 'ǹ': 'n', 'ǻ': 'a', 'ǽ': 'ae', 'ǿ': 'o', + } + translationTable = str.maketrans(toChange) + return name.translate(translationTable) + +async def search_torrents(session, indexers: list, name: str, type: str, season: str = None, episode: str = None): + tasks = [getJackett(session, indexers, name)] + if type == "series": + tasks.append(getJackett(session, indexers, f"{name} S{season}E{episode}")) + jackettSearchResponses = await asyncio.gather(*tasks) + + torrents = [] + for response in jackettSearchResponses: + results = await response.json() + for i in results["Results"]: + torrents.append(i) + return torrents + +async def get_torrent_hashes(session, torrents: list): + tasks = [getTorrentHash(session, torrent["Link"]) for torrent in torrents] + torrentHashes = await asyncio.gather(*tasks) + return list(set([hash for hash in torrentHashes if hash])) + +async def get_available_files(session, debridApiKey: str, torrentHashes: list, type: str, season: str = None, episode: str = None): + getAvailability = await session.get(f"https://api.real-debrid.com/rest/1.0/torrents/instantAvailability/{'/'.join(torrentHashes)}", headers={ + "Authorization": f"Bearer {debridApiKey}" + }) + + files = {} + strictFiles = {} + + availability = await getAvailability.json() + for hash, details in availability.items(): + if not "rd" in details: + continue if type == "series": - info = id.split(":") - - id = info[0] - season = info[1].zfill(2) - episode = info[2].zfill(2) - - getMetadata = await session.get(f"https://v3.sg.media-imdb.com/suggestion/a/{id}.json") - metadata = await getMetadata.json() - - name = metadata["d"][0]["l"] - toChange = { - 'ā': 'a', 'ă': 'a', 'ą': 'a', 'ć': 'c', 'č': 'c', 'ç': 'c', - 'ĉ': 'c', 'ċ': 'c', 'ď': 'd', 'đ': 'd', 'è': 'e', 'é': 'e', - 'ê': 'e', 'ë': 'e', 'ē': 'e', 'ĕ': 'e', 'ę': 'e', 'ě': 'e', - 'ĝ': 'g', 'ğ': 'g', 'ġ': 'g', 'ģ': 'g', 'ĥ': 'h', 'î': 'i', - 'ï': 'i', 'ì': 'i', 'í': 'i', 'ī': 'i', 'ĩ': 'i', 'ĭ': 'i', - 'ı': 'i', 'ĵ': 'j', 'ķ': 'k', 'ĺ': 'l', 'ļ': 'l', 'ł': 'l', - 'ń': 'n', 'ň': 'n', 'ñ': 'n', 'ņ': 'n', 'ʼn': 'n', 'ó': 'o', - 'ô': 'o', 'õ': 'o', 'ö': 'o', 'ø': 'o', 'ō': 'o', 'ő': 'o', - 'œ': 'oe', 'ŕ': 'r', 'ř': 'r', 'ŗ': 'r', 'š': 's', 'ş': 's', - 'ś': 's', 'ș': 's', 'ß': 'ss', 'ť': 't', 'ţ': 't', 'ū': 'u', - 'ŭ': 'u', 'ũ': 'u', 'û': 'u', 'ü': 'u', 'ù': 'u', 'ú': 'u', - 'ų': 'u', 'ű': 'u', 'ŵ': 'w', 'ý': 'y', 'ÿ': 'y', 'ŷ': 'y', - 'ž': 'z', 'ż': 'z', 'ź': 'z', 'æ': 'ae', 'ǎ': 'a', 'ǧ': 'g', - 'ə': 'e', 'ƒ': 'f', 'ǐ': 'i', 'ǒ': 'o', 'ǔ': 'u', 'ǚ': 'u', - 'ǜ': 'u', 'ǹ': 'n', 'ǻ': 'a', 'ǽ': 'ae', 'ǿ': 'o', - } - translationTable = str.maketrans(toChange) - name = name.translate(translationTable) - - print(f"Start of Jackett search for {name}") - - tasks = [] - tasks.append(getJackett(session, config["indexers"], name)) - if type == "series": - tasks.append(getJackett(session, config["indexers"], f"{name} S{season}E{episode}")) - jackettSearchResponses = await asyncio.gather(*tasks) - - torrents = [] - for response in jackettSearchResponses: - results = await response.json() - for i in results["Results"]: - torrents.append(i) - - print(f"{len(torrents)} torrents found for {name}") - - if len(torrents) == 0: - return {"streams": []} - - tasks = [] - for torrent in torrents: - tasks.append(getTorrentHash(session, torrent["Link"])) - torrentHashes = await asyncio.gather(*tasks) - torrentHashes = list(set([hash for hash in torrentHashes if hash])) - - print(f"{len(torrentHashes)} info hashes found for {name}") - - if len(torrentHashes) == 0: - return {"streams": []} - - getAvailability = await session.get(f"https://api.real-debrid.com/rest/1.0/torrents/instantAvailability/{'/'.join(torrentHashes)}", headers={ - "Authorization": f"Bearer {config['debridApiKey']}" - }) - - files = {} - strictFiles = {} - - availability = await getAvailability.json() - for hash, details in availability.items(): - if not "rd" in details: - continue - - if type == "series": - for variants in details["rd"]: - for index, file in variants.items(): - filename = file["filename"].lower() - - if not filename.endswith(tuple([".mkv", ".mp4", ".avi", ".mov", ".flv", ".wmv", ".webm", ".mpg", ".mpeg", ".m4v", ".3gp", ".3g2", ".ogv", ".ogg", ".drc", ".gif", ".gifv", ".mng", ".avi", ".mov", ".qt", ".wmv", ".yuv", ".rm", ".rmvb", ".asf", ".amv", ".m4p", ".m4v", ".mpg", ".mp2", ".mpeg", ".mpe", ".mpv", ".mpg", ".mpeg", ".m2v", ".m4v", ".svi", ".3gp", ".3g2", ".mxf", ".roq", ".nsv", ".flv", ".f4v", ".f4p", ".f4a", ".f4b"])): - continue - - if season in filename and episode in filename: - files[hash] = { - "index": index, - "title": file["filename"], - "size": file["filesize"] - } - - if f"s{season}" in filename and f"e{episode}" in filename: - strictFiles[hash] = { - "index": index, - "title": file["filename"], - "size": file["filesize"] - } - - continue - for variants in details["rd"]: for index, file in variants.items(): filename = file["filename"].lower() - if not filename.endswith(tuple([".mkv", ".mp4", ".avi", ".mov", ".flv", ".wmv", ".webm", ".mpg", ".mpeg", ".m4v", ".3gp", ".3g2", ".ogv", ".ogg", ".drc", ".gif", ".gifv", ".mng", ".avi", ".mov", ".qt", ".wmv", ".yuv", ".rm", ".rmvb", ".asf", ".amv", ".m4p", ".m4v", ".mpg", ".mp2", ".mpeg", ".mpe", ".mpv", ".mpg", ".mpeg", ".m2v", ".m4v", ".svi", ".3gp", ".3g2", ".mxf", ".roq", ".nsv", ".flv", ".f4v", ".f4p", ".f4a", ".f4b"])): + + if not filename.endswith(video_extensions): continue - files[hash] = { - "index": index, - "title": file["filename"], - "size": file["filesize"] - } + if season in filename and episode in filename: + files[hash] = { + "index": index, + "title": file["filename"], + "size": file["filesize"] + } - if len(strictFiles) > 0: - files = strictFiles + if f"s{season}" in filename and f"e{episode}" in filename: + strictFiles[hash] = { + "index": index, + "title": file["filename"], + "size": file["filesize"] + } - print(f"{len(files)} cached files found on Real-Debrid for {name}") + continue - if len(files) == 0: - return {"streams": []} + for variants in details["rd"]: + for index, file in variants.items(): + filename = file["filename"].lower() + if not filename.endswith(video_extensions): + continue + + files[hash] = { + "index": index, + "title": file["filename"], + "size": file["filesize"] + } + + if len(strictFiles) > 0: + files = strictFiles + + return files + +def generate_stream_results(files, maxResults, url, config_str): + files = dict(sorted(files.items(), key=lambda item: item[1]["size"], reverse=True)) + + filesByResolution = {"4k": [], "1080p": [], "720p": [], "480p": [], "Unknown": []} + for key, value in files.items(): + qualityPattern = ( + (r"\b(2160P|UHD|4K)\b", "4k"), + (r"\b(1080P|FHD|FULLHD|HD|HIGHDEFINITION)\b", "1080p"), + (r"\b(720P|HD|HIGHDEFINITION)\b", "720p"), + (r"\b(480P|SD|STANDARDDEFINITION)\b", "480p"), + ) + + resolution = "Unknown" + for pattern, quality in qualityPattern: + if re.search(pattern, value["title"], re.IGNORECASE): + resolution = quality - files = dict(sorted(files.items(), key=lambda item: item[1]["size"], reverse=True)) + filesByResolution[resolution].append({ + key: value + }) - filesByResolution = {"4k": [], "1080p": [], "720p": [], "480p": [], "Unknown": []} - for key, value in files.items(): - qualityPattern = ( - (r"\b(2160P|UHD|4K)\b", "4k"), - (r"\b(1080P|FHD|FULLHD|HD|HIGHDEFINITION)\b", "1080p"), - (r"\b(720P|HD|HIGHDEFINITION)\b", "720p"), - (r"\b(480P|SD|STANDARDDEFINITION)\b", "480p"), - ) + hashCount = 0 + for quality in filesByResolution: + hashCount += len(filesByResolution[quality]) - resolution = "Unknown" - for pattern, quality in qualityPattern: - if re.search(pattern, value["title"], re.IGNORECASE): - resolution = quality - - filesByResolution[resolution].append({ - key: value - }) + results = [] + if hashCount <= maxResults or maxResults == 0: + for quality, files in filesByResolution.items(): + for file in files: + for hash in file: + results.append({ + "name": f"[RD⚡] Comet {quality}", + "title": f"{file[hash]['title']}\n💾 {round(int(file[hash]['size']) / 1024 / 1024 / 1024, 2)}GB", + "url": f"{url.scheme}://{url.netloc}/{config_str}/playback/{hash}/{file[hash]['index']}" if config_str else f"{url.scheme}://{url.netloc}/playback/{hash}/{file[hash]['index']}" + }) + else: + selectedFiles = [] + resolutionCount = {res: 0 for res in filesByResolution.keys()} + resolutions = list(filesByResolution.keys()) + + while len(selectedFiles) < maxResults: + for resolution in resolutions: + if len(selectedFiles) >= maxResults: + break + if resolutionCount[resolution] < len(filesByResolution[resolution]): + selectedFiles.append((resolution, filesByResolution[resolution][resolutionCount[resolution]])) + resolutionCount[resolution] += 1 + + balancedFiles = {res: [] for res in filesByResolution.keys()} + for resolution, file in selectedFiles: + balancedFiles[resolution].append(file) - hashCount = 0 - for quality in filesByResolution: - hashCount += len(filesByResolution[quality]) + for quality, files in balancedFiles.items(): + for file in files: + for hash in file: + results.append({ + "name": f"[RD⚡] Comet {quality}", + "title": f"{file[hash]['title']}\n💾 {round(int(file[hash]['size']) / 1024 / 1024 / 1024, 2)}GB", + "url": f"{url.scheme}://{url.netloc}/{config_str}/playback/{hash}/{file[hash]['index']}" if config_str else f"{url.scheme}://{url.netloc}/playback/{hash}/{file[hash]['index']}" + }) - results = [] - if hashCount <= config["maxResults"] or config["maxResults"] == 0: - for quality, files in filesByResolution.items(): - for file in files: - for hash in file: - results.append({ - "name": f"[RD⚡] Comet {quality}", - "title": f"{file[hash]['title']}\n💾 {round(int(file[hash]['size']) / 1024 / 1024 / 1024, 2)}GB", - "url": f"{request.url.scheme}://{request.url.netloc}/{b64config}/playback/{hash}/{file[hash]['index']}" - }) - else: - selectedFiles = [] - resolutionCount = {res: 0 for res in filesByResolution.keys()} - resolutions = list(filesByResolution.keys()) - - while len(selectedFiles) < config["maxResults"]: - for resolution in resolutions: - if len(selectedFiles) >= config["maxResults"]: - break - if resolutionCount[resolution] < len(filesByResolution[resolution]): - selectedFiles.append((resolution, filesByResolution[resolution][resolutionCount[resolution]])) - resolutionCount[resolution] += 1 - - balancedFiles = {res: [] for res in filesByResolution.keys()} - for resolution, file in selectedFiles: - balancedFiles[resolution].append(file) + return results - for quality, files in balancedFiles.items(): - for file in files: - for hash in file: - results.append({ - "name": f"[RD⚡] Comet {quality}", - "title": f"{file[hash]['title']}\n💾 {round(int(file[hash]['size']) / 1024 / 1024 / 1024, 2)}GB", - "url": f"{request.url.scheme}://{request.url.netloc}/{b64config}/playback/{hash}/{file[hash]['index']}" - }) - - return { - "streams": results - } - async def generateDownloadLink(session, debridApiKey: str, hash: str, index: str): addMagnet = await session.post(f"https://api.real-debrid.com/rest/1.0/torrents/addMagnet", headers={ "Authorization": f"Bearer {debridApiKey}" diff --git a/comet/models.py b/comet/models.py new file mode 100644 index 0000000..f2a0595 --- /dev/null +++ b/comet/models.py @@ -0,0 +1,43 @@ +from typing import List + +from pydantic import BaseModel +from pydantic_settings import BaseSettings + + +class Settings(BaseSettings): + JACKETT_URL: str + JACKETT_KEY: str + GET_TORRENT_TIMEOUT: int + + class Config: + env_file = ".env" + env_file_encoding = "utf-8" + case_sensitive = False + extra = "ignore" + validate_assignment = True + +class ConfigModel(BaseModel): + debridService: str + debridApiKey: str + indexers: List[str] + maxResults: int + + class Config: + json_schema_extra = { + "example": { + "debridService": "RealDebrid", + "debridApiKey": "your_api_key_here", + "indexers": ["indexer1", "indexer2"], + "maxResults": 10 + } + } + +video_extensions: tuple = ( + ".mkv", ".mp4", ".avi", ".mov", ".flv", ".wmv", ".webm", ".mpg", ".mpeg", + ".m4v", ".3gp", ".3g2", ".ogv", ".ogg", ".drc", ".gif", ".gifv", ".mng", + ".avi", ".mov", ".qt", ".wmv", ".yuv", ".rm", ".rmvb", ".asf", ".amv", + ".m4p", ".mp2", ".mpe", ".mpv", ".m2v", ".svi", ".mxf", ".roq", ".nsv", + ".f4v", ".f4p", ".f4a", ".f4b" +) + +settings = Settings() diff --git a/makefile b/makefile new file mode 100644 index 0000000..adbd3a6 --- /dev/null +++ b/makefile @@ -0,0 +1,13 @@ +PHONY: run install sort + + +install: + @poetry install --no-root --group dev + +run: + @echo Visit http://127.0.0.1:8000/docs for the API Documentation + @echo Visit http://127.0.0.1:8000/manifest for the Stremio Manifest + @poetry run uvicorn comet.main:app --reload --reload-dir comet --reload-include "*.py" --reload-exclude "*.log" + +sort: + @poetry run isort comet diff --git a/poetry.lock b/poetry.lock index 284b2a1..a522869 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.7.1 and should not be changed by hand. [[package]] name = "aiohttp" @@ -517,6 +517,20 @@ files = [ {file = "idna-3.7.tar.gz", hash = "sha256:028ff3aadf0609c1fd278d8ea3089299412a7a8b9bd005dd08b9f8285bcb5cfc"}, ] +[[package]] +name = "isort" +version = "5.13.2" +description = "A Python utility / library to sort Python imports." +optional = false +python-versions = ">=3.8.0" +files = [ + {file = "isort-5.13.2-py3-none-any.whl", hash = "sha256:8ca5e72a8d85860d5a3fa69b8745237f2939afe12dbf656afbcb47fe72d947a6"}, + {file = "isort-5.13.2.tar.gz", hash = "sha256:48fdfcb9face5d58a4f6dde2e72a1fb8dcaf8ab26f95ab49fab84c2ddefb0109"}, +] + +[package.extras] +colors = ["colorama (>=0.4.6)"] + [[package]] name = "jinja2" version = "3.1.4" @@ -636,6 +650,24 @@ files = [ [package.dependencies] rapidfuzz = ">=3.8.0,<4.0.0" +[[package]] +name = "loguru" +version = "0.7.2" +description = "Python logging made (stupidly) simple" +optional = false +python-versions = ">=3.5" +files = [ + {file = "loguru-0.7.2-py3-none-any.whl", hash = "sha256:003d71e3d3ed35f0f8984898359d65b79e5b21943f78af86aa5491210429b8eb"}, + {file = "loguru-0.7.2.tar.gz", hash = "sha256:e671a53522515f34fd406340ee968cb9ecafbc4b36c679da03c18fd8d0bd51ac"}, +] + +[package.dependencies] +colorama = {version = ">=0.3.4", markers = "sys_platform == \"win32\""} +win32-setctime = {version = ">=1.0.0", markers = "sys_platform == \"win32\""} + +[package.extras] +dev = ["Sphinx (==7.2.5)", "colorama (==0.4.5)", "colorama (==0.4.6)", "exceptiongroup (==1.1.3)", "freezegun (==1.1.0)", "freezegun (==1.2.2)", "mypy (==v0.910)", "mypy (==v0.971)", "mypy (==v1.4.1)", "mypy (==v1.5.1)", "pre-commit (==3.4.0)", "pytest (==6.1.2)", "pytest (==7.4.0)", "pytest-cov (==2.12.1)", "pytest-cov (==4.1.0)", "pytest-mypy-plugins (==1.9.3)", "pytest-mypy-plugins (==3.0.0)", "sphinx-autobuild (==2021.3.14)", "sphinx-rtd-theme (==1.3.0)", "tox (==3.27.1)", "tox (==4.11.0)"] + [[package]] name = "markdown-it-py" version = "3.0.0" @@ -1031,6 +1063,25 @@ files = [ [package.dependencies] typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" +[[package]] +name = "pydantic-settings" +version = "2.3.2" +description = "Settings management using Pydantic" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pydantic_settings-2.3.2-py3-none-any.whl", hash = "sha256:ae06e44349e4c7bff8d57aff415dfd397ae75c217a098d54e9e6990ad7594ac7"}, + {file = "pydantic_settings-2.3.2.tar.gz", hash = "sha256:05d33003c74c2cd585de97b59eb17b6ed67181bc8a3ce594d74b5d24e4df7323"}, +] + +[package.dependencies] +pydantic = ">=2.7.0" +python-dotenv = ">=0.21.0" + +[package.extras] +toml = ["tomli (>=2.0.1)"] +yaml = ["pyyaml (>=6.0.1)"] + [[package]] name = "pygments" version = "2.18.0" @@ -1803,6 +1854,20 @@ files = [ {file = "websockets-12.0.tar.gz", hash = "sha256:81df9cbcbb6c260de1e007e58c011bfebe2dafc8435107b0537f393dd38c8b1b"}, ] +[[package]] +name = "win32-setctime" +version = "1.1.0" +description = "A small Python utility to set file creation time on Windows" +optional = false +python-versions = ">=3.5" +files = [ + {file = "win32_setctime-1.1.0-py3-none-any.whl", hash = "sha256:231db239e959c2fe7eb1d7dc129f11172354f98361c4fa2d6d2d7e278baa8aad"}, + {file = "win32_setctime-1.1.0.tar.gz", hash = "sha256:15cf5750465118d6929ae4de4eb46e8edae9a5634350c01ba582df868e932cb2"}, +] + +[package.extras] +dev = ["black (>=19.3b0)", "pytest (>=4.6.2)"] + [[package]] name = "yarl" version = "1.9.4" @@ -1909,4 +1974,4 @@ multidict = ">=4.0" [metadata] lock-version = "2.0" python-versions = "^3.11" -content-hash = "adfecfda8c14ef2ff44c5640e31d258010cab6bfa6047a5c06ab89c7c1def251" +content-hash = "0bfc6b5276e1b3b966d58a4def85071d589b03237c80ff8390af63336f493887" diff --git a/pyproject.toml b/pyproject.toml index abe969a..1e8d518 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,7 +16,12 @@ aiohttp = "^3.9.5" bencodepy = "^0.9.5" python-dotenv = "^1.0.1" asyncio = "^3.4.3" +pydantic-settings = "^2.3.2" +pydantic = "^2.7.3" +loguru = "^0.7.2" +[tool.poetry.group.dev.dependencies] +isort = "^5.13.2" [build-system] requires = ["poetry-core"]