aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--.dockerignore54
-rw-r--r--.forgejo/workflows/ci.yaml90
-rw-r--r--.forgejo/workflows/git-sync.yaml (renamed from .gitea/workflows/git-sync.yaml)0
-rw-r--r--.gitea/workflows/ci.yaml27
-rw-r--r--Dockerfile66
-rw-r--r--Makefile27
-rw-r--r--docker-compose.yml20
-rw-r--r--entrypoint-tor.sh17
-rw-r--r--entrypoint.sh15
-rw-r--r--server.py23
-rw-r--r--tests/test_watch_formats.py2
-rw-r--r--youtube/watch_formats.py2
12 files changed, 306 insertions, 37 deletions
diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..52e3395
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,54 @@
+# Git
+.git/
+.gitignore
+.gitattributes
+.gitea/
+
+# Python artifacts
+__pycache__/
+*.py[cod]
+*.so
+venv/
+.venv/
+*.egg-info/
+
+# Release / build artifacts
+yt-local/
+python/
+get-pip.py
+*.7z
+*.zip
+build/
+dist/
+
+# IDE / editors
+.vscode/
+.idea/
+*.swp
+*.swo
+*~
+
+# Testing / coverage
+.pytest_cache/
+.coverage
+htmlcov/
+tests/
+
+# Data / user config
+data/
+debug/
+settings.txt
+
+# Docs
+docs/
+*.md
+LICENSE
+
+# AI tools
+.kiro/
+.claude/
+.cursor/
+
+# Docker itself
+Dockerfile
+docker-compose*.yml
diff --git a/.forgejo/workflows/ci.yaml b/.forgejo/workflows/ci.yaml
new file mode 100644
index 0000000..c19e1fc
--- /dev/null
+++ b/.forgejo/workflows/ci.yaml
@@ -0,0 +1,90 @@
+name: CI
+
+on:
+ push:
+ branches: [master]
+ pull_request:
+ branches: [master]
+
+env:
+ IMAGE_NAME: rusian/yt-local
+ GRYPE_VERSION: "0.112.0"
+ PLATFORMS: linux/amd64,linux/arm64
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+
+ - name: Set up Python
+ uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
+ with:
+ python-version: "3.11"
+
+ - name: Install dependencies
+ run: |
+ pip install --upgrade pip
+ pip install -r requirements-dev.txt
+
+ - name: Run tests
+ run: pytest -v
+
+ docker:
+ needs: test
+ runs-on: ubuntu-latest
+ if: github.event_name == 'push' && github.ref == 'refs/heads/master'
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+
+ - name: Set up QEMU
+ uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
+
+ - name: Set up Buildx
+ uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
+
+ - name: Login to Docker Hub
+ uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
+ with:
+ username: ${{ secrets.DOCKER_REGISTRY_USER }}
+ password: ${{ secrets.DOCKER_REGISTRY_PASSWORD }}
+
+ - name: Extract metadata
+ id: meta
+ uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
+ with:
+ images: ${{ env.IMAGE_NAME }}
+ tags: |
+ type=sha,prefix=
+ type=raw,value=latest
+
+ - name: Build image for scan (amd64 only)
+ uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
+ with:
+ context: .
+ load: true
+ tags: ${{ env.IMAGE_NAME }}:scan
+
+ - name: Install Grype
+ run: |
+ curl -sSfL -o grype.tar.gz \
+ "https://github.com/anchore/grype/releases/download/v${GRYPE_VERSION}/grype_${GRYPE_VERSION}_linux_amd64.tar.gz"
+ curl -sSfL -o checksums.txt \
+ "https://github.com/anchore/grype/releases/download/v${GRYPE_VERSION}/grype_${GRYPE_VERSION}_checksums.txt"
+ sha256sum --check --ignore-missing checksums.txt
+ tar -xzf grype.tar.gz -C /usr/local/bin grype
+ grype version
+
+ - name: Scan image for vulnerabilities
+ run: grype ${{ env.IMAGE_NAME }}:scan --fail-on critical --output table
+
+ - name: Build and push (multi-platform)
+ uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
+ with:
+ context: .
+ platforms: ${{ env.PLATFORMS }}
+ push: true
+ tags: ${{ steps.meta.outputs.tags }}
+ labels: ${{ steps.meta.outputs.labels }}
diff --git a/.gitea/workflows/git-sync.yaml b/.forgejo/workflows/git-sync.yaml
index f1028c5..f1028c5 100644
--- a/.gitea/workflows/git-sync.yaml
+++ b/.forgejo/workflows/git-sync.yaml
diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml
deleted file mode 100644
index 77f89a6..0000000
--- a/.gitea/workflows/ci.yaml
+++ /dev/null
@@ -1,27 +0,0 @@
-name: CI
-
-on:
- push:
- branches: [master]
- pull_request:
- branches: [master]
-
-jobs:
- test:
- runs-on: ubuntu-latest
- steps:
- - name: Checkout code
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
-
- - name: Set up Python
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
- with:
- python-version: "3.11"
-
- - name: Install dependencies
- run: |
- pip install --upgrade pip
- pip install -r requirements-dev.txt
-
- - name: Run tests
- run: pytest -v
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..cfee5bc
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,66 @@
+# =============================================================================
+# yt-local — multi-stage, non-root, Tor-ready
+# =============================================================================
+
+# --------------- build stage ---------------
+FROM python:3.11-alpine AS builder
+
+ENV PIP_NO_CACHE_DIR=1 \
+ PIP_DISABLE_PIP_VERSION_CHECK=1 \
+ PYTHONDONTWRITEBYTECODE=1
+
+RUN apk add --no-cache build-base libffi-dev
+
+COPY requirements.txt /tmp/requirements.txt
+RUN pip install --prefix=/install --no-cache-dir -r /tmp/requirements.txt
+
+# --------------- runtime stage ---------------
+FROM python:3.11-alpine
+
+LABEL maintainer="heckyel@riseup.net"
+LABEL org.opencontainers.image.source="https://git.sr.ht/~heckyel/yt-local"
+LABEL org.opencontainers.image.licenses="AGPL-3.0"
+
+ENV LANG=C.UTF-8 \
+ PYTHONDONTWRITEBYTECODE=1 \
+ PYTHONUNBUFFERED=1 \
+ HOME=/home/appuser
+
+# tor package creates its own tor user/group and /var/lib/tor
+# su-exec for privilege drop in entrypoint
+RUN apk add --no-cache tor su-exec
+
+# App user (non-root)
+RUN addgroup -g 1000 -S appgroup \
+ && adduser -u 1000 -S appuser -G appgroup -h /home/appuser
+
+# Python packages from builder
+COPY --from=builder /install /usr/local
+
+# Application source (root-owned, read-only for appuser)
+WORKDIR /srv/app
+COPY --chown=root:root . /srv/app/
+
+# Compile translations (.po → .mo)
+RUN python manage_translations.py compile
+
+# Persistent data dir (settings.txt, data/, etc.)
+RUN mkdir -p /home/appuser/.yt-local/data \
+ && chown -R appuser:appgroup /home/appuser/.yt-local \
+ && chmod 750 /home/appuser/.yt-local
+
+VOLUME /home/appuser/.yt-local
+
+# Tor: fix perms on dirs already created by apk
+RUN chown tor:tor /var/lib/tor /var/log/tor \
+ && chmod 700 /var/lib/tor /var/log/tor
+
+# Entrypoints (root-owned, executable)
+COPY --chmod=755 entrypoint.sh entrypoint-tor.sh /
+
+EXPOSE 9010
+
+HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
+ CMD sh -c 'python -c "import urllib.request; urllib.request.urlopen(\"http://127.0.0.1:9010\")"'
+
+ENTRYPOINT ["/entrypoint.sh"]
diff --git a/Makefile b/Makefile
index de17745..9805871 100644
--- a/Makefile
+++ b/Makefile
@@ -4,7 +4,7 @@
.PHONY: help install dev clean test i18n-extract i18n-init i18n-update \
i18n-compile i18n-stats i18n-clean setup-dev lint format backup \
restore distclean info check-deps run test-cov i18n-workflow \
- ensure-venv
+ ensure-venv docker docker-run docker-stop docker-logs docker-clean
# Variables
SYSTEM_PYTHON := python3
@@ -197,6 +197,31 @@ restore: ## Restore translations from latest backup
echo "[ERROR] No backup files found"; \
fi
+## Docker ---------------------------------------------------------------------
+
+docker: ## Build Docker image
+ @echo "[INFO] Building Docker image..."
+ docker compose build
+ @echo "[SUCCESS] Image built"
+
+docker-run: ## Start container (use ENABLE_TOR=1 for Tor)
+ @echo "[INFO] Starting container..."
+ ENABLE_TOR=$(or $(ENABLE_TOR),0) docker compose up -d
+ @echo "[SUCCESS] Container running at http://localhost:9010"
+
+docker-stop: ## Stop container
+ @echo "[INFO] Stopping container..."
+ docker compose down
+ @echo "[SUCCESS] Container stopped"
+
+docker-logs: ## Show container logs
+ docker compose logs -f
+
+docker-clean: docker-stop ## Remove container, image, and volume
+ @echo "[INFO] Removing Docker artefacts..."
+ docker compose down -v --rmi local
+ @echo "[SUCCESS] Docker artefacts removed"
+
## Cleanup --------------------------------------------------------------------
clean: ## Clean temporary files, caches, and release artefacts
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..6b28926
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,20 @@
+services:
+ yt-local:
+ build: .
+ container_name: yt-local
+ restart: unless-stopped
+ ports:
+ - "127.0.0.1:9010:9010"
+ volumes:
+ - yt-local-data:/home/appuser/.yt-local
+ environment:
+ - ENABLE_TOR=0
+ healthcheck:
+ test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:9010')"]
+ interval: 30s
+ timeout: 5s
+ start_period: 10s
+ retries: 3
+
+volumes:
+ yt-local-data:
diff --git a/entrypoint-tor.sh b/entrypoint-tor.sh
new file mode 100644
index 0000000..0aaa030
--- /dev/null
+++ b/entrypoint-tor.sh
@@ -0,0 +1,17 @@
+#!/bin/sh
+set -eu
+
+TORRC="/var/lib/tor/torrc"
+
+# Generate a minimal torrc if none is mounted
+if [ ! -f "$TORRC" ]; then
+ echo "[tor] No torrc found, generating default..."
+ cat > "$TORRC" <<EOF
+SocksPort 0.0.0.0:9050
+DataDirectory /var/lib/tor
+Log notice file /var/log/tor/notices.log
+EOF
+fi
+
+echo "[tor] Starting Tor..."
+exec tor -f "$TORRC"
diff --git a/entrypoint.sh b/entrypoint.sh
new file mode 100644
index 0000000..a63a188
--- /dev/null
+++ b/entrypoint.sh
@@ -0,0 +1,15 @@
+#!/bin/sh
+set -eu
+
+echo "[entrypoint] Starting yt-local..."
+
+# Optionally start Tor in the background as the tor user
+if [ "${ENABLE_TOR:-0}" = "1" ]; then
+ echo "[entrypoint] Launching Tor daemon..."
+ su-exec tor /entrypoint-tor.sh &
+ sleep 3
+fi
+
+# Drop to appuser. Bind to all interfaces — container networking
+# requires 0.0.0.0; actual access is controlled by Docker (-p flag).
+exec su-exec appuser python server.py --bind 0.0.0.0
diff --git a/server.py b/server.py
index 7f0100c..37a8c6b 100644
--- a/server.py
+++ b/server.py
@@ -276,17 +276,26 @@ class FilteredRequestLog:
if __name__ == '__main__':
- if settings.allow_foreign_addresses:
- # Binding to all interfaces is opt-in via the
- # `allow_foreign_addresses` setting and documented as discouraged.
- server = WSGIServer(('0.0.0.0', settings.port_number), site_dispatch,
- log=FilteredRequestLog())
+ # Bind address: --bind flag overrides, then allow_foreign_addresses setting,
+ # then default to localhost only.
+ import argparse
+ parser = argparse.ArgumentParser(description='yt-local server')
+ parser.add_argument(
+ '--bind', default=None, metavar='ADDR',
+ help='Address to bind to (default: 127.0.0.1, or 0.0.0.0 if allow_foreign_addresses)',
+ )
+ args = parser.parse_args()
+
+ if args.bind:
+ ip_server = args.bind
+ elif settings.allow_foreign_addresses:
ip_server = '0.0.0.0'
else:
- server = WSGIServer(('127.0.0.1', settings.port_number), site_dispatch,
- log=FilteredRequestLog())
ip_server = '127.0.0.1'
+ server = WSGIServer((ip_server, settings.port_number), site_dispatch,
+ log=FilteredRequestLog())
+
print('Starting httpserver at http://%s:%s/' %
(ip_server, settings.port_number))
diff --git a/tests/test_watch_formats.py b/tests/test_watch_formats.py
index 85a103b..e1b31fd 100644
--- a/tests/test_watch_formats.py
+++ b/tests/test_watch_formats.py
@@ -69,4 +69,4 @@ class TestFormatBytes:
assert watch_formats.format_bytes(1024) == '1.00KiB'
def test_mebibytes(self):
- assert watch_formats.format_bytes(1048576) == '1.00MiB' \ No newline at end of file
+ assert watch_formats.format_bytes(1048576) == '1.00MiB'
diff --git a/youtube/watch_formats.py b/youtube/watch_formats.py
index 7dad325..14711df 100644
--- a/youtube/watch_formats.py
+++ b/youtube/watch_formats.py
@@ -79,4 +79,4 @@ __all__ = [
'short_video_quality_string',
'audio_quality_string',
'format_bytes',
-] \ No newline at end of file
+]