From fc48d80a3793ded24be823c723e16f059014862e Mon Sep 17 00:00:00 2001 From: quietsy Date: Sun, 9 Feb 2025 00:31:26 +0200 Subject: [PATCH 01/15] Add fields --- root/app/api.py | 13 +++-- root/app/keyvaluestore.py | 3 +- root/app/models.py | 17 ++++++ root/app/updater.py | 111 ++++++++++++++++++++++++++++---------- 4 files changed, 112 insertions(+), 32 deletions(-) diff --git a/root/app/api.py b/root/app/api.py index e0a13ec..65a8b5c 100644 --- a/root/app/api.py +++ b/root/app/api.py @@ -1,8 +1,9 @@ -from fastapi import FastAPI +from fastapi import FastAPI, HTTPException from keyvaluestore import KeyValueStore from models import ImagesResponse, ImagesData +import traceback -api = FastAPI(docs_url="/") +api = FastAPI(docs_url="/", title="LinuxServer API", redoc_url=None, version="1.0") @api.get("/health", summary="Get the health status") async def health(): @@ -10,8 +11,12 @@ async def health(): @api.get("/api/v1/images", response_model=ImagesResponse, summary="Get a list of images") async def images(): - with KeyValueStore() as kv: - return ImagesResponse(status="OK", data=ImagesData.model_validate_json(kv["images"])) + try: + with KeyValueStore() as kv: + return ImagesResponse(status="OK", data=ImagesData.model_validate_json(kv["images"])) + except Exception: + print(traceback.format_exc()) + raise HTTPException(status_code=404, detail="Not found") if __name__ == "__main__": api.run() diff --git a/root/app/keyvaluestore.py b/root/app/keyvaluestore.py index 3322021..2af79f1 100644 --- a/root/app/keyvaluestore.py +++ b/root/app/keyvaluestore.py @@ -21,7 +21,8 @@ class KeyValueStore(dict): self.conn.commit() self.conn.close() def __contains__(self, key): - return self.conn.execute(f"SELECT 1 FROM kv WHERE key = '{key}' AND updated_at >= DATETIME('now', '-{self.invalidate_hours} hours')").fetchone() is not None + where_clause = "" if self.invalidate_hours == 0 else f" AND updated_at >= DATETIME('now', '-{self.invalidate_hours} hours')" + return self.conn.execute(f"SELECT 1 FROM kv WHERE key = '{key}' {where_clause}").fetchone() is not None def __getitem__(self, key): item = self.conn.execute("SELECT value FROM kv WHERE key = ?", (key,)).fetchone() return item[0] if item else None diff --git a/root/app/models.py b/root/app/models.py index 5d13269..b0fea2c 100644 --- a/root/app/models.py +++ b/root/app/models.py @@ -1,12 +1,29 @@ from pydantic import BaseModel +# Increment when updating schema +SCHEMA_VERSION = "1" + +class Tag(BaseModel): + tag: str + desc: str + +class Architecture(BaseModel): + arch: str + tag: str class Image(BaseModel): name: str + github_url: str + project_url: str + description: str version: str + version_timestamp: str category: str stable: bool deprecated: bool + stars: int + tags: list[Tag] + architectures: list[Architecture] class Repository(BaseModel): linuxserver: list[Image] diff --git a/root/app/updater.py b/root/app/updater.py index e6d9f22..96a5842 100644 --- a/root/app/updater.py +++ b/root/app/updater.py @@ -1,11 +1,11 @@ from github import Auth from github import Github from keyvaluestore import KeyValueStore -from models import Image, Repository, ImagesData +from models import Architecture, Tag, Image, Repository, ImagesData, SCHEMA_VERSION import datetime +import json import os -import threading import time import yaml @@ -19,8 +19,8 @@ def get_repos(): gh = Github(auth=auth) org = gh.get_organization("linuxserver") repos = org.get_repos() - return [repo for repo in repos if repo.full_name.startswith("linuxserver/docker-") - and not repo.full_name.startswith("linuxserver/docker-baseimage-") + return [repo for repo in repos if repo.name.startswith("docker-") + and not repo.name.startswith("docker-baseimage-") and (repo.description is None or "DEPRECATED" not in repo.description)] def get_vars(repo, branch): @@ -30,41 +30,98 @@ def get_vars(repo, branch): except: return None +def get_version(repo): + for release in repo.get_releases(): + if release.prerelease: + continue + return release.tag_name, str(release.published_at) + return "latest", str(repo.pushed_at) + +def get_tags(readme_vars): + if "development_versions_items" not in readme_vars: + return [Tag(tag="latest", desc="Stable releases")], True + tags = [] + stable = False + for item in readme_vars["development_versions_items"]: + if item["tag"] == "latest": + stable = True + tags.append(Tag(tag=item["tag"], desc=item["desc"])) + return tags, stable + +def get_architectures(readme_vars): + if "available_architectures" not in readme_vars: + return [Architecture(arch="arch_x86_64", tag="amd64-latest")] + archs = [] + for item in readme_vars["available_architectures"]: + archs.append(Architecture(arch=item["arch"][8:-3], tag=item["tag"])) + return archs + +def get_description(readme_vars): + description = readme_vars.get("project_blurb", "No description") + description = description.replace("\n", " ").strip(" \t\n\r") + if "project_name" in readme_vars: + description = description.replace("[{{ project_name|capitalize }}]", readme_vars["project_name"]) + description = description.replace("[{{ project_name }}]", readme_vars["project_name"]) + if "project_url" in readme_vars: + description = description.replace("({{ project_url }})", "") + return json.dumps(description).replace("\"", "") + +def get_image(repo): + readme_vars = get_vars(repo, "master") or get_vars(repo, "main") or get_vars(repo, "develop") or get_vars(repo, "nightly") + if not readme_vars: + return None + categories = readme_vars.get("project_categories", "") + if "Internal" in categories: + return None + tags, stable = get_tags(readme_vars) + deprecated = readme_vars.get("project_deprecation_status", False) + version, version_timestamp = get_version(repo) + return Image( + name=repo.name.replace("docker-", ""), + github_url=repo.html_url, + project_url=readme_vars.get("project_url", ""), + description=get_description(readme_vars), + version=version, + version_timestamp=version_timestamp, + category=categories, + stable=stable, + deprecated=deprecated, + stars=repo.stargazers_count, + tags=tags, + architectures=get_architectures(readme_vars) + ) + def get_state(): images = [] repos = get_repos() - for repo in sorted(repos, key=lambda repo: repo.full_name): - readme_vars = get_vars(repo, "master") or get_vars(repo, "main") or get_vars(repo, "develop") or get_vars(repo, "nightly") - if not readme_vars or "'project_deprecation_status': True" in str(readme_vars): + for repo in sorted(repos, key=lambda repo: repo.name): + image = get_image(repo) + if not image: continue - categories = readme_vars.get("project_categories", "") - if "Internal" in categories: - continue - version = "latest" if "development_versions_items" not in readme_vars else readme_vars["development_versions_items"][0]["tag"] - images.append(Image( - name=repo.full_name.replace("linuxserver/docker-", ""), - version=version, - category=categories, - stable=version == "latest", - deprecated=False - )) + images.append(image) return ImagesData(repositories=Repository(linuxserver=images)).model_dump_json() -def update_images(): +def update_images(schema_updated): with KeyValueStore(invalidate_hours=INVALIDATE_HOURS, readonly=False) as kv: - if "images" in kv or CI == "1": + if ("images" in kv or CI == "1") and not schema_updated: print(f"{datetime.datetime.now()} - skipped - already updated") return print(f"{datetime.datetime.now()} - updating images") kv["images"] = get_state() print(f"{datetime.datetime.now()} - updated images") -class UpdateImages(threading.Thread): - def run(self,*args,**kwargs): - while True: - update_images() - time.sleep(INVALIDATE_HOURS*60*60) +def update_schema(): + with KeyValueStore(invalidate_hours=0, readonly=False) as kv: + if "schema_version" in kv and kv["schema_version"] == SCHEMA_VERSION: + return False + kv["schema_version"] = SCHEMA_VERSION + return True + +def main(): + while True: + schema_updated = update_schema() + update_images(schema_updated) + time.sleep(INVALIDATE_HOURS*60*60) if __name__ == "__main__": - update_images_thread = UpdateImages() - update_images_thread.start() + main() From 2b3078bd3f836a3ed1b1914c4be16c2f5f6b8bd9 Mon Sep 17 00:00:00 2001 From: quietsy Date: Sun, 9 Feb 2025 00:53:17 +0200 Subject: [PATCH 02/15] Add changelog --- root/app/models.py | 5 +++++ root/app/updater.py | 11 ++++++++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/root/app/models.py b/root/app/models.py index b0fea2c..1781c6d 100644 --- a/root/app/models.py +++ b/root/app/models.py @@ -11,6 +11,10 @@ class Architecture(BaseModel): arch: str tag: str +class Changelog(BaseModel): + date: str + desc: str + class Image(BaseModel): name: str github_url: str @@ -18,6 +22,7 @@ class Image(BaseModel): description: str version: str version_timestamp: str + changelog: list[Changelog] category: str stable: bool deprecated: bool diff --git a/root/app/updater.py b/root/app/updater.py index 96a5842..e173f25 100644 --- a/root/app/updater.py +++ b/root/app/updater.py @@ -1,7 +1,7 @@ from github import Auth from github import Github from keyvaluestore import KeyValueStore -from models import Architecture, Tag, Image, Repository, ImagesData, SCHEMA_VERSION +from models import Architecture, Changelog, Tag, Image, Repository, ImagesData, SCHEMA_VERSION import datetime import json @@ -56,6 +56,14 @@ def get_architectures(readme_vars): archs.append(Architecture(arch=item["arch"][8:-3], tag=item["tag"])) return archs +def get_changelogs(readme_vars): + if "changelogs" not in readme_vars: + return [Changelog(date="01.01.50", desc="No changelog")] + changelogs = [] + for item in readme_vars["changelogs"][0:3]: + changelogs.append(Changelog(date=item["date"][0:-1], desc=item["desc"])) + return changelogs + def get_description(readme_vars): description = readme_vars.get("project_blurb", "No description") description = description.replace("\n", " ").strip(" \t\n\r") @@ -83,6 +91,7 @@ def get_image(repo): description=get_description(readme_vars), version=version, version_timestamp=version_timestamp, + changelog=get_changelogs(readme_vars), category=categories, stable=stable, deprecated=deprecated, From 03546d3f782420b87d356136165ea0097c13196b Mon Sep 17 00:00:00 2001 From: quietsy Date: Sun, 9 Feb 2025 00:56:05 +0200 Subject: [PATCH 03/15] Fix CI --- root/app/updater.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/root/app/updater.py b/root/app/updater.py index e173f25..f21dfb3 100644 --- a/root/app/updater.py +++ b/root/app/updater.py @@ -112,7 +112,7 @@ def get_state(): def update_images(schema_updated): with KeyValueStore(invalidate_hours=INVALIDATE_HOURS, readonly=False) as kv: - if ("images" in kv or CI == "1") and not schema_updated: + if ("images" in kv and not schema_updated) or CI == "1": print(f"{datetime.datetime.now()} - skipped - already updated") return print(f"{datetime.datetime.now()} - updating images") From 6c3f9f737c6b428a0a87af4cc01a9b138791142c Mon Sep 17 00:00:00 2001 From: quietsy Date: Sun, 9 Feb 2025 13:20:18 +0200 Subject: [PATCH 04/15] Refactor --- root/app/api.py | 7 ++-- root/app/keyvaluestore.py | 24 +++++++++++-- root/app/lsio_github.py | 38 ++++++++++++++++++++ root/app/models.py | 2 +- root/app/updater.py | 75 +++++++++++---------------------------- 5 files changed, 85 insertions(+), 61 deletions(-) create mode 100644 root/app/lsio_github.py diff --git a/root/app/api.py b/root/app/api.py index 65a8b5c..5a986ff 100644 --- a/root/app/api.py +++ b/root/app/api.py @@ -1,6 +1,8 @@ from fastapi import FastAPI, HTTPException +from fastapi.responses import JSONResponse from keyvaluestore import KeyValueStore -from models import ImagesResponse, ImagesData +from models import ImagesResponse +import json import traceback api = FastAPI(docs_url="/", title="LinuxServer API", redoc_url=None, version="1.0") @@ -13,7 +15,8 @@ async def health(): async def images(): try: with KeyValueStore() as kv: - return ImagesResponse(status="OK", data=ImagesData.model_validate_json(kv["images"])) + content = json.loads(kv["images"]) + return JSONResponse(content=content) except Exception: print(traceback.format_exc()) raise HTTPException(status_code=404, detail="Not found") diff --git a/root/app/keyvaluestore.py b/root/app/keyvaluestore.py index 2af79f1..4185249 100644 --- a/root/app/keyvaluestore.py +++ b/root/app/keyvaluestore.py @@ -2,15 +2,27 @@ import os import sqlite3 DB_FILE = os.environ.get("DB_FILE", "/config/api.db") +# Increment to drop tables and start over +DB_SCHEMA_VERSION = 1 +def set_db_schema(): + conn = sqlite3.connect(DB_FILE) + conn.execute("CREATE TABLE IF NOT EXISTS db_schema (key TEXT UNIQUE, version INTEGER DEFAULT 0)") + is_updated = conn.execute(f"SELECT 1 FROM db_schema WHERE version = {DB_SCHEMA_VERSION}").fetchone() is not None + if not is_updated: + conn.execute(f"DROP TABLE IF EXISTS kv") + conn.execute(f"REPLACE INTO db_schema (key, version) VALUES('schema_version', {DB_SCHEMA_VERSION})") + conn.commit() + conn.close() + class KeyValueStore(dict): def __init__( self, invalidate_hours=24, readonly=True): self.invalidate_hours = invalidate_hours self.readonly = readonly if not readonly: self.conn = sqlite3.connect(DB_FILE) - self.conn.execute("CREATE TABLE IF NOT EXISTS kv (key TEXT UNIQUE, value TEXT, updated_at TEXT)") + self.conn.execute("CREATE TABLE IF NOT EXISTS kv (key TEXT UNIQUE, value TEXT, updated_at TEXT, schema_version INTEGER)") self.conn.commit() self.conn.close() def __enter__(self): @@ -26,6 +38,12 @@ class KeyValueStore(dict): def __getitem__(self, key): item = self.conn.execute("SELECT value FROM kv WHERE key = ?", (key,)).fetchone() return item[0] if item else None - def __setitem__(self, key, value): - self.conn.execute("REPLACE INTO kv (key, value, updated_at) VALUES (?,?, CURRENT_TIMESTAMP)", (key, value)) + def set_value(self, key, value, schema_version): + self.conn.execute("REPLACE INTO kv (key, value, updated_at, schema_version) VALUES (?, ?, CURRENT_TIMESTAMP, ?)", (key, value, schema_version)) self.conn.commit() + def update_schema(self, key, schema_version): + is_updated = self.conn.execute(f"SELECT 1 FROM kv WHERE key = '{key}' AND schema_version = {schema_version}").fetchone() is not None + if not is_updated: + self.conn.execute(f"UPDATE kv SET schema_version = {schema_version} WHERE key = '{key}'") + self.conn.commit() + return is_updated diff --git a/root/app/lsio_github.py b/root/app/lsio_github.py new file mode 100644 index 0000000..1e79867 --- /dev/null +++ b/root/app/lsio_github.py @@ -0,0 +1,38 @@ +from github import Auth +from github import Github + +import os +import yaml + +PAT = os.environ.get("PAT", None) + + +def get_repos(): + auth = Auth.Token(PAT) if PAT else None + gh = Github(auth=auth) + org = gh.get_organization("linuxserver") + repos = org.get_repos() + return [repo for repo in sorted(repos, key=lambda repo: repo.name) if repo.name.startswith("docker-") + and not repo.name.startswith("docker-baseimage-") + and (repo.description is None or "DEPRECATED" not in repo.description)] + +def get_file(repo, branch, path, is_yaml=False): + try: + content = repo.get_contents(path, ref=branch).decoded_content + return yaml.load(content, Loader=yaml.CLoader) if is_yaml else content + except: + return None + +def get_last_stable_release(repo): + for release in repo.get_releases(): + if release.prerelease: + continue + return release.tag_name, str(release.published_at) + return "latest", str(repo.pushed_at) + +def get_readme_vars(repo): + master = get_file(repo, "master", "readme-vars.yml", is_yaml=True) + main = get_file(repo, "master", "readme-vars.yml", is_yaml=True) + develop = get_file(repo, "master", "readme-vars.yml", is_yaml=True) + nightly = get_file(repo, "master", "readme-vars.yml", is_yaml=True) + return master or main or develop or nightly diff --git a/root/app/models.py b/root/app/models.py index 1781c6d..ddb4c98 100644 --- a/root/app/models.py +++ b/root/app/models.py @@ -1,7 +1,7 @@ from pydantic import BaseModel # Increment when updating schema -SCHEMA_VERSION = "1" +IMAGES_SCHEMA_VERSION = 1 class Tag(BaseModel): tag: str diff --git a/root/app/updater.py b/root/app/updater.py index f21dfb3..20662a1 100644 --- a/root/app/updater.py +++ b/root/app/updater.py @@ -1,42 +1,15 @@ -from github import Auth -from github import Github -from keyvaluestore import KeyValueStore -from models import Architecture, Changelog, Tag, Image, Repository, ImagesData, SCHEMA_VERSION +import lsio_github as gh +from keyvaluestore import KeyValueStore, set_db_schema +from models import Architecture, Changelog, Tag, Image, Repository, ImagesData, ImagesResponse, IMAGES_SCHEMA_VERSION import datetime -import json import os import time -import yaml CI = os.environ.get("CI", None) INVALIDATE_HOURS = int(os.environ.get("INVALIDATE_HOURS", "24")) -PAT = os.environ.get("PAT", None) -def get_repos(): - auth = Auth.Token(PAT) if PAT else None - gh = Github(auth=auth) - org = gh.get_organization("linuxserver") - repos = org.get_repos() - return [repo for repo in repos if repo.name.startswith("docker-") - and not repo.name.startswith("docker-baseimage-") - and (repo.description is None or "DEPRECATED" not in repo.description)] - -def get_vars(repo, branch): - try: - content = repo.get_contents("readme-vars.yml", ref=branch).decoded_content - return yaml.load(content, Loader=yaml.CLoader) - except: - return None - -def get_version(repo): - for release in repo.get_releases(): - if release.prerelease: - continue - return release.tag_name, str(release.published_at) - return "latest", str(repo.pushed_at) - def get_tags(readme_vars): if "development_versions_items" not in readme_vars: return [Tag(tag="latest", desc="Stable releases")], True @@ -72,10 +45,10 @@ def get_description(readme_vars): description = description.replace("[{{ project_name }}]", readme_vars["project_name"]) if "project_url" in readme_vars: description = description.replace("({{ project_url }})", "") - return json.dumps(description).replace("\"", "") + return description def get_image(repo): - readme_vars = get_vars(repo, "master") or get_vars(repo, "main") or get_vars(repo, "develop") or get_vars(repo, "nightly") + readme_vars = gh.get_readme_vars(repo) if not readme_vars: return None categories = readme_vars.get("project_categories", "") @@ -83,7 +56,7 @@ def get_image(repo): return None tags, stable = get_tags(readme_vars) deprecated = readme_vars.get("project_deprecation_status", False) - version, version_timestamp = get_version(repo) + version, version_timestamp = gh.get_last_stable_release(repo) return Image( name=repo.name.replace("docker-", ""), github_url=repo.html_url, @@ -100,36 +73,28 @@ def get_image(repo): architectures=get_architectures(readme_vars) ) -def get_state(): - images = [] - repos = get_repos() - for repo in sorted(repos, key=lambda repo: repo.name): - image = get_image(repo) - if not image: - continue - images.append(image) - return ImagesData(repositories=Repository(linuxserver=images)).model_dump_json() - -def update_images(schema_updated): +def update_images(): with KeyValueStore(invalidate_hours=INVALIDATE_HOURS, readonly=False) as kv: - if ("images" in kv and not schema_updated) or CI == "1": + is_updated = kv.update_schema("images", IMAGES_SCHEMA_VERSION) + if ("images" in kv and is_updated) or CI == "1": print(f"{datetime.datetime.now()} - skipped - already updated") return print(f"{datetime.datetime.now()} - updating images") - kv["images"] = get_state() + images = [] + repos = gh.get_repos() + for repo in repos: + image = get_image(repo) + if not image: + continue + images.append(image) + new_state = ImagesResponse(status="OK", data=ImagesData(repositories=Repository(linuxserver=images))).model_dump_json() + kv.set_value("images", new_state, IMAGES_SCHEMA_VERSION) print(f"{datetime.datetime.now()} - updated images") -def update_schema(): - with KeyValueStore(invalidate_hours=0, readonly=False) as kv: - if "schema_version" in kv and kv["schema_version"] == SCHEMA_VERSION: - return False - kv["schema_version"] = SCHEMA_VERSION - return True - def main(): + set_db_schema() while True: - schema_updated = update_schema() - update_images(schema_updated) + update_images() time.sleep(INVALIDATE_HOURS*60*60) if __name__ == "__main__": From 5f816beec9937bb154d67861ac5d8dda5da39053 Mon Sep 17 00:00:00 2001 From: quietsy Date: Sun, 9 Feb 2025 14:00:31 +0200 Subject: [PATCH 05/15] Cleanup --- root/app/lsio_github.py | 25 ++++++++++++------------- root/app/updater.py | 6 +++++- 2 files changed, 17 insertions(+), 14 deletions(-) diff --git a/root/app/lsio_github.py b/root/app/lsio_github.py index 1e79867..e8191e7 100644 --- a/root/app/lsio_github.py +++ b/root/app/lsio_github.py @@ -5,16 +5,12 @@ import os import yaml PAT = os.environ.get("PAT", None) - +GH_AUTH = Auth.Token(PAT) if PAT else None +GH = Github(auth=GH_AUTH) def get_repos(): - auth = Auth.Token(PAT) if PAT else None - gh = Github(auth=auth) - org = gh.get_organization("linuxserver") - repos = org.get_repos() - return [repo for repo in sorted(repos, key=lambda repo: repo.name) if repo.name.startswith("docker-") - and not repo.name.startswith("docker-baseimage-") - and (repo.description is None or "DEPRECATED" not in repo.description)] + org = GH.get_organization("linuxserver") + return org.get_repos() def get_file(repo, branch, path, is_yaml=False): try: @@ -31,8 +27,11 @@ def get_last_stable_release(repo): return "latest", str(repo.pushed_at) def get_readme_vars(repo): - master = get_file(repo, "master", "readme-vars.yml", is_yaml=True) - main = get_file(repo, "master", "readme-vars.yml", is_yaml=True) - develop = get_file(repo, "master", "readme-vars.yml", is_yaml=True) - nightly = get_file(repo, "master", "readme-vars.yml", is_yaml=True) - return master or main or develop or nightly + return ( + get_file(repo, "master", "readme-vars.yml", is_yaml=True) or + get_file(repo, "main", "readme-vars.yml", is_yaml=True) or + get_file(repo, "develop", "readme-vars.yml", is_yaml=True) or + get_file(repo, "nightly", "readme-vars.yml", is_yaml=True)) + +def print_rate_limit(): + print(f"Github ratelimit - {GH.get_rate_limit()}") diff --git a/root/app/updater.py b/root/app/updater.py index 20662a1..80a20a4 100644 --- a/root/app/updater.py +++ b/root/app/updater.py @@ -48,6 +48,8 @@ def get_description(readme_vars): return description def get_image(repo): + if not repo.name.startswith("docker-") or repo.name.startswith("docker-baseimage-"): + return None readme_vars = gh.get_readme_vars(repo) if not readme_vars: return None @@ -82,7 +84,7 @@ def update_images(): print(f"{datetime.datetime.now()} - updating images") images = [] repos = gh.get_repos() - for repo in repos: + for repo in sorted(repos, key=lambda repo: repo.name): image = get_image(repo) if not image: continue @@ -94,7 +96,9 @@ def update_images(): def main(): set_db_schema() while True: + gh.print_rate_limit() update_images() + gh.print_rate_limit() time.sleep(INVALIDATE_HOURS*60*60) if __name__ == "__main__": From 3fa9ecb539c97e386097b4cac27f47ac4db5ab75 Mon Sep 17 00:00:00 2001 From: quietsy Date: Sun, 9 Feb 2025 14:02:26 +0200 Subject: [PATCH 06/15] Cleanup --- root/app/lsio_github.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/root/app/lsio_github.py b/root/app/lsio_github.py index e8191e7..35e1459 100644 --- a/root/app/lsio_github.py +++ b/root/app/lsio_github.py @@ -27,8 +27,7 @@ def get_last_stable_release(repo): return "latest", str(repo.pushed_at) def get_readme_vars(repo): - return ( - get_file(repo, "master", "readme-vars.yml", is_yaml=True) or + return (get_file(repo, "master", "readme-vars.yml", is_yaml=True) or get_file(repo, "main", "readme-vars.yml", is_yaml=True) or get_file(repo, "develop", "readme-vars.yml", is_yaml=True) or get_file(repo, "nightly", "readme-vars.yml", is_yaml=True)) From 2674fce998662e2852be4d96fd47e36823808870 Mon Sep 17 00:00:00 2001 From: quietsy Date: Sun, 9 Feb 2025 14:19:07 +0200 Subject: [PATCH 07/15] Format string --- root/app/lsio_github.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/root/app/lsio_github.py b/root/app/lsio_github.py index 35e1459..7838a62 100644 --- a/root/app/lsio_github.py +++ b/root/app/lsio_github.py @@ -33,4 +33,5 @@ def get_readme_vars(repo): get_file(repo, "nightly", "readme-vars.yml", is_yaml=True)) def print_rate_limit(): - print(f"Github ratelimit - {GH.get_rate_limit()}") + ratelimit = GH.get_rate_limit().core + print(f"Github ratelimit - {ratelimit.remaining}/{ratelimit.limit} resets at {ratelimit.reset}") From 44e6c866c00783389bf080e95d7e5be2071eded6 Mon Sep 17 00:00:00 2001 From: quietsy Date: Sun, 9 Feb 2025 17:37:12 +0200 Subject: [PATCH 08/15] Add even more fields --- root/app/keyvaluestore.py | 3 + root/app/models.py | 80 +++++++++++++++++++++++- root/app/updater.py | 125 +++++++++++++++++++++++++++++++++++--- 3 files changed, 198 insertions(+), 10 deletions(-) diff --git a/root/app/keyvaluestore.py b/root/app/keyvaluestore.py index 4185249..ac89702 100644 --- a/root/app/keyvaluestore.py +++ b/root/app/keyvaluestore.py @@ -38,6 +38,9 @@ class KeyValueStore(dict): def __getitem__(self, key): item = self.conn.execute("SELECT value FROM kv WHERE key = ?", (key,)).fetchone() return item[0] if item else None + def get_updated_at(self, key): + item = self.conn.execute("SELECT updated_at FROM kv WHERE key = ?", (key,)).fetchone() + return item[0] if item else None def set_value(self, key, value, schema_version): self.conn.execute("REPLACE INTO kv (key, value, updated_at, schema_version) VALUES (?, ?, CURRENT_TIMESTAMP, ?)", (key, value, schema_version)) self.conn.commit() diff --git a/root/app/models.py b/root/app/models.py index ddb4c98..a2c7037 100644 --- a/root/app/models.py +++ b/root/app/models.py @@ -15,20 +15,93 @@ class Changelog(BaseModel): date: str desc: str +class Volume(BaseModel): + path: str + host_path: str + desc: str + optional: bool + +class Port(BaseModel): + external: str + internal: str + desc: str + optional: bool + +class EnvVar(BaseModel): + name: str + value: str + desc: str + optional: bool + +class EnvVar(BaseModel): + name: str + value: str + desc: str + optional: bool + +class Custom(BaseModel): + name: str + name_compose: str + value: str + desc: str + optional: bool + +class SecurityOpt(BaseModel): + run_var: str + compose_var: str + desc: str + optional: bool + +class Device(BaseModel): + path: str + host_path: str + desc: str + optional: bool + +class Cap(BaseModel): + cap_add: str + desc: str + optional: bool + +class Hostname(BaseModel): + hostname: str + desc: str + optional: bool + +class MacAddress(BaseModel): + mac_address: str + desc: str + optional: bool + class Image(BaseModel): name: str github_url: str - project_url: str + project_url: str | None + project_logo: str | None + application_setup: str description: str version: str version_timestamp: str - changelog: list[Changelog] category: str stable: bool - deprecated: bool + deprecated: bool | None stars: int + readonly_supported: bool | None + nonroot_supported: bool | None + privileged: bool | None + networking: str | None + hostname: Hostname | None + mac_address: MacAddress | None tags: list[Tag] architectures: list[Architecture] + env_vars: list[EnvVar] | None + volumes: list[Volume] | None + ports: list[Port] | None + custom: list[Custom] | None + security_opt: list[SecurityOpt] | None + devices: list[Device] | None + caps: list[Cap] | None + changelog: list[Changelog] | None class Repository(BaseModel): linuxserver: list[Image] @@ -38,4 +111,5 @@ class ImagesData(BaseModel): class ImagesResponse(BaseModel): status: str + last_updated: str data: ImagesData diff --git a/root/app/updater.py b/root/app/updater.py index 80a20a4..514ea15 100644 --- a/root/app/updater.py +++ b/root/app/updater.py @@ -1,6 +1,8 @@ import lsio_github as gh from keyvaluestore import KeyValueStore, set_db_schema -from models import Architecture, Changelog, Tag, Image, Repository, ImagesData, ImagesResponse, IMAGES_SCHEMA_VERSION +from models import Architecture, Changelog, Tag, EnvVar, Volume, Port +from models import Custom, SecurityOpt, Device, Cap, Hostname, MacAddress, Image +from models import Repository, ImagesData, ImagesResponse, IMAGES_SCHEMA_VERSION import datetime import os @@ -31,7 +33,7 @@ def get_architectures(readme_vars): def get_changelogs(readme_vars): if "changelogs" not in readme_vars: - return [Changelog(date="01.01.50", desc="No changelog")] + return None changelogs = [] for item in readme_vars["changelogs"][0:3]: changelogs.append(Changelog(date=item["date"][0:-1], desc=item["desc"])) @@ -47,6 +49,98 @@ def get_description(readme_vars): description = description.replace("({{ project_url }})", "") return description +def get_env_vars(readme_vars): + env_vars = [] + if readme_vars.get("common_param_env_vars_enabled", False): + env_vars.extend([ + EnvVar(name="PUID", value="1000", desc="User ID", optional=False), + EnvVar(name="PGID", value="1000", desc="Group ID", optional=False), + EnvVar(name="TZ", value="Etc/UTC", desc="Timezone", optional=False), + ]) + if "param_env_vars" in readme_vars: + for item in readme_vars["param_env_vars"]: + env_vars.append(EnvVar(name=item["env_var"], value=item["env_value"], desc=item["desc"], optional=False)) + if "opt_param_env_vars" in readme_vars: + for item in readme_vars["opt_param_env_vars"]: + env_vars.append(EnvVar(name=item["env_var"], value=item["env_value"], desc=item["desc"], optional=True)) + return env_vars if env_vars else None + +def get_volumes(readme_vars): + volumes = [] + if "param_volumes" in readme_vars: + for item in readme_vars["param_volumes"]: + volumes.append(Volume(path=item["vol_path"], host_path=item["vol_host_path"], desc=item["desc"], optional=False)) + if "opt_param_volumes" in readme_vars: + for item in readme_vars["opt_param_volumes"]: + volumes.append(Volume(path=item["vol_path"], host_path=item["vol_host_path"], desc=item["desc"], optional=True)) + return volumes if volumes else None + +def get_ports(readme_vars): + ports = [] + if "param_ports" in readme_vars: + for item in readme_vars["param_ports"]: + ports.append(Port(external=item["external_port"], internal=item["internal_port"], desc=item["port_desc"], optional=False)) + if "opt_param_ports" in readme_vars: + for item in readme_vars["opt_param_ports"]: + ports.append(Port(external=item["external_port"], internal=item["internal_port"], desc=item["port_desc"], optional=True)) + return ports if ports else None + +def get_custom(readme_vars): + custom = [] + if "custom_params" in readme_vars: + for item in readme_vars["custom_params"]: + custom.append(Custom(name=item["name"], name_compose=item["name_compose"], value=item["value"], desc=item["desc"], optional=False)) + if "opt_custom_params" in readme_vars: + for item in readme_vars["opt_custom_params"]: + custom.append(Custom(name=item["name"], name_compose=item["name_compose"], value=item["value"], desc=item["desc"], optional=True)) + return custom if custom else None + +def get_security_opt(readme_vars): + security_opts = [] + if "security_opt_param_vars" in readme_vars: + for item in readme_vars["security_opt_param_vars"]: + security_opts.append(SecurityOpt(run_var=item["run_var"], compose_var=item["compose_var"], desc=item["desc"], optional=False)) + if "opt_security_opt_param_vars" in readme_vars: + for item in readme_vars["opt_security_opt_param_vars"]: + security_opts.append(SecurityOpt(run_var=item["run_var"], compose_var=item["compose_var"], desc=item["desc"], optional=True)) + return security_opts if security_opts else None + +def get_devices(readme_vars): + devices = [] + if "param_devices" in readme_vars: + for item in readme_vars["param_devices"]: + devices.append(Device(path=item["device_path"], host_path=item["device_host_path"], desc=item["desc"], optional=False)) + if "opt_param_devices" in readme_vars: + for item in readme_vars["opt_param_devices"]: + devices.append(Device(path=item["device_path"], host_path=item["device_host_path"], desc=item["desc"], optional=True)) + return devices if devices else None + +def get_caps(readme_vars): + caps = [] + if "cap_add_param_vars" in readme_vars: + for item in readme_vars["cap_add_param_vars"]: + caps.append(Cap(cap_add=item["cap_add_var"], desc=item["desc"], optional=False)) + if "opt_cap_add_param_vars" in readme_vars: + for item in readme_vars["opt_cap_add_param_vars"]: + caps.append(Cap(cap_add=item["cap_add_var"], desc=item["desc"], optional=True)) + return caps if caps else None + +def get_hostname(readme_vars): + include_hostname = readme_vars.get("param_usage_include_hostname", False) + if not include_hostname: + return None + optional = include_hostname == "optional" + hostname = readme_vars.get("param_hostname", False).replace("{{ project_name }}", readme_vars["project_name"]) + return Hostname(hostname=hostname, desc=readme_vars.get("param_hostname_desc", ""), optional=optional) + +def get_mac_address(readme_vars): + include_mac_address = readme_vars.get("param_usage_include_mac_address", False) + if not include_mac_address: + return None + optional = include_mac_address == "optional" + hostname = readme_vars.get("param_mac_address", False) + return MacAddress(mac_address=hostname, desc=readme_vars.get("param_mac_address_desc", ""), optional=optional) + def get_image(repo): if not repo.name.startswith("docker-") or repo.name.startswith("docker-baseimage-"): return None @@ -57,12 +151,15 @@ def get_image(repo): if "Internal" in categories: return None tags, stable = get_tags(readme_vars) - deprecated = readme_vars.get("project_deprecation_status", False) + deprecated = readme_vars.get("project_deprecation_status", None) version, version_timestamp = gh.get_last_stable_release(repo) return Image( name=repo.name.replace("docker-", ""), github_url=repo.html_url, - project_url=readme_vars.get("project_url", ""), + stars=repo.stargazers_count, + project_url=readme_vars.get("project_url", None), + project_logo=readme_vars.get("project_logo", None), + application_setup=f"{repo.html_url}?tab=readme-ov-file#application-setup", description=get_description(readme_vars), version=version, version_timestamp=version_timestamp, @@ -70,9 +167,21 @@ def get_image(repo): category=categories, stable=stable, deprecated=deprecated, - stars=repo.stargazers_count, tags=tags, - architectures=get_architectures(readme_vars) + architectures=get_architectures(readme_vars), + readonly_supported=readme_vars.get("readonly_supported", None), + nonroot_supported=readme_vars.get("nonroot_supported", None), + privileged=readme_vars.get("privileged", None), + networking=readme_vars.get("param_net", None), + hostname=get_hostname(readme_vars), + mac_address=get_mac_address(readme_vars), + env_vars=get_env_vars(readme_vars), + volumes=get_volumes(readme_vars), + ports=get_ports(readme_vars), + custom=get_custom(readme_vars), + security_opt=get_security_opt(readme_vars), + devices=get_devices(readme_vars), + caps=get_caps(readme_vars), ) def update_images(): @@ -89,7 +198,9 @@ def update_images(): if not image: continue images.append(image) - new_state = ImagesResponse(status="OK", data=ImagesData(repositories=Repository(linuxserver=images))).model_dump_json() + data = ImagesData(repositories=Repository(linuxserver=images)) + response = ImagesResponse(status="OK", last_updated=kv.get_updated_at("images"), data=data) + new_state = response.model_dump_json(exclude_none=True) kv.set_value("images", new_state, IMAGES_SCHEMA_VERSION) print(f"{datetime.datetime.now()} - updated images") From 9a57b41d96a9fe1d268984ea8a5bef26e79ca7a7 Mon Sep 17 00:00:00 2001 From: quietsy Date: Sun, 9 Feb 2025 17:55:53 +0200 Subject: [PATCH 09/15] Fix typo --- root/app/models.py | 2 +- root/app/updater.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/root/app/models.py b/root/app/models.py index a2c7037..690d740 100644 --- a/root/app/models.py +++ b/root/app/models.py @@ -42,7 +42,7 @@ class EnvVar(BaseModel): class Custom(BaseModel): name: str name_compose: str - value: str + value: str | list[str] desc: str optional: bool diff --git a/root/app/updater.py b/root/app/updater.py index 514ea15..bf43cc4 100644 --- a/root/app/updater.py +++ b/root/app/updater.py @@ -142,6 +142,7 @@ def get_mac_address(readme_vars): return MacAddress(mac_address=hostname, desc=readme_vars.get("param_mac_address_desc", ""), optional=optional) def get_image(repo): + print(f"Processing {repo.name}") if not repo.name.startswith("docker-") or repo.name.startswith("docker-baseimage-"): return None readme_vars = gh.get_readme_vars(repo) From 4a359ec76e8ca7803f71839bd81bebd29e842c8a Mon Sep 17 00:00:00 2001 From: quietsy Date: Sun, 9 Feb 2025 18:49:36 +0200 Subject: [PATCH 10/15] Add config --- root/app/models.py | 31 +++++++++++++++++-------------- root/app/updater.py | 35 +++++++++++++++++++---------------- 2 files changed, 36 insertions(+), 30 deletions(-) diff --git a/root/app/models.py b/root/app/models.py index 690d740..840b837 100644 --- a/root/app/models.py +++ b/root/app/models.py @@ -73,27 +73,14 @@ class MacAddress(BaseModel): desc: str optional: bool -class Image(BaseModel): - name: str - github_url: str - project_url: str | None - project_logo: str | None +class Config(BaseModel): application_setup: str - description: str - version: str - version_timestamp: str - category: str - stable: bool - deprecated: bool | None - stars: int readonly_supported: bool | None nonroot_supported: bool | None privileged: bool | None networking: str | None hostname: Hostname | None mac_address: MacAddress | None - tags: list[Tag] - architectures: list[Architecture] env_vars: list[EnvVar] | None volumes: list[Volume] | None ports: list[Port] | None @@ -101,7 +88,23 @@ class Image(BaseModel): security_opt: list[SecurityOpt] | None devices: list[Device] | None caps: list[Cap] | None + +class Image(BaseModel): + name: str + github_url: str + project_url: str | None + project_logo: str | None + description: str + version: str + version_timestamp: str + category: str + stable: bool + deprecated: bool | None + stars: int + tags: list[Tag] + architectures: list[Architecture] changelog: list[Changelog] | None + config: Config class Repository(BaseModel): linuxserver: list[Image] diff --git a/root/app/updater.py b/root/app/updater.py index bf43cc4..85d9b0f 100644 --- a/root/app/updater.py +++ b/root/app/updater.py @@ -1,6 +1,6 @@ import lsio_github as gh from keyvaluestore import KeyValueStore, set_db_schema -from models import Architecture, Changelog, Tag, EnvVar, Volume, Port +from models import Architecture, Changelog, Tag, EnvVar, Volume, Port, Config from models import Custom, SecurityOpt, Device, Cap, Hostname, MacAddress, Image from models import Repository, ImagesData, ImagesResponse, IMAGES_SCHEMA_VERSION @@ -154,22 +154,8 @@ def get_image(repo): tags, stable = get_tags(readme_vars) deprecated = readme_vars.get("project_deprecation_status", None) version, version_timestamp = gh.get_last_stable_release(repo) - return Image( - name=repo.name.replace("docker-", ""), - github_url=repo.html_url, - stars=repo.stargazers_count, - project_url=readme_vars.get("project_url", None), - project_logo=readme_vars.get("project_logo", None), + config = Config( application_setup=f"{repo.html_url}?tab=readme-ov-file#application-setup", - description=get_description(readme_vars), - version=version, - version_timestamp=version_timestamp, - changelog=get_changelogs(readme_vars), - category=categories, - stable=stable, - deprecated=deprecated, - tags=tags, - architectures=get_architectures(readme_vars), readonly_supported=readme_vars.get("readonly_supported", None), nonroot_supported=readme_vars.get("nonroot_supported", None), privileged=readme_vars.get("privileged", None), @@ -184,6 +170,23 @@ def get_image(repo): devices=get_devices(readme_vars), caps=get_caps(readme_vars), ) + return Image( + name=repo.name.replace("docker-", ""), + github_url=repo.html_url, + stars=repo.stargazers_count, + project_url=readme_vars.get("project_url", None), + project_logo=readme_vars.get("project_logo", None), + description=get_description(readme_vars), + version=version, + version_timestamp=version_timestamp, + category=categories, + stable=stable, + deprecated=deprecated, + tags=tags, + architectures=get_architectures(readme_vars), + changelog=get_changelogs(readme_vars), + config=config, + ) def update_images(): with KeyValueStore(invalidate_hours=INVALIDATE_HOURS, readonly=False) as kv: From 7f29e2db0a2df807d25feeb5ee691cfbfa8a7890 Mon Sep 17 00:00:00 2001 From: quietsy Date: Sun, 9 Feb 2025 18:51:59 +0200 Subject: [PATCH 11/15] Remove optional from deprecated --- root/app/models.py | 2 +- root/app/updater.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/root/app/models.py b/root/app/models.py index 840b837..c8b7996 100644 --- a/root/app/models.py +++ b/root/app/models.py @@ -99,7 +99,7 @@ class Image(BaseModel): version_timestamp: str category: str stable: bool - deprecated: bool | None + deprecated: bool stars: int tags: list[Tag] architectures: list[Architecture] diff --git a/root/app/updater.py b/root/app/updater.py index 85d9b0f..0aae22a 100644 --- a/root/app/updater.py +++ b/root/app/updater.py @@ -152,7 +152,7 @@ def get_image(repo): if "Internal" in categories: return None tags, stable = get_tags(readme_vars) - deprecated = readme_vars.get("project_deprecation_status", None) + deprecated = readme_vars.get("project_deprecation_status", False) version, version_timestamp = gh.get_last_stable_release(repo) config = Config( application_setup=f"{repo.html_url}?tab=readme-ov-file#application-setup", From f39941272dd6d6778b0bbd81f8cb9ea686596998 Mon Sep 17 00:00:00 2001 From: quietsy Date: Mon, 10 Feb 2025 11:57:22 +0200 Subject: [PATCH 12/15] Normalize timestamps --- root/app/keyvaluestore.py | 7 ++----- root/app/updater.py | 3 ++- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/root/app/keyvaluestore.py b/root/app/keyvaluestore.py index ac89702..b14dfca 100644 --- a/root/app/keyvaluestore.py +++ b/root/app/keyvaluestore.py @@ -33,16 +33,13 @@ class KeyValueStore(dict): self.conn.commit() self.conn.close() def __contains__(self, key): - where_clause = "" if self.invalidate_hours == 0 else f" AND updated_at >= DATETIME('now', '-{self.invalidate_hours} hours')" + where_clause = "" if self.invalidate_hours == 0 else f" AND updated_at >= DATETIME('now', '-{self.invalidate_hours} hours', 'utc')" return self.conn.execute(f"SELECT 1 FROM kv WHERE key = '{key}' {where_clause}").fetchone() is not None def __getitem__(self, key): item = self.conn.execute("SELECT value FROM kv WHERE key = ?", (key,)).fetchone() return item[0] if item else None - def get_updated_at(self, key): - item = self.conn.execute("SELECT updated_at FROM kv WHERE key = ?", (key,)).fetchone() - return item[0] if item else None def set_value(self, key, value, schema_version): - self.conn.execute("REPLACE INTO kv (key, value, updated_at, schema_version) VALUES (?, ?, CURRENT_TIMESTAMP, ?)", (key, value, schema_version)) + self.conn.execute("REPLACE INTO kv (key, value, updated_at, schema_version) VALUES (?, ?, DATETIME('now', 'utc'), ?)", (key, value, schema_version)) self.conn.commit() def update_schema(self, key, schema_version): is_updated = self.conn.execute(f"SELECT 1 FROM kv WHERE key = '{key}' AND schema_version = {schema_version}").fetchone() is not None diff --git a/root/app/updater.py b/root/app/updater.py index 0aae22a..3f79492 100644 --- a/root/app/updater.py +++ b/root/app/updater.py @@ -203,7 +203,8 @@ def update_images(): continue images.append(image) data = ImagesData(repositories=Repository(linuxserver=images)) - response = ImagesResponse(status="OK", last_updated=kv.get_updated_at("images"), data=data) + last_updated = datetime.datetime.now(datetime.timezone.utc).isoformat(' ', 'seconds') + response = ImagesResponse(status="OK", last_updated=last_updated, data=data) new_state = response.model_dump_json(exclude_none=True) kv.set_value("images", new_state, IMAGES_SCHEMA_VERSION) print(f"{datetime.datetime.now()} - updated images") From 4005a6fcceaf18951320c886ca70e991eaa76ad0 Mon Sep 17 00:00:00 2001 From: quietsy Date: Mon, 10 Feb 2025 16:18:48 +0200 Subject: [PATCH 13/15] Add query params --- root/app/api.py | 24 ++++++++++++++++----- root/app/keyvaluestore.py | 8 ++----- root/app/models.py | 44 +++++++++++++++++++++++---------------- root/app/updater.py | 10 ++++++--- 4 files changed, 54 insertions(+), 32 deletions(-) diff --git a/root/app/api.py b/root/app/api.py index 5a986ff..e7e4729 100644 --- a/root/app/api.py +++ b/root/app/api.py @@ -2,6 +2,7 @@ from fastapi import FastAPI, HTTPException from fastapi.responses import JSONResponse from keyvaluestore import KeyValueStore from models import ImagesResponse +from pydantic import ValidationError import json import traceback @@ -11,12 +12,25 @@ api = FastAPI(docs_url="/", title="LinuxServer API", redoc_url=None, version="1. async def health(): return "Success" -@api.get("/api/v1/images", response_model=ImagesResponse, summary="Get a list of images") -async def images(): +async def get_images(): + with KeyValueStore() as kv: + return kv["images"] + +@api.get("/api/v1/images", response_model=ImagesResponse, summary="Get a list of images", response_model_exclude_none=True) +async def images(include_config: bool = False, include_deprecated: bool = False): try: - with KeyValueStore() as kv: - content = json.loads(kv["images"]) - return JSONResponse(content=content) + response = await get_images() + image_response = ImagesResponse.model_validate_json(response) + if not include_deprecated: + image_response.exclude_deprecated() + if not include_config: + image_response.exclude_config() + return image_response + except ValidationError: + print(traceback.format_exc()) + response = await get_images() + content = json.loads(response) + return JSONResponse(content=content) except Exception: print(traceback.format_exc()) raise HTTPException(status_code=404, detail="Not found") diff --git a/root/app/keyvaluestore.py b/root/app/keyvaluestore.py index b14dfca..396393c 100644 --- a/root/app/keyvaluestore.py +++ b/root/app/keyvaluestore.py @@ -41,9 +41,5 @@ class KeyValueStore(dict): def set_value(self, key, value, schema_version): self.conn.execute("REPLACE INTO kv (key, value, updated_at, schema_version) VALUES (?, ?, DATETIME('now', 'utc'), ?)", (key, value, schema_version)) self.conn.commit() - def update_schema(self, key, schema_version): - is_updated = self.conn.execute(f"SELECT 1 FROM kv WHERE key = '{key}' AND schema_version = {schema_version}").fetchone() is not None - if not is_updated: - self.conn.execute(f"UPDATE kv SET schema_version = {schema_version} WHERE key = '{key}'") - self.conn.commit() - return is_updated + def is_current_schema(self, key, schema_version): + return self.conn.execute(f"SELECT 1 FROM kv WHERE key = '{key}' AND schema_version = {schema_version}").fetchone() is not None diff --git a/root/app/models.py b/root/app/models.py index c8b7996..4d796b8 100644 --- a/root/app/models.py +++ b/root/app/models.py @@ -74,26 +74,26 @@ class MacAddress(BaseModel): optional: bool class Config(BaseModel): - application_setup: str - readonly_supported: bool | None - nonroot_supported: bool | None - privileged: bool | None - networking: str | None - hostname: Hostname | None - mac_address: MacAddress | None - env_vars: list[EnvVar] | None - volumes: list[Volume] | None - ports: list[Port] | None - custom: list[Custom] | None - security_opt: list[SecurityOpt] | None - devices: list[Device] | None - caps: list[Cap] | None + application_setup: str | None = None + readonly_supported: bool | None = None + nonroot_supported: bool | None = None + privileged: bool | None = None + networking: str | None = None + hostname: Hostname | None = None + mac_address: MacAddress | None = None + env_vars: list[EnvVar] | None = None + volumes: list[Volume] | None = None + ports: list[Port] | None = None + custom: list[Custom] | None = None + security_opt: list[SecurityOpt] | None = None + devices: list[Device] | None = None + caps: list[Cap] | None = None class Image(BaseModel): name: str github_url: str - project_url: str | None - project_logo: str | None + project_url: str | None = None + project_logo: str | None = None description: str version: str version_timestamp: str @@ -103,8 +103,8 @@ class Image(BaseModel): stars: int tags: list[Tag] architectures: list[Architecture] - changelog: list[Changelog] | None - config: Config + changelog: list[Changelog] | None = None + config: Config | None = None class Repository(BaseModel): linuxserver: list[Image] @@ -116,3 +116,11 @@ class ImagesResponse(BaseModel): status: str last_updated: str data: ImagesData + + def exclude_config(self): + for image in self.data.repositories.linuxserver: + image.config = None + + def exclude_deprecated(self): + images = self.data.repositories.linuxserver + self.data.repositories.linuxserver = list(filter(lambda image: not image.deprecated, images)) diff --git a/root/app/updater.py b/root/app/updater.py index 3f79492..c387321 100644 --- a/root/app/updater.py +++ b/root/app/updater.py @@ -154,8 +154,11 @@ def get_image(repo): tags, stable = get_tags(readme_vars) deprecated = readme_vars.get("project_deprecation_status", False) version, version_timestamp = gh.get_last_stable_release(repo) + application_setup = None + if readme_vars.get("app_setup_block_enabled", False): + application_setup = f"{repo.html_url}?tab=readme-ov-file#application-setup" config = Config( - application_setup=f"{repo.html_url}?tab=readme-ov-file#application-setup", + application_setup=application_setup, readonly_supported=readme_vars.get("readonly_supported", None), nonroot_supported=readme_vars.get("nonroot_supported", None), privileged=readme_vars.get("privileged", None), @@ -190,8 +193,8 @@ def get_image(repo): def update_images(): with KeyValueStore(invalidate_hours=INVALIDATE_HOURS, readonly=False) as kv: - is_updated = kv.update_schema("images", IMAGES_SCHEMA_VERSION) - if ("images" in kv and is_updated) or CI == "1": + is_current_schema = kv.is_current_schema("images", IMAGES_SCHEMA_VERSION) + if ("images" in kv and is_current_schema) or CI == "1": print(f"{datetime.datetime.now()} - skipped - already updated") return print(f"{datetime.datetime.now()} - updating images") @@ -202,6 +205,7 @@ def update_images(): if not image: continue images.append(image) + data = ImagesData(repositories=Repository(linuxserver=images)) last_updated = datetime.datetime.now(datetime.timezone.utc).isoformat(' ', 'seconds') response = ImagesResponse(status="OK", last_updated=last_updated, data=data) From 76c00e71e6615a39792251f45b0d866e422592b3 Mon Sep 17 00:00:00 2001 From: quietsy Date: Mon, 10 Feb 2025 17:24:35 +0200 Subject: [PATCH 14/15] Add favicon --- root/app/api.py | 10 +++++++++- root/app/static/logo.png | Bin 0 -> 19733 bytes 2 files changed, 9 insertions(+), 1 deletion(-) create mode 100644 root/app/static/logo.png diff --git a/root/app/api.py b/root/app/api.py index e7e4729..31b0afe 100644 --- a/root/app/api.py +++ b/root/app/api.py @@ -1,12 +1,20 @@ from fastapi import FastAPI, HTTPException +from fastapi.openapi.docs import get_swagger_ui_html from fastapi.responses import JSONResponse +from fastapi.staticfiles import StaticFiles from keyvaluestore import KeyValueStore from models import ImagesResponse from pydantic import ValidationError import json import traceback -api = FastAPI(docs_url="/", title="LinuxServer API", redoc_url=None, version="1.0") +api = FastAPI(docs_url=None, redoc_url=None, version="1.0", title="LinuxServer API") +api.mount("/static", StaticFiles(directory="static"), name="static") + + +@api.get("/", include_in_schema=False) +async def swagger_ui_html(): + return get_swagger_ui_html(openapi_url="/openapi.json", title="LinuxServer API", swagger_favicon_url="/static/logo.png") @api.get("/health", summary="Get the health status") async def health(): diff --git a/root/app/static/logo.png b/root/app/static/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..ccd96661d53898a7292fadec9a10fec4e90e9e2d GIT binary patch literal 19733 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≪d=bsmh9G+Z*eYomeP5B{nsZ zcJERYJ4o0|zjXL4#ZmU{LBNj?l5DYHRGqms72y~K^m`8;HV3Ip$z(jLlT4OegB|@o zX(6@BYtZN)mrFZfVIa`FZk?qz2PmXlr+!!x@~_f)={e%l`pMWwidN!Ld1A+}JF=i# z0B9R$rPX!#7f=E#3L8DUye2NY!N)RO+V;55M7MTH3QPTe{`vLa0zU5x0|5K`>X#Bj V-Ti+j^52~S9BnW0eV*R&{{eF)^CtiR literal 0 HcmV?d00001 From a3cc30f09234ad959ec337c1388c456f92bd3e74 Mon Sep 17 00:00:00 2001 From: quietsy Date: Mon, 10 Feb 2025 19:07:44 +0200 Subject: [PATCH 15/15] Add server env var --- README.md | 1 + readme-vars.yml | 1 + root/app/api.py | 4 +++- root/app/lsio_github.py | 1 + root/app/models.py | 1 + 5 files changed, 7 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 9b6983c..7177e3d 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,7 @@ docker run -d \ -e DB_FILE=/config/api.db `#optional` \ -e INVALIDATE_HOURS=24 `#optional` \ -e PAT=token `#optional` \ +-e URL=http://localhost:8000 `#optional` \ -p 8000:8000 \ -v /path/to/lsio-api/config:/config \ --restart unless-stopped \ diff --git a/readme-vars.yml b/readme-vars.yml index e3863eb..aade1b4 100644 --- a/readme-vars.yml +++ b/readme-vars.yml @@ -40,6 +40,7 @@ full_custom_readme: | -e DB_FILE=/config/api.db `#optional` \ -e INVALIDATE_HOURS=24 `#optional` \ -e PAT=token `#optional` \ + -e URL=http://localhost:8000 `#optional` \ -p 8000:8000 \ -v /path/to/lsio-api/config:/config \ --restart unless-stopped \ diff --git a/root/app/api.py b/root/app/api.py index 31b0afe..e176b6a 100644 --- a/root/app/api.py +++ b/root/app/api.py @@ -6,9 +6,11 @@ from keyvaluestore import KeyValueStore from models import ImagesResponse from pydantic import ValidationError import json +import os import traceback -api = FastAPI(docs_url=None, redoc_url=None, version="1.0", title="LinuxServer API") +URL = os.environ.get("URL", "http://localhost:8000") +api = FastAPI(docs_url=None, redoc_url=None, version="1.0", title="LinuxServer API", servers=[{"url": URL}]) api.mount("/static", StaticFiles(directory="static"), name="static") diff --git a/root/app/lsio_github.py b/root/app/lsio_github.py index 7838a62..822fcaf 100644 --- a/root/app/lsio_github.py +++ b/root/app/lsio_github.py @@ -8,6 +8,7 @@ PAT = os.environ.get("PAT", None) GH_AUTH = Auth.Token(PAT) if PAT else None GH = Github(auth=GH_AUTH) + def get_repos(): org = GH.get_organization("linuxserver") return org.get_repos() diff --git a/root/app/models.py b/root/app/models.py index 4d796b8..d0f2956 100644 --- a/root/app/models.py +++ b/root/app/models.py @@ -3,6 +3,7 @@ from pydantic import BaseModel # Increment when updating schema IMAGES_SCHEMA_VERSION = 1 + class Tag(BaseModel): tag: str desc: str