Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand Down
1 change: 1 addition & 0 deletions readme-vars.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand Down
44 changes: 38 additions & 6 deletions root/app/api.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,49 @@
from fastapi import FastAPI
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, ImagesData
from models import ImagesResponse
from pydantic import ValidationError
import json
import os
import traceback

api = FastAPI(docs_url="/")
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")


@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():
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 ImagesResponse(status="OK", data=ImagesData.model_validate_json(kv["images"]))
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:
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")

if __name__ == "__main__":
api.run()
23 changes: 19 additions & 4 deletions root/app/keyvaluestore.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,27 @@
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):
Expand All @@ -21,10 +33,13 @@ def __exit__(self, exc_type, exc_val, exc_tb):
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', '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 __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 (?, ?, DATETIME('now', 'utc'), ?)", (key, value, schema_version))
self.conn.commit()
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
38 changes: 38 additions & 0 deletions root/app/lsio_github.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
from github import Auth
from github import Github

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():
org = GH.get_organization("linuxserver")
return org.get_repos()

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):
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():
ratelimit = GH.get_rate_limit().core
print(f"Github ratelimit - {ratelimit.remaining}/{ratelimit.limit} resets at {ratelimit.reset}")
108 changes: 108 additions & 0 deletions root/app/models.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,111 @@
from pydantic import BaseModel

# Increment when updating schema
IMAGES_SCHEMA_VERSION = 1


class Tag(BaseModel):
tag: str
desc: str

class Architecture(BaseModel):
arch: str
tag: str

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 | list[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 Config(BaseModel):
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 = None
project_logo: str | None = None
description: str
version: str
version_timestamp: str
category: str
stable: bool
deprecated: bool
stars: int
tags: list[Tag]
architectures: list[Architecture]
changelog: list[Changelog] | None = None
config: Config | None = None

class Repository(BaseModel):
linuxserver: list[Image]
Expand All @@ -16,4 +115,13 @@ class ImagesData(BaseModel):

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))
Binary file added root/app/static/logo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading