diff --git a/.changeset/QUICK_REFERENCE.md b/.changeset/QUICK_REFERENCE.md deleted file mode 100644 index 0ab7cb9..0000000 --- a/.changeset/QUICK_REFERENCE.md +++ /dev/null @@ -1,155 +0,0 @@ -# Changeset Quick Reference - -## Quick Commands - -```bash -# Add a changeset for your changes -pnpm changeset - -# Check what will be versioned -pnpm changeset:status - -# Apply version bumps (maintainers only) -pnpm version - -# Build and publish (maintainers only) -pnpm release -``` - -## When to Use Each Bump Type - -### Patch (0.0.X) - Bug Fixes -- šŸ› Bug fixes -- šŸ“ Documentation -- šŸŽØ Style/formatting -- ā™»ļø Refactoring (no API change) -- ⚔ Performance (no breaking change) - -**Example:** -``` -šŸ¦‹ Summary: Fix authentication token expiry bug -šŸ¦‹ Bump: patch -``` - -### Minor (0.X.0) - New Features -- ✨ New features -- šŸ”„ Deprecations -- šŸš€ Enhancements -- šŸ“¦ New optional parameters - -**Example:** -``` -šŸ¦‹ Summary: Add user profile image upload endpoint -šŸ¦‹ Bump: minor -``` - -### Major (X.0.0) - Breaking Changes -- šŸ’„ Breaking API changes -- āŒ Removed features -- šŸ”„ Changed behavior -- āš ļø Required parameter changes - -**Example:** -``` -šŸ¦‹ Summary: Replace REST auth with OAuth2 (BREAKING) -šŸ¦‹ Bump: major -``` - -## Changeset Flow - -```mermaid -graph LR - A[Make Changes] --> B[Create Changeset] - B --> C[Commit & Push] - C --> D[Create PR to dev] - D --> E[Review & Merge] - E --> F[Version Bump] - F --> G[Merge to main] - G --> H[Auto Release] -``` - -## Example Changeset File - -```markdown ---- -"api": minor ---- - -Add WebSocket support for real-time notifications - -- New `/ws` endpoint for WebSocket connections -- Real-time event streaming -- Connection management utilities -``` - -## Commit Message Examples - -With `pnpm commit`: - -```bash -# Feature -feat(api): add user search endpoint - -# Bug fix -fix(api): resolve JWT validation error - -# Breaking change -feat(api)!: redesign authentication API - -BREAKING CHANGE: Replace /auth/login with OAuth2 -``` - -## PR Workflow - -1. **Create feature branch from `dev`** - ```bash - git checkout dev && git pull - git checkout -b feat/my-feature - ``` - -2. **Make changes and commit** - ```bash - git add . - pnpm commit - ``` - -3. **Add changeset** - ```bash - pnpm changeset - git add .changeset - git commit -m "chore: add changeset" - ``` - -4. **Push and create PR** - ```bash - git push -u origin feat/my-feature - ``` - -5. **After merge to dev → version bump** - ```bash - pnpm version - git commit -am "chore: version packages" - ``` - -6. **Merge to main → auto-release** - -## Tips - -āœ… **DO** -- Create changesets for user-facing changes -- Write clear, descriptive summaries -- One changeset per logical change -- Review generated CHANGELOGs - -āŒ **DON'T** -- Skip changesets for features/fixes -- Bundle multiple features in one changeset -- Forget to commit the changeset file -- Edit CHANGELOGs manually - -## Need Help? - -- šŸ“– [Full Documentation](../VERSIONING.md) -- šŸ”— [Changesets Docs](https://github.com/changesets/changesets) -- šŸ”— [Semantic Versioning](https://semver.org/) - diff --git a/.changeset/README.md b/.changeset/README.md deleted file mode 100644 index e5b6d8d..0000000 --- a/.changeset/README.md +++ /dev/null @@ -1,8 +0,0 @@ -# Changesets - -Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works -with multi-package repos, or single-package repos to help you version and publish your code. You can -find the full documentation for it [in our repository](https://github.com/changesets/changesets) - -We have a quick list of common questions to get you started engaging with this project in -[our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) diff --git a/.changeset/api-improvements.md b/.changeset/api-improvements.md new file mode 100644 index 0000000..c3e47cd --- /dev/null +++ b/.changeset/api-improvements.md @@ -0,0 +1,49 @@ +--- +"api": minor +--- + +Add significant API improvements and new features + +**Version Management:** + +- Version matching system between API and clients +- Automatic version validation middleware +- Client version compatibility checking with strict semantic versioning +- Version information in health endpoint +- API version exposed via response headers (`X-API-Version`) +- Version mismatch error handling (HTTP 426) + +**Media Scanning:** + +- Batch scanning with job tracking and resume capability +- Scan job status tracking in database (PENDING, IN_PROGRESS, COMPLETED, FAILED, PAUSED) +- Progress tracking with batch processing +- Folder-level batch processing for large libraries +- Improved file detection with configurable regex patterns +- Media type detection (movies, TV shows, music, comics) +- Path validation and sanitization +- Rate limiting for TMDB API calls +- Timeout handling for long-running scans + +**Color Extraction & Mesh Gradients:** + +- Automatic color extraction from media posters/backdrops +- Mesh gradient generation with 4-corner color mapping +- Color caching in database for performance +- On-demand color extraction middleware +- Background color extraction for non-blocking requests + +**Logging & Monitoring:** + +- Real-time logs streaming with WebSocket support +- REST endpoints for log retrieval +- Log filtering by level (error, warn, info, debug) +- Log file parsing and structured log entries +- Log clearing functionality + +**API Improvements:** + +- Unified response structure across all endpoints +- Enhanced Swagger/OpenAPI documentation +- Improved error handling and validation +- Input sanitization middleware diff --git a/.changeset/publish-cli.md b/.changeset/publish-cli.md new file mode 100644 index 0000000..fa73ad4 --- /dev/null +++ b/.changeset/publish-cli.md @@ -0,0 +1,5 @@ +--- +"@desterlib/cli": minor +--- + +Initial release of DesterLib CLI tool for easy Docker-based setup and configuration diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index a6c4476..40d5d8f 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -50,4 +50,3 @@ ## Additional Notes - diff --git a/.github/workflows/changeset-check.yml b/.github/workflows/changeset-check.yml index a831c5f..f499538 100644 --- a/.github/workflows/changeset-check.yml +++ b/.github/workflows/changeset-check.yml @@ -1,4 +1,4 @@ -name: Changeset Check +name: CI on: pull_request: @@ -6,15 +6,19 @@ on: - main jobs: - changeset-check: - name: Check for Changeset + ci: + name: Validate PR (changesets & versioning) runs-on: ubuntu-latest steps: - name: Checkout Repository uses: actions/checkout@v4 with: + # Fetch full history of the current ref so git operations behave like locally fetch-depth: 0 + - name: Ensure main branch is available + run: git fetch origin main:main + - name: Setup pnpm uses: pnpm/action-setup@v4 with: @@ -24,7 +28,7 @@ jobs: uses: actions/setup-node@v4 with: node-version: 20 - cache: 'pnpm' + cache: "pnpm" - name: Install Dependencies run: pnpm install --frozen-lockfile @@ -37,7 +41,7 @@ jobs: echo "Skipping changeset check for version PR" exit 0 fi - + # Skip check for chore commits that don't need changesets if [[ "${{ github.head_ref }}" == chore/* ]] || \ [[ "${{ github.head_ref }}" == docs/* ]] || \ @@ -45,10 +49,13 @@ jobs: echo "Skipping changeset check for chore/docs/ci PR" exit 0 fi - + # Check if changesets exist pnpm changeset status --since=origin/${{ github.base_ref }} + - name: Verify Versioning Setup + run: pnpm verify:versioning + - name: Comment on PR if: failure() uses: actions/github-script@v7 @@ -58,6 +65,5 @@ jobs: issue_number: context.issue.number, owner: context.repo.owner, repo: context.repo.repo, - body: 'āš ļø **No changeset found**\n\nThis PR appears to contain changes that should have a changeset.\n\nPlease run `pnpm changeset` and commit the generated changeset file.\n\nIf this PR doesn\'t need a changeset (e.g., docs, tests, or internal changes), you can ignore this message.' + body: 'āš ļø **Versioning verification failed**\n\nPlease run `pnpm verify:versioning` locally to check the issues.\n\nCommon issues:\n- Missing changesets: Run `pnpm changeset`\n- Changelog sync issues: Run `pnpm changelog:sync`\n- Sidebar configuration: Check `apps/docs/astro.config.mjs`\n\nSee [Pre-Merge Checklist](.github/PRE_MERGE_CHECKLIST.md) for details.' }) - diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml deleted file mode 100644 index 7853971..0000000 --- a/.github/workflows/deploy-docs.yml +++ /dev/null @@ -1,67 +0,0 @@ -name: Deploy Docs to GitHub Pages - -on: - # Runs on pushes targeting the default branch - push: - branches: ["main"] - paths: - - 'apps/docs/**' - - '.github/workflows/deploy-docs.yml' - - # Allows you to run this workflow manually from the Actions tab - workflow_dispatch: - -# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages -permissions: - contents: read - pages: write - id-token: write - -# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. -# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. -concurrency: - group: "pages" - cancel-in-progress: false - -jobs: - build: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup pnpm - uses: pnpm/action-setup@v4 - with: - version: 9.0.0 - - - name: Setup Node - uses: actions/setup-node@v4 - with: - node-version: "18" - cache: "pnpm" - - - name: Setup Pages - uses: actions/configure-pages@v4 - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - - name: Build with Turbo - run: pnpm turbo build --filter=docs - - - name: Upload artifact - uses: actions/upload-pages-artifact@v3 - with: - path: ./apps/docs/dist - - deploy: - environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} - runs-on: ubuntu-latest - needs: build - steps: - - name: Deploy to GitHub Pages - id: deployment - uses: actions/deploy-pages@v4 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fc60f1f..f33c009 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,4 +1,4 @@ -name: Release +name: Release & Docs on: push: @@ -11,18 +11,24 @@ permissions: contents: write pull-requests: write packages: write + pages: write + id-token: write jobs: release: - name: Release + name: Release packages & deploy docs runs-on: ubuntu-latest steps: - name: Checkout Repository uses: actions/checkout@v4 with: + # Fetch full history of the current ref so git operations behave like locally fetch-depth: 0 token: ${{ secrets.PAT_TOKEN }} + - name: Ensure main branch is available + run: git fetch origin main:main + - name: Setup pnpm uses: pnpm/action-setup@v4 with: @@ -32,7 +38,7 @@ jobs: uses: actions/setup-node@v4 with: node-version: 20 - cache: 'pnpm' + cache: "pnpm" - name: Install Dependencies run: pnpm install --frozen-lockfile @@ -43,14 +49,17 @@ jobs: - name: Build Packages run: pnpm build + - name: Verify Versioning Setup + run: pnpm verify:versioning + - name: Create Release Pull Request or Publish id: changesets uses: changesets/action@v1 with: version: pnpm version publish: pnpm release - commit: 'chore: version packages' - title: 'chore: version packages' + commit: "chore: version packages" + title: "chore: version packages" env: GITHUB_TOKEN: ${{ secrets.PAT_TOKEN }} NPM_TOKEN: ${{ secrets.NPM_TOKEN }} @@ -64,7 +73,30 @@ jobs: tag_name: v${{ steps.changesets.outputs.publishedPackages[0].version }} release_name: Release v${{ steps.changesets.outputs.publishedPackages[0].version }} body: | - See [CHANGELOG.md](https://github.com/${{ github.repository }}/blob/main/CHANGELOG.md) for details. + See [Changelog](https://desterlib.github.io/desterlib/changelog) for details. + + Package changelogs: + - [API](../blob/main/apps/api/CHANGELOG.md) + - [CLI](../blob/main/packages/cli/CHANGELOG.md) + - [Docs](../blob/main/apps/docs/CHANGELOG.md) draft: false prerelease: false + # Build and deploy docs to GitHub Pages only when a new release is published + - name: Setup Pages + if: steps.changesets.outputs.published == 'true' + uses: actions/configure-pages@v4 + + - name: Build Docs + if: steps.changesets.outputs.published == 'true' + run: pnpm turbo build --filter=docs + + - name: Upload Docs Artifact + if: steps.changesets.outputs.published == 'true' + uses: actions/upload-pages-artifact@v3 + with: + path: ./apps/docs/dist + + - name: Deploy Docs to GitHub Pages + if: steps.changesets.outputs.published == 'true' + uses: actions/deploy-pages@v4 diff --git a/.github/workflows/version-pr.yml b/.github/workflows/version-pr.yml deleted file mode 100644 index 1d74dea..0000000 --- a/.github/workflows/version-pr.yml +++ /dev/null @@ -1,79 +0,0 @@ -name: Version PR - -on: - workflow_dispatch: - push: - branches: - - main - paths: - - '.changeset/**' - -permissions: - contents: write - pull-requests: write - -jobs: - version-pr: - name: Create Version PR - runs-on: ubuntu-latest - steps: - - name: Checkout Repository - uses: actions/checkout@v4 - with: - fetch-depth: 0 - token: ${{ secrets.PAT_TOKEN }} - - - name: Setup pnpm - uses: pnpm/action-setup@v4 - with: - version: 9.0.0 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: 20 - cache: 'pnpm' - - - name: Install Dependencies - run: pnpm install --frozen-lockfile - - - name: Check for Changesets - id: check-changesets - run: | - if [ -n "$(ls -A .changeset/*.md 2>/dev/null | grep -v README)" ]; then - echo "has-changesets=true" >> $GITHUB_OUTPUT - else - echo "has-changesets=false" >> $GITHUB_OUTPUT - fi - - - name: Create Version Branch - if: steps.check-changesets.outputs.has-changesets == 'true' - run: | - git config --local user.email "github-actions[bot]@users.noreply.github.com" - git config --local user.name "github-actions[bot]" - git checkout -b chore/version-packages-$(date +%s) - pnpm version - git add . - git commit -m "chore: version packages" || echo "No changes to commit" - git push origin HEAD - - - name: Create Pull Request - if: steps.check-changesets.outputs.has-changesets == 'true' - uses: peter-evans/create-pull-request@v6 - with: - token: ${{ secrets.PAT_TOKEN }} - branch: chore/version-packages-${{ github.run_number }} - base: main - title: 'chore: version packages' - body: | - This PR was automatically generated by the Version PR workflow. - - It bumps package versions and updates changelogs based on changesets. - - **Please review the changes before merging!** - - Once merged to `main`, the release workflow will automatically publish the packages. - labels: | - automated - release - diff --git a/.gitignore b/.gitignore index 88ad12a..9d8aee9 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,5 @@ yarn-error.log* # Misc .DS_Store *.pem +SOLO_DEV_WORKFLOW.md +apps/docs/.astro/ diff --git a/.vscode/settings.json b/.vscode/settings.json index 44a73ec..0241ff1 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -3,5 +3,10 @@ { "mode": "auto" } - ] + ], + "editor.formatOnSave": true, + "editor.defaultFormatter": "esbenp.prettier-vscode", + "[astro]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + } } diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 37c5f35..c125a2d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,58 +2,11 @@ Thank you for your interest in contributing to DesterLib! šŸŽ‰ -## šŸ“– Documentation +## Documentation -All contribution guidelines are maintained in the **DesterLib Documentation**: +šŸ“– **[Contributing Guide](https://desterlib.github.io/desterlib/development/contributing)** -šŸ‘‰ **[View Contributing Guide](https://desterlib.github.io/desterlib/development/contributing)** - -The documentation covers: -- **General Guidelines** - Commit conventions, versioning, PR process (applies to all projects) -- **API Server Development** - Backend development setup -- **Client Development** - Mobile, desktop, and TV app development -- **Code Standards** - Style guides and best practices - -## šŸš€ Quick Start - -```bash -# 1. Fork and clone -git clone https://github.com/YOUR-USERNAME/desterlib.git -cd desterlib - -# 2. Install dependencies -pnpm install - -# 3. Start database -docker-compose -f docker-compose.test.yml up -d - -# 4. Start API -cd apps/api -pnpm dev - -# 5. Make commits -pnpm commit - -# 6. Add changeset (for user-facing changes) -pnpm changeset - -# 7. Push and create PR -``` - -## šŸ“š Additional Resources - -- [API Server Overview](https://desterlib.github.io/desterlib/api/overview) - Backend development -- [Client Overview](https://desterlib.github.io/desterlib/clients/overview) - Client development -- [Project Structure](https://desterlib.github.io/desterlib/development/structure) - Codebase organization -- [Commit Guidelines](https://desterlib.github.io/desterlib/development/commit-guidelines) - Commit conventions -- [Versioning Guide](https://desterlib.github.io/desterlib/development/versioning) - Version management - -## šŸ’¬ Need Help? - -- šŸ› [Report Issues](https://github.com/DesterLib/desterlib/issues) -- šŸ’¬ [GitHub Discussions](https://github.com/DesterLib/desterlib/discussions) -- šŸ“– [Documentation](https://desterlib.github.io/desterlib) -- šŸ“š [API Docs](http://localhost:3001/api/docs) (when running locally) +All contribution guidelines, setup instructions, and development workflows are maintained in the [DesterLib Documentation](https://desterlib.github.io/desterlib/development/contributing). ## License diff --git a/README.md b/README.md index 2ba87ba..562fb70 100644 --- a/README.md +++ b/README.md @@ -2,137 +2,26 @@ **Your Personal Media Server** - Self-hosted media streaming for movies and TV shows. -[![GitHub](https://img.shields.io/badge/GitHub-DesterLib-blue?logo=github)](https://github.com/DesterLib/desterlib) -[![License](https://img.shields.io/badge/License-AGPL--3.0-blue.svg)](LICENSE) -[![Documentation](https://img.shields.io/badge/docs-desterlib-blue)](https://desterlib.github.io/desterlib) +## Key Features ---- - -## What is DesterLib? - -DesterLib is a modern, self-hosted media server that lets you: -- šŸ“š **Organize** your media collection automatically -- šŸŽžļø **Stream** movies and TV shows smoothly -- šŸ“± **Watch** on any device (mobile, desktop, TV) -- šŸŽØ **Beautiful UI** with automatic metadata and artwork - -**Components:** -- **API Server** (this repo) - Backend for media management and streaming -- **Client Apps** - Mobile and desktop applications ([desterlib-flutter](https://github.com/DesterLib/desterlib-flutter)) - ---- - -## šŸš€ Quick Start - -### Using Docker (Recommended) - -```bash -# Clone the repository -git clone https://github.com/DesterLib/desterlib.git -cd desterlib - -# Start all services -docker-compose up -d - -# Access API: http://localhost:3001 -# API Docs: http://localhost:3001/api/docs -``` +- šŸ“š **Automatic Organization** - Intelligent media scanning with TMDB metadata and artwork integration +- šŸš€ **Easy Setup** - One command CLI tool (`npx @desterlib/cli`) to get started in minutes +- šŸŽžļø **Direct Streaming** - Stream media files directly with HTTP range support for seamless playback +- šŸ“± **Multi-Platform** - Native apps for iOS, Android, macOS, Linux, and Windows +- šŸ–„ļø **Self-Hosted** - Full control over your media library and data +- šŸ”“ **Open Source** - Fully open source with active community development -### Development Setup +## Documentation -```bash -# Install dependencies -pnpm install - -# Start test database -docker-compose -f docker-compose.test.yml up -d - -# Run API server -cd apps/api -pnpm dev -``` - ---- - -## šŸ“š Documentation - -**šŸ“– Full Documentation:** [desterlib.github.io/desterlib](https://desterlib.github.io/desterlib) - -### Quick Links - -- [Getting Started](https://desterlib.github.io/desterlib/getting-started/quick-start) - Installation and setup -- [API Server Guide](https://desterlib.github.io/desterlib/api/overview) - Backend development -- [Client Apps](https://desterlib.github.io/desterlib/clients/overview) - Mobile & desktop apps -- [Contributing](https://desterlib.github.io/desterlib/development/contributing) - How to contribute -- [API Docs](http://localhost:3001/api/docs) - Interactive API documentation (when running) - ---- +šŸ“– **[Full Documentation](https://desterlib.github.io/desterlib)** -## šŸ¤ Contributing - -We welcome contributions! Please see our [Contributing Guide](https://desterlib.github.io/desterlib/development/contributing). - -**Quick Start:** -```bash -# Fork, clone, and create branch -git checkout -b feat/your-feature - -# Make changes with conventional commits -pnpm commit - -# Add changeset for user-facing changes -pnpm changeset - -# Push and create PR -git push origin feat/your-feature -``` - -**Resources:** -- [Contributing Guide](CONTRIBUTING.md) - Quick start -- [Commit Guidelines](https://desterlib.github.io/desterlib/development/commit-guidelines) -- [Versioning Guide](https://desterlib.github.io/desterlib/development/versioning) - ---- - -## šŸ—ļø Project Structure - -``` -desterlib/ -ā”œā”€ā”€ apps/ -│ ā”œā”€ā”€ api/ # Backend API (Node.js + TypeScript + Express) -│ └── docs/ # Documentation (Astro + Starlight) -└── packages/ - ā”œā”€ā”€ eslint-config/ # Shared ESLint configuration - └── typescript-config/ # Shared TypeScript configuration -``` - ---- - -## šŸ“¦ Features - -- āœ… Automatic media scanning and organization -- āœ… TMDB metadata and artwork integration -- āœ… Video streaming with transcoding support -- āœ… Watch progress tracking -- āœ… REST API + WebSocket support -- āœ… Docker-ready deployment -- āœ… Cross-platform clients (Android, iOS, macOS, Linux, Windows) - ---- - -## šŸ’¬ Support - -- šŸ“– [Documentation](https://desterlib.github.io/desterlib) -- šŸ› [Report Issues](https://github.com/DesterLib/desterlib/issues) -- šŸ’¬ [Discussions](https://github.com/DesterLib/desterlib/discussions) - ---- +For installation, usage, API reference, and more, visit the [documentation site](https://desterlib.github.io/desterlib). -## šŸ“„ License +## License -GNU Affero General Public License v3.0 (AGPL-3.0) +This project is licensed under the GNU Affero General Public License v3.0 (AGPL-3.0). -This ensures the software remains free and open source forever. See [LICENSE](LICENSE) for details. +See [LICENSE](LICENSE) for details. --- diff --git a/apps/api/CODE_STRUCTURE.md b/apps/api/CODE_STRUCTURE.md deleted file mode 100644 index 94f567a..0000000 --- a/apps/api/CODE_STRUCTURE.md +++ /dev/null @@ -1,192 +0,0 @@ -# API File Structure - -This document outlines the reorganized file structure of the DesterLib API following clean architecture principles. - -## šŸ“ Directory Structure - -``` -apps/api/src/ -ā”œā”€ā”€ core/ # Core application configuration and services -│ ā”œā”€ā”€ config/ # Application configuration -│ │ ā”œā”€ā”€ env.ts # Environment configuration -│ │ ā”œā”€ā”€ routes.ts # Route setup and static file serving -│ │ ā”œā”€ā”€ settings.ts # User settings management -│ │ └── index.ts # Config exports -│ └── services/ # Shared business services -│ ā”œā”€ā”€ genreService.ts # Genre management service -│ └── index.ts # Services exports -ā”œā”€ā”€ domains/ # Domain-driven modules -│ ā”œā”€ā”€ library/ # Library management domain -│ │ ā”œā”€ā”€ library.controller.ts -│ │ ā”œā”€ā”€ library.routes.ts -│ │ ā”œā”€ā”€ library.schema.ts -│ │ ā”œā”€ā”€ library.services.ts -│ │ ā”œā”€ā”€ library.types.ts -│ │ └── index.ts -│ ā”œā”€ā”€ movies/ # Movies domain -│ │ ā”œā”€ā”€ movies.controller.ts -│ │ ā”œā”€ā”€ movies.routes.ts -│ │ ā”œā”€ā”€ movies.schema.ts -│ │ ā”œā”€ā”€ movies.services.ts -│ │ ā”œā”€ā”€ movies.types.ts -│ │ └── index.ts -│ ā”œā”€ā”€ scan/ # Media scanning domain -│ │ ā”œā”€ā”€ scan.controller.ts -│ │ ā”œā”€ā”€ scan.routes.ts -│ │ ā”œā”€ā”€ scan.schema.ts -│ │ ā”œā”€ā”€ scan.services.ts -│ │ ā”œā”€ā”€ scan.types.ts -│ │ └── index.ts -│ ā”œā”€ā”€ settings/ # Application settings domain -│ │ ā”œā”€ā”€ settings.controller.ts -│ │ ā”œā”€ā”€ settings.routes.ts -│ │ ā”œā”€ā”€ settings.types.ts -│ │ └── index.ts -│ ā”œā”€ā”€ stream/ # Media streaming domain -│ │ ā”œā”€ā”€ stream.controller.ts -│ │ ā”œā”€ā”€ stream.routes.ts -│ │ ā”œā”€ā”€ stream.schema.ts -│ │ └── stream.services.ts -│ │ └── index.ts -│ ā”œā”€ā”€ tvshows/ # TV Shows domain -│ │ ā”œā”€ā”€ tvshows.controller.ts -│ │ ā”œā”€ā”€ tvshows.routes.ts -│ │ ā”œā”€ā”€ tvshows.schema.ts -│ │ ā”œā”€ā”€ tvshows.services.ts -│ │ ā”œā”€ā”€ tvshows.types.ts -│ │ └── index.ts -│ └── index.ts # Domain exports -ā”œā”€ā”€ lib/ # Shared libraries and utilities -│ ā”œā”€ā”€ build/ # Build-specific utilities -│ │ ā”œā”€ā”€ static-routes.ts # Static file serving -│ │ └── index.ts -│ ā”œā”€ā”€ config/ # Configuration utilities -│ │ ā”œā”€ā”€ swagger.ts # API documentation setup -│ │ └── index.ts -│ ā”œā”€ā”€ database/ # Database layer -│ │ ā”œā”€ā”€ postgres-manager.ts # PostgreSQL management -│ │ ā”œā”€ā”€ prisma.ts # Prisma client -│ │ └── index.ts -│ ā”œā”€ā”€ middleware/ # Express middleware -│ │ ā”œā”€ā”€ errorHandler.ts -│ │ ā”œā”€ā”€ middleware.ts -│ │ ā”œā”€ā”€ sanitization.ts -│ │ ā”œā”€ā”€ validation.ts -│ │ └── index.ts -│ ā”œā”€ā”€ providers/ # External service providers -│ │ └── tmdb/ -│ │ ā”œā”€ā”€ tmdb.services.ts -│ │ └── tmdb.types.ts -│ ā”œā”€ā”€ utils/ # Utility functions -│ │ ā”œā”€ā”€ extractExternalId.ts -│ │ ā”œā”€ā”€ genreMapping.ts -│ │ ā”œā”€ā”€ logger.ts -│ │ ā”œā”€ā”€ sanitization.ts -│ │ ā”œā”€ā”€ serialization.ts -│ │ └── index.ts -│ ā”œā”€ā”€ websocket/ # WebSocket management -│ │ └── index.ts -│ └── index.ts # Library exports -ā”œā”€ā”€ routes/ # Route definitions -│ ā”œā”€ā”€ index.ts # Main route handler -│ └── v1/ -│ └── index.ts # API v1 routes -ā”œā”€ā”€ scripts/ # Build and utility scripts -ā”œā”€ā”€ types/ # TypeScript type definitions -│ └── express.d.ts -└── index.ts # Application entry point -``` - -## šŸ—ļø Architecture Principles - -### 1. **Core Layer** (`core/`) - -Contains fundamental application configuration and shared services that are used across multiple domains. - -- **Config**: Environment variables, route setup, user settings -- **Services**: Shared business logic (e.g., genre management) - -### 2. **Domain Layer** (`domains/`) - -Each domain represents a specific business capability: - -- **Self-contained**: Each domain has its own controllers, routes, schemas, services, and types -- **Clear boundaries**: Domains communicate through well-defined interfaces -- **Consistent structure**: All domains follow the same file organization pattern - -### 3. **Library Layer** (`lib/`) - -Shared infrastructure and utilities: - -- **Database**: Database connection and management -- **Middleware**: Express middleware for cross-cutting concerns -- **Providers**: External service integrations (TMDB, etc.) -- **Utils**: Pure utility functions - -### 4. **Routes Layer** (`routes/`) - -Route definitions and API endpoint organization: - -- **Versioned**: Clear API versioning strategy -- **Domain routing**: Routes delegate to appropriate domain handlers - -## šŸ”„ Import Patterns - -### Domain-to-Domain Communication - -```typescript -// Import shared services through core -import { settingsManager } from "../../core/config/settings"; - -// Import utilities through lib -import { logger } from "../../lib/utils"; -``` - -### Cross-Domain Dependencies - -```typescript -// Import types from other domains when needed -import type { ScanResult } from "../../domains/scan"; -``` - -### Library Usage - -```typescript -// Import from lib for infrastructure concerns -import { validate } from "../../lib/middleware/validation"; -import prisma from "../../lib/database/prisma"; -``` - -## šŸ“‹ Benefits - -1. **Maintainability**: Clear separation of concerns makes the codebase easier to understand and modify -2. **Testability**: Each domain can be tested in isolation -3. **Scalability**: New domains can be added without affecting existing ones -4. **Reusability**: Shared services in `core/` can be used across domains -5. **Type Safety**: Proper TypeScript organization with clear import paths - -## šŸ—ļø Build System Separation - -Build-related code has been isolated from the core application: - -- **Build Scripts**: Moved to `../../build/scripts/` (project root level) -- **Build Configs**: Moved to `../../build/configs/` (project root level) -- **Static Routes**: Moved to `lib/build/` for runtime build-specific logic -- **PKG Config**: Extracted from package.json to separate JSON file - -This separation ensures: - -- **Clean Dependencies**: Core application doesn't depend on build tools -- **Maintainability**: Build configuration is centralized and versioned -- **Testability**: Build logic can be tested independently -- **Reusability**: Build configurations can be shared across environments - -## šŸš€ Migration Notes - -The structure was reorganized from the previous flat structure to this domain-driven approach. Key changes: - -- Moved configuration files from `lib/config/` to `core/config/` -- Organized route handlers by domain instead of by HTTP method -- Created proper domain boundaries with consistent file patterns -- Established clear import patterns for cross-domain communication -- **Separated build-related code** into dedicated build system directory diff --git a/apps/api/package.json b/apps/api/package.json index 79b9ad4..b483289 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -1,12 +1,14 @@ { "name": "api", - "version": "0.0.1", + "version": "0.1.0", "description": "Express API with TypeScript", "main": "dist/index.js", "scripts": { "dev": "tsx watch src/index.ts", "build": "tsc && tsc-alias", "start": "node dist/index.js", + "kill-port": "(lsof -ti:3001 | xargs kill -9 2>/dev/null && sleep 1 && lsof -ti:3001 | xargs kill -9 2>/dev/null) || echo 'Port cleared or not in use'", + "dev:clean": "pnpm kill-port && pnpm dev", "db:generate": "prisma generate", "db:push": "prisma db push", "db:push:force": "prisma db push --accept-data-loss", @@ -36,10 +38,12 @@ "express-rate-limit": "^7.4.1", "helmet": "^7.1.0", "jsonwebtoken": "^9.0.2", + "node-vibrant": "^4.0.2", "pg": "^8.16.3", "swagger-jsdoc": "^6.2.8", "swagger-ui-express": "^5.0.1", "winston": "^3.18.3", + "winston-transport": "^4.9.0", "ws": "^8.18.3", "zod": "^4.1.12" }, diff --git a/apps/api/prisma/migrations/20251110191932_add_scan_job_tracking/migration.sql b/apps/api/prisma/migrations/20251110191932_add_scan_job_tracking/migration.sql new file mode 100644 index 0000000..d4fe230 --- /dev/null +++ b/apps/api/prisma/migrations/20251110191932_add_scan_job_tracking/migration.sql @@ -0,0 +1,38 @@ +-- CreateEnum +CREATE TYPE "ScanJobStatus" AS ENUM ('PENDING', 'IN_PROGRESS', 'COMPLETED', 'FAILED', 'PAUSED'); + +-- CreateTable +CREATE TABLE "ScanJob" ( + "id" TEXT NOT NULL, + "libraryId" TEXT NOT NULL, + "scanPath" TEXT NOT NULL, + "mediaType" "MediaType" NOT NULL, + "status" "ScanJobStatus" NOT NULL DEFAULT 'PENDING', + "batchSize" INTEGER NOT NULL, + "totalFolders" INTEGER NOT NULL DEFAULT 0, + "processedCount" INTEGER NOT NULL DEFAULT 0, + "failedCount" INTEGER NOT NULL DEFAULT 0, + "processedFolders" TEXT NOT NULL DEFAULT '[]', + "failedFolders" TEXT NOT NULL DEFAULT '[]', + "pendingFolders" TEXT NOT NULL DEFAULT '[]', + "errorMessage" TEXT, + "startedAt" TIMESTAMP(3), + "completedAt" TIMESTAMP(3), + "lastBatchAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "ScanJob_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "ScanJob_libraryId_idx" ON "ScanJob"("libraryId"); + +-- CreateIndex +CREATE INDEX "ScanJob_status_idx" ON "ScanJob"("status"); + +-- CreateIndex +CREATE INDEX "ScanJob_scanPath_idx" ON "ScanJob"("scanPath"); + +-- AddForeignKey +ALTER TABLE "ScanJob" ADD CONSTRAINT "ScanJob_libraryId_fkey" FOREIGN KEY ("libraryId") REFERENCES "Library"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/api/prisma/migrations/20251110194320_add_batch_tracking/migration.sql b/apps/api/prisma/migrations/20251110194320_add_batch_tracking/migration.sql new file mode 100644 index 0000000..b28a5f4 --- /dev/null +++ b/apps/api/prisma/migrations/20251110194320_add_batch_tracking/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "ScanJob" ADD COLUMN "currentBatch" INTEGER NOT NULL DEFAULT 0, +ADD COLUMN "totalBatches" INTEGER NOT NULL DEFAULT 0; diff --git a/apps/api/prisma/migrations/20251110220936_add_total_items_saved/migration.sql b/apps/api/prisma/migrations/20251110220936_add_total_items_saved/migration.sql new file mode 100644 index 0000000..f29633a --- /dev/null +++ b/apps/api/prisma/migrations/20251110220936_add_total_items_saved/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "ScanJob" ADD COLUMN "totalItemsSaved" INTEGER NOT NULL DEFAULT 0; diff --git a/apps/api/prisma/migrations/20251112211711_store_mesh_gradiant/migration.sql b/apps/api/prisma/migrations/20251112211711_store_mesh_gradiant/migration.sql new file mode 100644 index 0000000..9e4249e --- /dev/null +++ b/apps/api/prisma/migrations/20251112211711_store_mesh_gradiant/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Media" ADD COLUMN "meshGradientColors" TEXT[]; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index d64a053..5f09a1a 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -15,16 +15,17 @@ enum MediaType { } model Media { - id String @id @default(cuid()) - title String - type MediaType - description String? - posterUrl String? - backdropUrl String? - releaseDate DateTime? - rating Float? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(cuid()) + title String + type MediaType + description String? + posterUrl String? + backdropUrl String? + meshGradientColors String[] // Hex colors for mesh gradient (4 colors for corners) + releaseDate DateTime? + rating Float? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt // Relations - subtypes point to Media, not the other way around movie Movie? @@ -256,6 +257,7 @@ model Library { children Library[] @relation("LibraryHierarchy") media MediaLibrary[] + scanJobs ScanJob[] @@index([slug]) @@index([parentId]) @@ -314,6 +316,55 @@ model ExternalId { @@index([externalId]) } +// ──────────────────────────── +// SCAN JOBS +// ──────────────────────────── + +enum ScanJobStatus { + PENDING + IN_PROGRESS + COMPLETED + FAILED + PAUSED +} + +model ScanJob { + id String @id @default(cuid()) + libraryId String + scanPath String + mediaType MediaType + status ScanJobStatus @default(PENDING) + batchSize Int // Number of folders per batch + + // Progress tracking + totalFolders Int @default(0) + totalBatches Int @default(0) // Total number of batches (calculated) + currentBatch Int @default(0) // Current batch number + processedCount Int @default(0) // Number of folders processed + failedCount Int @default(0) // Number of folders failed + totalItemsSaved Int @default(0) // Number of media items actually saved to DB + + // Folder tracking (JSON arrays) + processedFolders String @default("[]") // JSON array of processed folder paths + failedFolders String @default("[]") // JSON array of failed folder paths + pendingFolders String @default("[]") // JSON array of remaining folder paths + + errorMessage String? + + startedAt DateTime? + completedAt DateTime? + lastBatchAt DateTime? // Last batch completion time + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + library Library @relation(fields: [libraryId], references: [id], onDelete: Cascade) + + @@index([libraryId]) + @@index([status]) + @@index([scanPath]) +} + // ──────────────────────────── // SETTINGS // ──────────────────────────── diff --git a/apps/api/src/core/config/settings.ts b/apps/api/src/core/config/settings.ts index c1c05cd..a6f9060 100644 --- a/apps/api/src/core/config/settings.ts +++ b/apps/api/src/core/config/settings.ts @@ -120,7 +120,10 @@ async function ensureCoreSettings(): Promise { /** * Get setting value */ -async function getSetting(key: string, defaultValue?: T): Promise { +async function getSetting( + key: string, + defaultValue?: T, +): Promise { const setting = await prisma.setting.findUnique({ where: { key } }); if (!setting) return defaultValue as T; return parseValue(setting.value, setting.type) as T; @@ -129,7 +132,10 @@ async function getSetting(key: string, defaultValue?: T): Promise /** * Set setting value */ -async function setSetting(key: string, value: string | number | boolean): Promise { +async function setSetting( + key: string, + value: string | number | boolean, +): Promise { const stringValue = serializeValue(value); await prisma.setting.update({ where: { key }, @@ -150,10 +156,17 @@ export async function initializeSettings(): Promise { */ export async function getSettings(): Promise { return { - tmdbApiKey: await getSetting(SETTING_KEYS.TMDB_API_KEY) || undefined, + tmdbApiKey: + (await getSetting(SETTING_KEYS.TMDB_API_KEY)) || undefined, port: await getSetting(SETTING_KEYS.PORT, 3001), - jwtSecret: await getSetting(SETTING_KEYS.JWT_SECRET, "change-me-in-production"), - enableRouteGuards: await getSetting(SETTING_KEYS.ENABLE_ROUTE_GUARDS, false), + jwtSecret: await getSetting( + SETTING_KEYS.JWT_SECRET, + "change-me-in-production", + ), + enableRouteGuards: await getSetting( + SETTING_KEYS.ENABLE_ROUTE_GUARDS, + false, + ), firstRun: await getSetting(SETTING_KEYS.FIRST_RUN, true), }; } @@ -183,7 +196,9 @@ export function getDefaultSettings(): UserSettings { /** * Update settings */ -export async function updateSettings(updates: Partial): Promise { +export async function updateSettings( + updates: Partial, +): Promise { try { if (updates.tmdbApiKey !== undefined) { await setSetting(SETTING_KEYS.TMDB_API_KEY, updates.tmdbApiKey); @@ -195,7 +210,10 @@ export async function updateSettings(updates: Partial): Promise + providerGenres: Array<{ id: number | string; name: string }>, ): Promise<{ linked: number; duplicatesAvoided: number }> { if (!providerGenres || providerGenres.length === 0) { return { linked: 0, duplicatesAvoided: 0 }; diff --git a/apps/api/src/domains/index.ts b/apps/api/src/domains/index.ts index 88747f8..3aa7a35 100644 --- a/apps/api/src/domains/index.ts +++ b/apps/api/src/domains/index.ts @@ -5,4 +5,5 @@ export { moviesRoutes } from "./movies"; export { tvshowsRoutes } from "./tvshows"; export { streamRoutes } from "./stream"; export { settingsRoutes } from "./settings"; +export { logsRoutes } from "./logs"; export { default as searchRoutes } from "./search/search.routes"; diff --git a/apps/api/src/domains/library/library.controller.ts b/apps/api/src/domains/library/library.controller.ts index b3adec8..30119a6 100644 --- a/apps/api/src/domains/library/library.controller.ts +++ b/apps/api/src/domains/library/library.controller.ts @@ -19,7 +19,7 @@ export const libraryControllers = { delete: asyncHandler(async (req: Request, res: Response) => { const { id } = req.validatedData as DeleteLibraryRequest; const result = await libraryServices.delete(id); - + return sendSuccess(res, result, 200, result.message); }), diff --git a/apps/api/src/domains/library/library.routes.ts b/apps/api/src/domains/library/library.routes.ts index 0e9540c..3a562ae 100644 --- a/apps/api/src/domains/library/library.routes.ts +++ b/apps/api/src/domains/library/library.routes.ts @@ -157,7 +157,7 @@ const router: Router = express.Router(); router.get( "/", validateQuery(getLibrariesSchema), - libraryControllers.getLibraries + libraryControllers.getLibraries, ); /** @@ -269,7 +269,7 @@ router.put("/", validateBody(updateLibrarySchema), libraryControllers.update); router.delete( "/", validateBody(deleteLibrarySchema), - libraryControllers.delete + libraryControllers.delete, ); export default router; diff --git a/apps/api/src/domains/library/library.services.ts b/apps/api/src/domains/library/library.services.ts index 8fd45cc..63bdc9c 100644 --- a/apps/api/src/domains/library/library.services.ts +++ b/apps/api/src/domains/library/library.services.ts @@ -38,55 +38,59 @@ export const libraryServices = { // Find media that ONLY belongs to this library const mediaToDelete = library.media - .filter((ml: MediaLibraryWithRelations) => ml.media.libraries.length === 1) + .filter( + (ml: MediaLibraryWithRelations) => ml.media.libraries.length === 1, + ) .map((ml: MediaLibraryWithRelations) => ml.mediaId); logger.info( `šŸ“Š Analysis: - Total media in library: ${library.media.length} - Media only in this library (will be deleted): ${mediaToDelete.length} - - Media in other libraries (will be kept): ${library.media.length - mediaToDelete.length}` + - Media in other libraries (will be kept): ${library.media.length - mediaToDelete.length}`, ); // Use a transaction to ensure atomicity - const result = await prisma.$transaction(async (tx: PrismaTransactionClient) => { - let deletedCount = 0; - - // Delete media that only belongs to this library - // The cascade rules will automatically delete: - // - Movie/TVShow/Music/Comic records - // - MediaPerson associations - // - MediaGenre associations - // - ExternalId records - // - MediaLibrary associations - if (mediaToDelete.length > 0) { - const deleteResult = await tx.media.deleteMany({ - where: { - id: { - in: mediaToDelete, + const result = await prisma.$transaction( + async (tx: PrismaTransactionClient) => { + let deletedCount = 0; + + // Delete media that only belongs to this library + // The cascade rules will automatically delete: + // - Movie/TVShow/Music/Comic records + // - MediaPerson associations + // - MediaGenre associations + // - ExternalId records + // - MediaLibrary associations + if (mediaToDelete.length > 0) { + const deleteResult = await tx.media.deleteMany({ + where: { + id: { + in: mediaToDelete, + }, }, - }, + }); + deletedCount = deleteResult.count; + logger.info(`āœ“ Deleted ${deletedCount} media entries`); + } + + // Delete the library itself + // This will also cascade delete: + // - MediaLibrary associations (for media in other libraries) + // - Child libraries + await tx.library.delete({ + where: { id: libraryId }, }); - deletedCount = deleteResult.count; - logger.info(`āœ“ Deleted ${deletedCount} media entries`); - } - - // Delete the library itself - // This will also cascade delete: - // - MediaLibrary associations (for media in other libraries) - // - Child libraries - await tx.library.delete({ - where: { id: libraryId }, - }); - logger.info(`āœ“ Deleted library: ${library.name}`); - - return { - libraryId: library.id, - libraryName: library.name, - mediaDeleted: deletedCount, - message: `Successfully deleted library "${library.name}" and ${deletedCount} associated media entries`, - }; - }); + logger.info(`āœ“ Deleted library: ${library.name}`); + + return { + libraryId: library.id, + libraryName: library.name, + mediaDeleted: deletedCount, + message: `Successfully deleted library "${library.name}" and ${deletedCount} associated media entries`, + }; + }, + ); logger.info(`āœ… Library deletion complete: ${library.name}\n`); return result; @@ -126,7 +130,7 @@ export const libraryServices = { updatedAt: library.updatedAt.toISOString(), mediaCount: media.length, }; - } + }, ); logger.info(`āœ“ Found ${librariesWithMetadata.length} libraries`); @@ -142,7 +146,7 @@ export const libraryServices = { backdropUrl?: string; libraryPath?: string; libraryType?: string; - } + }, ): Promise => { logger.info(`āœļø Updating library: ${libraryId}`, updateData); diff --git a/apps/api/src/domains/logs/index.ts b/apps/api/src/domains/logs/index.ts new file mode 100644 index 0000000..0d2e37f --- /dev/null +++ b/apps/api/src/domains/logs/index.ts @@ -0,0 +1,2 @@ +export { default as logsRoutes } from "./logs.routes"; + diff --git a/apps/api/src/domains/logs/logs.controller.ts b/apps/api/src/domains/logs/logs.controller.ts new file mode 100644 index 0000000..4550ad9 --- /dev/null +++ b/apps/api/src/domains/logs/logs.controller.ts @@ -0,0 +1,51 @@ +import { Request, Response } from "express"; +import { logsServices } from "./logs.services"; +import { logger } from "@/lib/utils"; + +/** + * Get recent logs + * @route GET /api/v1/logs + */ +export const getLogs = async (req: Request, res: Response) => { + try { + const limit = req.query.limit ? parseInt(req.query.limit as string) : 100; + const level = req.query.level as string | undefined; + + const logs = await logsServices.getRecentLogs(limit, level); + + res.status(200).json({ + success: true, + data: logs, + }); + } catch (error) { + logger.error("Error fetching logs:", error); + res.status(500).json({ + success: false, + message: "Failed to fetch logs", + error: error instanceof Error ? error.message : "Unknown error", + }); + } +}; + +/** + * Clear logs + * @route DELETE /api/v1/logs + */ +export const clearLogs = async (req: Request, res: Response) => { + try { + await logsServices.clearLogs(); + + res.status(200).json({ + success: true, + message: "Logs cleared successfully", + }); + } catch (error) { + logger.error("Error clearing logs:", error); + res.status(500).json({ + success: false, + message: "Failed to clear logs", + error: error instanceof Error ? error.message : "Unknown error", + }); + } +}; + diff --git a/apps/api/src/domains/logs/logs.routes.ts b/apps/api/src/domains/logs/logs.routes.ts new file mode 100644 index 0000000..5ec51e8 --- /dev/null +++ b/apps/api/src/domains/logs/logs.routes.ts @@ -0,0 +1,110 @@ +import express, { Router } from "express"; +import { getLogs, clearLogs } from "./logs.controller"; + +const router: Router = express.Router(); + +/** + * @swagger + * /api/v1/logs: + * get: + * summary: Get recent API logs + * description: Fetches recent log entries from the server with optional filtering + * tags: [Logs] + * parameters: + * - in: query + * name: limit + * schema: + * type: number + * default: 100 + * description: Number of logs to retrieve + * - in: query + * name: level + * schema: + * type: string + * enum: [error, warn, info, http, debug] + * description: Filter by log level + * responses: + * 200: + * description: Successfully retrieved logs + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * example: true + * data: + * type: array + * items: + * type: object + * properties: + * timestamp: + * type: string + * example: "2025-11-11 18:36:11" + * level: + * type: string + * example: "info" + * message: + * type: string + * example: "Server started successfully" + * meta: + * type: object + * nullable: true + * 500: + * description: Internal server error + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * example: false + * message: + * type: string + * error: + * type: string + */ +router.get("/", getLogs); + +/** + * @swagger + * /api/v1/logs: + * delete: + * summary: Clear all logs + * description: Clears all log entries from the server + * tags: [Logs] + * responses: + * 200: + * description: Logs cleared successfully + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * example: true + * message: + * type: string + * example: "Logs cleared successfully" + * 500: + * description: Internal server error + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * example: false + * message: + * type: string + * error: + * type: string + */ +router.delete("/", clearLogs); + +export default router; + diff --git a/apps/api/src/domains/logs/logs.services.ts b/apps/api/src/domains/logs/logs.services.ts new file mode 100644 index 0000000..0ff3cb6 --- /dev/null +++ b/apps/api/src/domains/logs/logs.services.ts @@ -0,0 +1,119 @@ +import fs from "fs/promises"; +import path from "path"; +import { logger } from "@/lib/utils"; + +interface LogEntry { + timestamp: string; + level: string; + message: string; + meta?: Record; +} + +const LOG_FILE_PATH = path.join(process.cwd(), "logs", "combined.log"); +const MAX_LOGS_TO_READ = 500; // Maximum number of logs to read from file + +/** + * Strip ANSI color codes from a string + */ +function stripAnsiColors(str: string): string { + // eslint-disable-next-line no-control-regex + return str.replace(/\x1b\[[0-9;]*m/g, ""); +} + +/** + * Parse a log line from the combined.log file + */ +function parseLogLine(line: string): LogEntry | null { + try { + // Strip ANSI color codes first + const cleanLine = stripAnsiColors(line); + + // Log format: "YYYY-MM-DD HH:mm:ss [level]: message meta" + const match = cleanLine.match( + /^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}) \[(\w+)\]: (.+)$/ + ); + + if (!match) return null; + + const [, timestamp, level, rest] = match; + + if (!timestamp || !level || !rest) return null; + + // Try to extract metadata JSON if present + let message = rest; + let meta: Record | undefined; + + // Check if there's JSON at the end + const jsonMatch = rest.match(/^(.+?)(\{.+\})$/); + if (jsonMatch && jsonMatch[1] && jsonMatch[2]) { + try { + message = jsonMatch[1].trim(); + meta = JSON.parse(jsonMatch[2]); + } catch { + // If JSON parsing fails, keep original message + message = rest; + } + } + + return { + timestamp, + level: level.toLowerCase(), + message, + meta, + }; + } catch { + return null; + } +} + +/** + * Get recent logs from the log file + */ +export async function getRecentLogs( + limit: number = 100, + levelFilter?: string +): Promise { + try { + // Read the log file + const content = await fs.readFile(LOG_FILE_PATH, "utf-8"); + const lines = content.split("\n").filter((line) => line.trim()); + + // Parse log lines + const logs = lines + .slice(-MAX_LOGS_TO_READ) // Get last N lines + .map(parseLogLine) + .filter((log): log is LogEntry => log !== null); + + // Filter by level if specified + let filteredLogs = logs; + if (levelFilter) { + const normalizedLevel = levelFilter.toLowerCase(); + filteredLogs = logs.filter((log) => log.level === normalizedLevel); + } + + // Return most recent logs (up to limit) + return filteredLogs.slice(-limit).reverse(); + } catch { + logger.error("Error reading log file"); + return []; + } +} + +/** + * Clear the log file + */ +export async function clearLogs(): Promise { + try { + await fs.writeFile(LOG_FILE_PATH, ""); + logger.info("Logs cleared"); + } catch (error) { + logger.error("Error clearing logs"); + throw error; + } +} + +export const logsServices = { + getRecentLogs, + clearLogs, +}; + diff --git a/apps/api/src/domains/movies/movies.routes.ts b/apps/api/src/domains/movies/movies.routes.ts index 92ee381..e79e300 100644 --- a/apps/api/src/domains/movies/movies.routes.ts +++ b/apps/api/src/domains/movies/movies.routes.ts @@ -77,6 +77,12 @@ const router: Router = express.Router(); * backdropUrl: * type: string * nullable: true + * meshGradientColors: + * type: array + * items: + * type: string + * description: Hex color strings for mesh gradient (4 corners) + * example: ["#7C3AED", "#2563EB", "#EC4899", "#8B5CF6"] * releaseDate: * type: string * format: date-time @@ -192,6 +198,12 @@ router.get("/", moviesControllers.getMovies); * backdropUrl: * type: string * nullable: true + * meshGradientColors: + * type: array + * items: + * type: string + * description: Hex color strings for mesh gradient (4 corners) + * example: ["#7C3AED", "#2563EB", "#EC4899", "#8B5CF6"] * releaseDate: * type: string * format: date-time @@ -258,7 +270,7 @@ router.get("/", moviesControllers.getMovies); router.get( "/:id", validateParams(getMovieByIdSchema), - moviesControllers.getMovieById + moviesControllers.getMovieById, ); export default router; diff --git a/apps/api/src/domains/movies/movies.services.ts b/apps/api/src/domains/movies/movies.services.ts index 20b9769..9997900 100644 --- a/apps/api/src/domains/movies/movies.services.ts +++ b/apps/api/src/domains/movies/movies.services.ts @@ -1,20 +1,48 @@ import prisma from "@/lib/database/prisma"; import { MoviesListResponse, MovieResponse } from "./movies.types"; -import { serializeBigInt, NotFoundError } from "@/lib/utils"; +import { serializeBigInt, NotFoundError, logger } from "@/lib/utils"; +import { + enrichMediaWithColors, + enrichMediaArrayWithColors, +} from "../scan/helpers"; export const moviesServices = { getMovies: async (): Promise => { + logger.info("šŸ“½ļø Fetching movies list..."); + const movies = await prisma.movie.findMany({ include: { media: true, }, + orderBy: { + media: { + createdAt: "desc", // Most recent first + }, + }, + take: 10, // Limit to 10 most recent }); - return serializeBigInt(movies) as MoviesListResponse; + + logger.info(`Found ${movies.length} movies, enriching with colors...`); + + // Enrich with mesh gradient colors on-demand + const enrichedMovies = await enrichMediaArrayWithColors( + movies.map((m) => m.media), + ); + + // Map back to movie structure + const moviesWithColors = movies.map((movie, index) => ({ + ...movie, + media: enrichedMovies[index], + })); + + return serializeBigInt(moviesWithColors) as MoviesListResponse; }, getMovieById: async ( - id: string + id: string, ): Promise => { + logger.info(`šŸ“½ļø Fetching movie by ID: ${id}`); + const movie = await prisma.movie.findUnique({ where: { id }, include: { @@ -24,7 +52,19 @@ export const moviesServices = { if (!movie) { throw new NotFoundError("Movie", id); } - const serialized = serializeBigInt(movie) as MovieResponse; + + logger.info( + `Found movie: "${movie.media.title}", enriching with colors...`, + ); + + // Enrich with mesh gradient colors on-demand + const enrichedMedia = await enrichMediaWithColors(movie.media); + const movieWithColors = { + ...movie, + media: enrichedMedia, + }; + + const serialized = serializeBigInt(movieWithColors) as MovieResponse; return { ...serialized, streamUrl: `/api/v1/stream/${id}`, diff --git a/apps/api/src/domains/scan/helpers/batch-scanner.helper.ts b/apps/api/src/domains/scan/helpers/batch-scanner.helper.ts new file mode 100644 index 0000000..02c71d0 --- /dev/null +++ b/apps/api/src/domains/scan/helpers/batch-scanner.helper.ts @@ -0,0 +1,411 @@ +/** + * Batch scanning utilities + * Handles scanning large directories in manageable batches + */ + +import { readdir } from "fs/promises"; +import { join } from "path"; +import { logger } from "@/lib/utils"; +import { MediaType, ScanJobStatus } from "@/lib/database"; +import prisma from "@/lib/database/prisma"; +import { collectMediaEntries } from "./file-scanner.helper"; +import { + fetchExistingMetadata, + fetchMetadataForEntries, + fetchSeasonMetadata, + saveMediaToDatabase, + createRateLimiter, + withTimeoutAndRetry, +} from "./index"; +import { wsManager } from "@/lib/websocket"; +import type { TmdbMetadata } from "../scan.types"; +import type { TmdbSeasonMetadata } from "@/lib/providers/tmdb/tmdb.types"; + +/** + * Discover top-level folders to batch process + * For TV shows: Returns show folders + * For movies: Returns movie folders or files depending on structure + */ +export async function discoverFoldersToScan( + rootPath: string, + mediaType: "movie" | "tv", +): Promise { + return withTimeoutAndRetry( + async () => { + logger.info( + `šŸ” Listing directory: ${rootPath} (this may take a while on slow mounts)...`, + ); + const entries = await readdir(rootPath, { withFileTypes: true }); + const folders: string[] = []; + + for (const entry of entries) { + // Skip hidden files and system files + if (entry.name.startsWith(".") || entry.name.startsWith("@")) { + continue; + } + + if (entry.isDirectory()) { + folders.push(entry.name); + } + } + + logger.info( + `šŸ“‚ Discovered ${folders.length} ${mediaType === "tv" ? "show" : "movie"} folders to scan`, + ); + return folders; + }, + { + timeoutMs: 600000, // 10 minutes timeout for very slow FTP mounts (initial folder discovery can be slow) + maxRetries: 2, // Only retry twice (each retry could take 10 min) + operationName: `Discover folders in ${rootPath}`, + }, + ); +} + +/** + * Create or get existing scan job + */ +export async function createScanJob( + libraryId: string, + scanPath: string, + mediaType: MediaType, + folders: string[], +): Promise { + // Determine batch size based on media type + const batchSize = mediaType === MediaType.TV_SHOW ? 5 : 25; + + // Calculate total number of batches + const totalBatches = Math.ceil(folders.length / batchSize); + + const scanJob = await prisma.scanJob.create({ + data: { + libraryId, + scanPath, + mediaType, + status: ScanJobStatus.PENDING, + batchSize, + totalFolders: folders.length, + totalBatches, + currentBatch: 0, + pendingFolders: JSON.stringify(folders), + startedAt: new Date(), + }, + }); + + logger.info( + `šŸ“ Created scan job ${scanJob.id} for ${folders.length} folders (${totalBatches} batches of ${batchSize})`, + ); + return scanJob.id; +} + +/** + * Get the next batch of folders to process + */ +export async function getNextBatch( + scanJobId: string, +): Promise { + const scanJob = await prisma.scanJob.findUnique({ + where: { id: scanJobId }, + }); + + if (!scanJob) { + throw new Error(`Scan job ${scanJobId} not found`); + } + + if (scanJob.status === ScanJobStatus.COMPLETED) { + return null; + } + + const pendingFolders: string[] = JSON.parse(scanJob.pendingFolders); + + if (pendingFolders.length === 0) { + return null; + } + + const batch = pendingFolders.slice(0, scanJob.batchSize); + return batch; +} + +/** + * Mark a batch as processed + */ +export async function markBatchProcessed( + scanJobId: string, + processedFolderNames: string[], + failedFolderNames: string[] = [], + itemsSaved: number = 0, +): Promise { + const scanJob = await prisma.scanJob.findUnique({ + where: { id: scanJobId }, + }); + + if (!scanJob) { + throw new Error(`Scan job ${scanJobId} not found`); + } + + const pendingFolders: string[] = JSON.parse(scanJob.pendingFolders); + const processedFolders: string[] = JSON.parse(scanJob.processedFolders); + const failedFolders: string[] = JSON.parse(scanJob.failedFolders); + + // Remove processed folders from pending + const newPendingFolders = pendingFolders.filter( + (f) => !processedFolderNames.includes(f) && !failedFolderNames.includes(f), + ); + + // Add to processed/failed lists + processedFolders.push(...processedFolderNames); + failedFolders.push(...failedFolderNames); + + const totalProcessed = processedFolders.length + failedFolders.length; + const isComplete = newPendingFolders.length === 0; + + // Increment current batch number + const newCurrentBatch = scanJob.currentBatch + 1; + + const newTotalItemsSaved = scanJob.totalItemsSaved + itemsSaved; + + await prisma.scanJob.update({ + where: { id: scanJobId }, + data: { + pendingFolders: JSON.stringify(newPendingFolders), + processedFolders: JSON.stringify(processedFolders), + failedFolders: JSON.stringify(failedFolders), + processedCount: processedFolders.length, + failedCount: failedFolders.length, + totalItemsSaved: newTotalItemsSaved, + currentBatch: newCurrentBatch, + lastBatchAt: new Date(), + status: isComplete ? ScanJobStatus.COMPLETED : ScanJobStatus.IN_PROGRESS, + completedAt: isComplete ? new Date() : undefined, + }, + }); + + logger.info( + `āœ… Batch ${newCurrentBatch}/${scanJob.totalBatches} processed: ${processedFolderNames.length} success, ${failedFolderNames.length} failed (${totalProcessed}/${scanJob.totalFolders} folders, ${newTotalItemsSaved} items saved)`, + ); +} + +/** + * Process a single folder batch + */ +export async function processFolderBatch( + scanJobId: string, + folderNames: string[], + options: { + rootPath: string; + mediaType: "movie" | "tv"; + tmdbApiKey: string; + libraryId: string; + maxDepth: number; + fileExtensions: string[]; + rescan?: boolean; + originalPath?: string; + }, +): Promise<{ + processedFolders: string[]; + failedFolders: string[]; + totalSaved: number; +}> { + const { + rootPath, + mediaType, + tmdbApiKey, + libraryId, + maxDepth, + fileExtensions, + rescan = false, + originalPath, + } = options; + + const rateLimiter = createRateLimiter(); + const metadataCache = new Map(); + const episodeMetadataCache = new Map(); + + const processedFolders: string[] = []; + const failedFolders: string[] = []; + let totalSaved = 0; + + // Get scan job for total folder count + const scanJob = await prisma.scanJob.findUnique({ + where: { id: scanJobId }, + }); + + const totalFolders = scanJob?.totalFolders || folderNames.length; + const currentOffset = scanJob + ? scanJob.processedCount + scanJob.failedCount + : 0; + + for (const folderName of folderNames) { + const folderPath = join(rootPath, folderName); + + try { + logger.info(`\nšŸ“ Processing: ${folderName}`); + + // Calculate overall progress (not just batch progress) + const overallCurrent = + currentOffset + processedFolders.length + failedFolders.length; + const overallProgress = Math.floor((overallCurrent / totalFolders) * 100); + + wsManager.sendScanProgress({ + phase: "scanning", + progress: overallProgress, + current: overallCurrent, + total: totalFolders, + message: `Scanning: ${folderName}`, + libraryId, + scanJobId, + }); + + // Step 1: Collect media entries for this folder (with timeout and retry for slow mounts) + const mediaEntries = await withTimeoutAndRetry( + () => + collectMediaEntries(folderPath, { + maxDepth, + mediaType, + fileExtensions, + }), + { + timeoutMs: 300000, // 5 minutes timeout per folder for very slow mounts + maxRetries: 2, // Retry twice if it fails + operationName: `Scan folder: ${folderName}`, + }, + ); + + if (mediaEntries.length === 0) { + logger.warn(`āš ļø No media found in ${folderName}, skipping`); + processedFolders.push(folderName); + continue; + } + + logger.info(`Found ${mediaEntries.length} media items in ${folderName}`); + + // Step 2: Fetch existing metadata if not rescanning + let existingMetadataMap = new Map(); + if (!rescan) { + const tmdbIdsToCheck = mediaEntries + .filter((e) => e.extractedIds.tmdbId) + .map((e) => e.extractedIds.tmdbId!); + + existingMetadataMap = await fetchExistingMetadata( + tmdbIdsToCheck, + libraryId, + ); + existingMetadataMap.forEach((metadata, tmdbId) => { + metadataCache.set(tmdbId, metadata); + }); + } + + // Step 3: Fetch metadata from TMDB + const metadataProgress = Math.floor( + ((currentOffset + processedFolders.length) / totalFolders) * 100, + ); + wsManager.sendScanProgress({ + phase: "fetching-metadata", + progress: metadataProgress, + current: currentOffset + processedFolders.length, + total: totalFolders, + message: `Fetching metadata: ${folderName}`, + libraryId, + scanJobId, + }); + + await fetchMetadataForEntries(mediaEntries, { + mediaType, + tmdbApiKey, + rateLimiter, + metadataCache, + existingMetadataMap, + libraryId, + }); + + // Step 4: Fetch season metadata for TV shows + if (mediaType === "tv") { + await fetchSeasonMetadata(mediaEntries, { + tmdbApiKey, + rateLimiter, + episodeMetadataCache, + libraryId, + }); + } + + // Step 5: Save to database + const savingProgress = Math.floor( + ((currentOffset + processedFolders.length) / totalFolders) * 100, + ); + wsManager.sendScanProgress({ + phase: "saving", + progress: savingProgress, + current: currentOffset + processedFolders.length, + total: totalFolders, + message: `Saving: ${folderName}`, + libraryId, + scanJobId, + }); + + let savedCount = 0; + for (const mediaEntry of mediaEntries) { + if (!mediaEntry.isDirectory) { + try { + await saveMediaToDatabase( + mediaEntry, + mediaType, + tmdbApiKey, + episodeMetadataCache, + libraryId, + originalPath, + ); + savedCount++; + } catch (error) { + logger.error( + `Failed to save ${mediaEntry.name}: ${error instanceof Error ? error.message : error}`, + ); + } + } + } + + totalSaved += savedCount; + processedFolders.push(folderName); + + logger.info( + `āœ… ${folderName}: Saved ${savedCount}/${mediaEntries.length} items`, + ); + + // Send batch item completion + const completionCurrent = currentOffset + processedFolders.length; + const completionProgress = Math.floor( + (completionCurrent / totalFolders) * 100, + ); + + wsManager.sendScanProgress({ + phase: "batch-complete", + progress: completionProgress, + current: completionCurrent, + total: totalFolders, + message: `Completed: ${folderName} (${savedCount} items)`, + libraryId, + scanJobId, + batchItemComplete: { + folderName, + itemsSaved: savedCount, + totalItems: mediaEntries.length, + }, + }); + } catch (error) { + logger.error( + `āŒ Failed to process ${folderName}: ${error instanceof Error ? error.message : error}`, + ); + failedFolders.push(folderName); + + wsManager.sendScanError({ + error: `Failed to process ${folderName}: ${error instanceof Error ? error.message : String(error)}`, + scanJobId, + }); + } + } + + return { + processedFolders, + failedFolders, + totalSaved, + }; +} diff --git a/apps/api/src/domains/scan/helpers/color-extraction-middleware.helper.ts b/apps/api/src/domains/scan/helpers/color-extraction-middleware.helper.ts new file mode 100644 index 0000000..26ca244 --- /dev/null +++ b/apps/api/src/domains/scan/helpers/color-extraction-middleware.helper.ts @@ -0,0 +1,139 @@ +/** + * Middleware helper for on-demand color extraction + * Extracts and caches mesh gradient colors when media is requested + */ + +import prisma from "@/lib/database/prisma"; +import { logger } from "@/lib/utils"; +import { extractAndDarkenMeshColors } from "./color-extraction.helper"; + +/** + * Ensure media has mesh gradient colors extracted + * If colors don't exist, extract them from backdrop and cache in database + */ +export async function ensureMeshColors( + mediaId: string, + backdropUrl: string | null, + mediaTitle?: string, +): Promise { + try { + // Check if media already has colors + const media = await prisma.media.findUnique({ + where: { id: mediaId }, + select: { meshGradientColors: true, title: true }, + }); + + const title = mediaTitle || media?.title || mediaId; + + // If colors already exist, return them + if ( + media?.meshGradientColors && + Array.isArray(media.meshGradientColors) && + media.meshGradientColors.length === 4 + ) { + logger.debug( + `[${title}] Using cached mesh colors: ${media.meshGradientColors.join(", ")}`, + ); + return media.meshGradientColors; + } + + // No colors yet - extract them + if (!backdropUrl) { + logger.debug(`[${title}] No backdrop URL, skipping color extraction`); + return []; + } + + logger.info(`[${title}] šŸŽØ Extracting mesh colors from backdrop...`); + const colors = await extractAndDarkenMeshColors(backdropUrl, 0.4); + + // Cache colors in database + await prisma.media.update({ + where: { id: mediaId }, + data: { meshGradientColors: colors }, + }); + + logger.info( + `[${title}] āœ“ Colors extracted and cached: ${colors.join(", ")}`, + ); + return colors; + } catch (error) { + const title = mediaTitle || mediaId; + logger.warn( + `[${title}] Failed to extract/cache colors: ${error instanceof Error ? error.message : error}`, + ); + return []; + } +} + +/** + * Enrich media object with mesh gradient colors + * Returns immediately - extracts colors in background if needed + */ +export async function enrichMediaWithColors< + T extends { + id: string; + title?: string; + backdropUrl: string | null; + meshGradientColors?: string[] | null; + }, +>(media: T): Promise { + const title = media.title || media.id; + + // If colors already exist, return them + if ( + media.meshGradientColors && + Array.isArray(media.meshGradientColors) && + media.meshGradientColors.length === 4 + ) { + logger.debug(`[${title}] āœ“ Using cached mesh colors`); + return media; + } + + // No colors yet - trigger background extraction but don't wait for it + if (media.backdropUrl) { + logger.info(`[${title}] šŸŽØ Triggering background color extraction...`); + + // Extract in background - don't await! + ensureMeshColors(media.id, media.backdropUrl, media.title) + .then((colors) => { + logger.info( + `[${title}] āœ“ Background extraction complete: ${colors.join(", ")}`, + ); + }) + .catch((err) => { + logger.warn(`[${title}] Background extraction failed: ${err.message}`); + }); + } + + // Return immediately without colors (will be available on next request) + return media; +} + +/** + * Enrich an array of media objects with mesh gradient colors + * Returns immediately - triggers background extraction for items without colors + */ +export async function enrichMediaArrayWithColors< + T extends { + id: string; + title?: string; + backdropUrl: string | null; + meshGradientColors?: string[] | null; + }, +>(mediaArray: T[]): Promise { + const withColors = mediaArray.filter( + (m) => m.meshGradientColors?.length === 4, + ).length; + const withoutColors = mediaArray.length - withColors; + + logger.info( + `šŸ“Š Media colors: ${withColors} cached, ${withoutColors} to extract in background`, + ); + + // Process all media (returns immediately, extracts in background) + const enrichedMedia = await Promise.all( + mediaArray.map((media) => enrichMediaWithColors(media)), + ); + + return enrichedMedia; +} diff --git a/apps/api/src/domains/scan/helpers/color-extraction.helper.ts b/apps/api/src/domains/scan/helpers/color-extraction.helper.ts new file mode 100644 index 0000000..729d9fa --- /dev/null +++ b/apps/api/src/domains/scan/helpers/color-extraction.helper.ts @@ -0,0 +1,113 @@ +/** + * Color extraction utilities for mesh gradients + * Extracts prominent colors from images for UI backgrounds + */ + +import { logger } from "@/lib/utils"; + +// Type definition for node-vibrant v4 Swatch +type VibrantSwatch = { + hex: string; + rgb: [number, number, number]; + population: number; +} | null; + +// Default fallback colors (vibrant purple-blue theme) +const DEFAULT_MESH_COLORS = [ + "#7C3AED", // Top-left: Vibrant purple + "#2563EB", // Top-right: Bright blue + "#EC4899", // Bottom-left: Hot pink + "#8B5CF6", // Bottom-right: Purple-blue +]; + +/** + * Extract mesh gradient colors from an image URL + * Returns 4 hex color strings for mesh gradient corners + */ +export async function extractMeshColors(imageUrl: string): Promise { + try { + // Only extract if we have an image URL + if (!imageUrl) { + return DEFAULT_MESH_COLORS; + } + + // Import node-vibrant v4 for Node.js environment + const { Vibrant } = await import("node-vibrant/node"); + + // Extract color palette from image + const vibrant = new Vibrant(imageUrl); + const palette: any = await vibrant.getPalette(); + + // Extract 4 colors for mesh gradient corners + // Prefer muted/dark tones for better background aesthetics + // In v4, swatch has direct 'hex' property + const color1 = (palette.DarkMuted?.hex || + palette.Muted?.hex || + palette.Vibrant?.hex || + DEFAULT_MESH_COLORS[0]) as string; + + const color2 = (palette.Muted?.hex || + palette.LightMuted?.hex || + palette.LightVibrant?.hex || + DEFAULT_MESH_COLORS[1]) as string; + + const color3 = (palette.DarkVibrant?.hex || + palette.Vibrant?.hex || + palette.DarkMuted?.hex || + DEFAULT_MESH_COLORS[2]) as string; + + const color4 = (palette.LightMuted?.hex || + palette.LightVibrant?.hex || + palette.Muted?.hex || + DEFAULT_MESH_COLORS[3]) as string; + + const colors: string[] = [color1, color2, color3, color4]; + + logger.debug( + `Extracted mesh colors from ${imageUrl}: ${colors.join(", ")}`, + ); + return colors; + } catch (error) { + logger.warn( + `Failed to extract colors from ${imageUrl}: ${error instanceof Error ? error.message : error}`, + ); + return DEFAULT_MESH_COLORS; + } +} + +/** + * Darken a hex color for better background contrast + */ +export function darkenColor(hex: string, amount: number = 0.3): string { + try { + // Remove # if present + const cleanHex = hex.replace("#", ""); + + // Parse RGB + const r = parseInt(cleanHex.substring(0, 2), 16); + const g = parseInt(cleanHex.substring(2, 4), 16); + const b = parseInt(cleanHex.substring(4, 6), 16); + + // Darken + const newR = Math.floor(r * (1 - amount)); + const newG = Math.floor(g * (1 - amount)); + const newB = Math.floor(b * (1 - amount)); + + // Convert back to hex + const toHex = (n: number) => n.toString(16).padStart(2, "0"); + return `#${toHex(newR)}${toHex(newG)}${toHex(newB)}`; + } catch (error) { + return hex; // Return original on error + } +} + +/** + * Extract and darken colors for mesh gradient + */ +export async function extractAndDarkenMeshColors( + imageUrl: string, + darkenAmount: number = 0.3, +): Promise { + const colors = await extractMeshColors(imageUrl); + return colors.map((color) => darkenColor(color, darkenAmount)); +} diff --git a/apps/api/src/domains/scan/helpers/database.helper.ts b/apps/api/src/domains/scan/helpers/database.helper.ts index c705bf8..cc717f5 100644 --- a/apps/api/src/domains/scan/helpers/database.helper.ts +++ b/apps/api/src/domains/scan/helpers/database.helper.ts @@ -35,7 +35,7 @@ type ExtendedMetadata = TmdbMetadata & { export async function upsertMedia( metadata: TmdbMetadata, tmdbId: string, - mediaType: "movie" | "tv" + mediaType: "movie" | "tv", ) { // Check if media already exists const existingExternalId = await prisma.externalId.findUnique({ @@ -53,9 +53,13 @@ export async function upsertMedia( // Debug logging for poster URL const posterUrl = getTmdbImageUrl(metadata.poster_path); const backdropUrl = getTmdbImageUrl(metadata.backdrop_path); - - logger.debug(`[${metadata.title || metadata.name}] poster_path: ${metadata.poster_path} → posterUrl: ${posterUrl}`); - logger.debug(`[${metadata.title || metadata.name}] backdrop_path: ${metadata.backdrop_path} → backdropUrl: ${backdropUrl}`); + + logger.debug( + `[${metadata.title || metadata.name}] poster_path: ${metadata.poster_path} → posterUrl: ${posterUrl}`, + ); + logger.debug( + `[${metadata.title || metadata.name}] backdrop_path: ${metadata.backdrop_path} → backdropUrl: ${backdropUrl}`, + ); let media; @@ -115,7 +119,7 @@ export async function saveExternalIds( extractedIds: { imdbId?: string; tvdbId?: string; - } + }, ) { // Create/update IMDB external ID if exists if (extractedIds.imdbId) { @@ -164,7 +168,7 @@ export async function saveExternalIds( export async function saveGenres( mediaId: string, genres: Array<{ id: number; name: string }> | undefined, - mediaTitle: string + mediaTitle: string, ) { // Debug logging for genres if (!genres || genres.length === 0) { @@ -172,11 +176,13 @@ export async function saveGenres( return; } - logger.debug(`[${mediaTitle}] Received ${genres.length} genres from TMDB: ${genres.map(g => g.name).join(', ')}`); + logger.debug( + `[${mediaTitle}] Received ${genres.length} genres from TMDB: ${genres.map((g) => g.name).join(", ")}`, + ); const result = await assignGenresToMedia(mediaId, genres); logger.info( - `āœ“ Genres for ${mediaTitle}: ${result.linked} linked${result.duplicatesAvoided > 0 ? `, ${result.duplicatesAvoided} duplicates avoided` : ""}` + `āœ“ Genres for ${mediaTitle}: ${result.linked} linked${result.duplicatesAvoided > 0 ? `, ${result.duplicatesAvoided} duplicates avoided` : ""}`, ); } @@ -187,7 +193,7 @@ export async function saveMovie( mediaId: string, mediaEntry: MediaEntry, extendedMetadata: ExtendedMetadata, - filePathForStorage: string + filePathForStorage: string, ) { await prisma.movie.upsert({ where: { mediaId: mediaId }, @@ -208,7 +214,7 @@ export async function saveMovie( // Handle director if exists in metadata const director = extendedMetadata.credits?.crew?.find( - (c: { id: number; name: string; job: string }) => c.job === "Director" + (c: { id: number; name: string; job: string }) => c.job === "Director", ); if (director) { // Create or get person @@ -247,7 +253,7 @@ export async function saveTVShow( mediaId: string, mediaEntry: MediaEntry, episodeCache: Map, - filePathForStorage: string + filePathForStorage: string, ) { // Create TV show record const tvShow = await prisma.tVShow.upsert({ @@ -302,7 +308,7 @@ export async function saveTVShow( const cachedSeason = episodeCache.get(seasonCacheKey); if (cachedSeason?.episodes) { const episode = cachedSeason.episodes.find( - (ep: TmdbEpisodeMetadata) => ep.episode_number === episodeNumber + (ep: TmdbEpisodeMetadata) => ep.episode_number === episodeNumber, ); if (episode) { episodeTitle = episode.name || episodeTitle; @@ -310,7 +316,7 @@ export async function saveTVShow( episodeAirDate = episode.air_date ? new Date(episode.air_date) : null; episodeStillPath = getTmdbImageUrl(episode.still_path); logger.debug( - `āœ“ Using cached episode metadata for S${seasonNumber}E${episodeNumber}` + `āœ“ Using cached episode metadata for S${seasonNumber}E${episodeNumber}`, ); } } @@ -380,7 +386,7 @@ export async function saveMediaToDatabase( tmdbApiKey: string, episodeCache: Map, libraryId: string, - originalPath?: string + originalPath?: string, ): Promise { try { // Only process if we have metadata and a TMDB ID @@ -395,7 +401,7 @@ export async function saveMediaToDatabase( // Map container path back to host path for database storage const filePathForStorage = mapContainerToHostPath( mediaEntry.path, - originalPath + originalPath, ); const extendedMetadata = metadata as ExtendedMetadata; @@ -414,24 +420,38 @@ export async function saveMediaToDatabase( // 4. Save type-specific records if (mediaType === "movie") { - await saveMovie(media.id, mediaEntry, extendedMetadata, filePathForStorage); + await saveMovie( + media.id, + mediaEntry, + extendedMetadata, + filePathForStorage, + ); logger.info(`āœ“ Saved ${media.title}`); } else { const result = await saveTVShow( media.id, mediaEntry, episodeCache, - filePathForStorage + filePathForStorage, ); if (result) { - const { seasonNumber, episodeNumber, episodeTitle, fileTitleExtracted } = result; + const { + seasonNumber, + episodeNumber, + episodeTitle, + fileTitleExtracted, + } = result; logger.info( - `āœ“ Saved ${media.title} - S${seasonNumber}E${episodeNumber}: ${episodeTitle}${fileTitleExtracted ? ` (file: ${fileTitleExtracted})` : ""}` + `āœ“ Saved ${media.title} - S${seasonNumber}E${episodeNumber}: ${episodeTitle}${fileTitleExtracted ? ` (file: ${fileTitleExtracted})` : ""}`, ); } else if (mediaEntry.extractedIds.season) { - logger.info(`āœ“ Saved ${media.title} - Season ${mediaEntry.extractedIds.season}`); + logger.info( + `āœ“ Saved ${media.title} - Season ${mediaEntry.extractedIds.season}`, + ); } else { - logger.info(`āœ“ Saved ${media.title} (TV Show - no season/episode info)`); + logger.info( + `āœ“ Saved ${media.title} (TV Show - no season/episode info)`, + ); } } @@ -439,9 +459,8 @@ export async function saveMediaToDatabase( await linkMediaToLibrary(media.id, libraryId); } catch (error) { logger.error( - `Error saving media to database for ${mediaEntry.path}: ${error instanceof Error ? error.message : error}` + `Error saving media to database for ${mediaEntry.path}: ${error instanceof Error ? error.message : error}`, ); throw error; } } - diff --git a/apps/api/src/domains/scan/helpers/file-filter.helper.ts b/apps/api/src/domains/scan/helpers/file-filter.helper.ts index 9c39ece..bfb201e 100644 --- a/apps/api/src/domains/scan/helpers/file-filter.helper.ts +++ b/apps/api/src/domains/scan/helpers/file-filter.helper.ts @@ -16,7 +16,7 @@ const SKIP_DIRECTORIES = [ ".Trashes", ".fseventsd", ".TemporaryItems", - + // Hidden/config directories ".git", ".svn", @@ -24,7 +24,7 @@ const SKIP_DIRECTORIES = [ "node_modules", ".cache", ".tmp", - + // Media-specific "@eaDir", // Synology "#recycle", @@ -46,23 +46,23 @@ const SKIP_DIRECTORIES = [ */ const SKIP_FILE_PATTERNS = [ // System files - /^\./, // Hidden files - /^~\$/, // Temp files + /^\./, // Hidden files + /^~\$/, // Temp files /^Thumbs\.db$/i, /^\.DS_Store$/, /^desktop\.ini$/i, - + // Media-specific - /\.(nfo|txt|srt|sub|idx|ass|ssa|vtt)$/i, // Metadata/subtitles - /\.(jpg|jpeg|png|gif|bmp)$/i, // Images - /^sample\./i, // Sample files + /\.(nfo|txt|srt|sub|idx|ass|ssa|vtt)$/i, // Metadata/subtitles + /\.(jpg|jpeg|png|gif|bmp)$/i, // Images + /^sample\./i, // Sample files /-sample\./i, /\bsample\b/i, ]; /** * Checks if a directory or file should be skipped during scanning - * + * * @param name - File or directory name * @param isDirectory - Whether this is a directory * @returns True if should skip, false otherwise @@ -82,7 +82,7 @@ export function shouldSkipEntry(name: string, isDirectory: boolean): boolean { if (SKIP_DIRECTORIES.includes(name)) { return true; } - + // Skip directories matching patterns if (name.toLowerCase().includes("@eadir")) { return true; @@ -123,16 +123,15 @@ export function getDefaultVideoExtensions(): string[] { /** * Check if a file has a valid video extension - * + * * @param filename - Name of the file * @param allowedExtensions - Optional array of allowed extensions * @returns True if file has video extension */ export function isVideoFile( filename: string, - allowedExtensions: string[] = getDefaultVideoExtensions() + allowedExtensions: string[] = getDefaultVideoExtensions(), ): boolean { const ext = filename.toLowerCase().substring(filename.lastIndexOf(".")); return allowedExtensions.includes(ext); } - diff --git a/apps/api/src/domains/scan/helpers/file-scanner.helper.ts b/apps/api/src/domains/scan/helpers/file-scanner.helper.ts index e16bc27..746d53b 100644 --- a/apps/api/src/domains/scan/helpers/file-scanner.helper.ts +++ b/apps/api/src/domains/scan/helpers/file-scanner.helper.ts @@ -12,7 +12,7 @@ import type { MediaEntry } from "../scan.types"; /** * Recursively collect media entries from a directory - * + * * @param rootPath - Root directory to scan * @param options - Scanning options * @returns Array of found media entries @@ -24,9 +24,14 @@ export async function collectMediaEntries( mediaType: "movie" | "tv"; fileExtensions: string[]; onProgress?: (count: number) => void; - } + }, ): Promise { - const { maxDepth = Infinity, mediaType, fileExtensions, onProgress } = options; + const { + maxDepth = Infinity, + mediaType, + fileExtensions, + onProgress, + } = options; const mediaEntries: MediaEntry[] = []; let totalScanned = 0; let totalSkipped = 0; @@ -34,22 +39,24 @@ export async function collectMediaEntries( let structureViolations = 0; const sampleFiles: string[] = []; const maxSamples = 10; - + // Track unique show folders for TV shows (for optimization) const tvShowFolders = new Map>(); // showFolder -> Set - - logger.debug(`File scanner initialized with ${fileExtensions.length} extensions: ${fileExtensions.join(', ')}`); + + logger.debug( + `File scanner initialized with ${fileExtensions.length} extensions: ${fileExtensions.join(", ")}`, + ); logger.debug(`Media type: ${mediaType}, Max depth: ${maxDepth}`); async function collectEntries( currentPath: string, - depth: number = 0 + depth: number = 0, ): Promise { if (depth > maxDepth) return; try { const entries = await readdir(currentPath, { withFileTypes: true }); - + if (depth === 0 && entries.length === 0) { logger.warn(`Directory is empty: ${currentPath}`); return; @@ -57,12 +64,12 @@ export async function collectMediaEntries( for (const entry of entries) { totalScanned++; - + // Collect sample file names for debugging (first few files only) if (sampleFiles.length < maxSamples && !entry.isDirectory()) { sampleFiles.push(entry.name); } - + // Skip system files and unwanted entries if (shouldSkipEntry(entry.name, entry.isDirectory())) { totalSkipped++; @@ -77,22 +84,26 @@ export async function collectMediaEntries( // Extract IDs from the filename const extractedFromName = extractIds(entry.name); - + // For TV shows, try to extract from parent folders // Structure: "Show Name (2020)/Season 1/episode.mkv" - const pathParts = currentPath.split('/'); - const parentFolderName = pathParts[pathParts.length - 1] || ''; - const grandparentFolderName = pathParts[pathParts.length - 2] || ''; - + const pathParts = currentPath.split("/"); + const parentFolderName = pathParts[pathParts.length - 1] || ""; + const grandparentFolderName = pathParts[pathParts.length - 2] || ""; + const extractedFromParent = extractIds(parentFolderName); const extractedFromGrandparent = extractIds(grandparentFolderName); // Determine if file has season/episode info (likely an episode file) - const hasEpisodeInfo = !!(extractedFromName.season && extractedFromName.episode); - + const hasEpisodeInfo = !!( + extractedFromName.season && extractedFromName.episode + ); + // For episode files, prefer show info from grandparent folder (show folder) // For other files, use parent folder or filename - const showInfo = hasEpisodeInfo ? extractedFromGrandparent : extractedFromParent; + const showInfo = hasEpisodeInfo + ? extractedFromGrandparent + : extractedFromParent; // Merge IDs, prioritizing: filename > grandparent (for episodes) > parent const extractedIds = { @@ -101,7 +112,9 @@ export async function collectMediaEntries( tvdbId: extractedFromName.tvdbId || showInfo.tvdbId, year: extractedFromName.year || showInfo.year, // For title, use show folder name for episodes, filename for others - title: hasEpisodeInfo ? (showInfo.title || extractedFromName.title) : extractedFromName.title, + title: hasEpisodeInfo + ? showInfo.title || extractedFromName.title + : extractedFromName.title, season: extractedFromName.season || extractedFromParent.season, episode: extractedFromName.episode, }; @@ -115,12 +128,14 @@ export async function collectMediaEntries( const isMediaFile = !entry.isDirectory() && fileExtensions.some((ext) => - entry.name.toLowerCase().endsWith(ext.toLowerCase()) + entry.name.toLowerCase().endsWith(ext.toLowerCase()), ); // Debug log for first few files to see why they're not matching if (!entry.isDirectory() && mediaEntries.length < 3) { - logger.debug(`Checking file: ${entry.name}, hasIds: ${hasIds}, isMediaFile: ${isMediaFile}, extensions: ${fileExtensions.join(',')}`); + logger.debug( + `Checking file: ${entry.name}, hasIds: ${hasIds}, isMediaFile: ${isMediaFile}, extensions: ${fileExtensions.join(",")}`, + ); } if (hasIds || isMediaFile) { @@ -129,31 +144,36 @@ export async function collectMediaEntries( rootPath, fullPath, mediaType, - extractedIds + extractedIds, ); - + if (!validation.valid) { totalSkipped++; - + // Track violation type for statistics if (validation.reason?.includes("too deeply nested")) { depthViolations++; } else { structureViolations++; } - + // Log first few violations at info level, rest at debug - const logLevel = (depthViolations + structureViolations) <= 5 ? "info" : "debug"; + const logLevel = + depthViolations + structureViolations <= 5 ? "info" : "debug"; logger[logLevel]( - `ā­ļø Skipping ${entry.name}: ${validation.reason}` + `ā­ļø Skipping ${entry.name}: ${validation.reason}`, ); - + // Skip this file continue; } - + // For TV shows, track show folders for metadata optimization - if (mediaType === "tv" && validation.metadata?.showFolder && extractedIds.season) { + if ( + mediaType === "tv" && + validation.metadata?.showFolder && + extractedIds.season + ) { const showFolder = validation.metadata.showFolder; if (!tvShowFolders.has(showFolder)) { tvShowFolders.set(showFolder, new Set()); @@ -180,14 +200,14 @@ export async function collectMediaEntries( ? ` S${extractedIds.season}${extractedIds.episode ? `E${extractedIds.episode}` : ""}` : ""; logger.debug( - `Found: ${entry.name}${extractedIds.tmdbId ? ` [TMDB: ${extractedIds.tmdbId}${seasonEpInfo}]` : extractedIds.title ? ` [Title: ${extractedIds.title}]` : ""}` + `Found: ${entry.name}${extractedIds.tmdbId ? ` [TMDB: ${extractedIds.tmdbId}${seasonEpInfo}]` : extractedIds.title ? ` [Title: ${extractedIds.title}]` : ""}`, ); } else { // Log why item wasn't picked up (debug level) if (!entry.isDirectory() && !hasIds && !isMediaFile) { - const ext = entry.name.substring(entry.name.lastIndexOf('.')); + const ext = entry.name.substring(entry.name.lastIndexOf(".")); logger.debug( - `Not a media file: ${entry.name} (ext: ${ext}, expected: ${fileExtensions.join(', ')})` + `Not a media file: ${entry.name} (ext: ${ext}, expected: ${fileExtensions.join(", ")})`, ); } } @@ -197,22 +217,24 @@ export async function collectMediaEntries( } } catch (err) { logger.warn( - `Cannot access: ${fullPath} - ${err instanceof Error ? err.message : err}` + `Cannot access: ${fullPath} - ${err instanceof Error ? err.message : err}`, ); } } } catch (err) { logger.error( - `Error scanning ${currentPath}: ${err instanceof Error ? err.message : err}` + `Error scanning ${currentPath}: ${err instanceof Error ? err.message : err}`, ); } } await collectEntries(rootPath); - + const notRecognized = totalScanned - totalSkipped - mediaEntries.length; - logger.info(`Scan statistics: Scanned ${totalScanned} items, Skipped ${totalSkipped} filtered items, Not recognized as media: ${notRecognized}, Found ${mediaEntries.length} media items`); - + logger.info( + `Scan statistics: Scanned ${totalScanned} items, Skipped ${totalSkipped} filtered items, Not recognized as media: ${notRecognized}, Found ${mediaEntries.length} media items`, + ); + // Log validation statistics if (depthViolations > 0 || structureViolations > 0) { logValidationStats({ @@ -222,7 +244,7 @@ export async function collectMediaEntries( structureViolations, }); } - + // Log TV show folder information for optimization insights if (mediaType === "tv" && tvShowFolders.size > 0) { logger.info(`\nšŸ“ŗ TV Show Structure Detected:`); @@ -233,19 +255,24 @@ export async function collectMediaEntries( logger.debug(` - ${showFolder}: ${seasons.size} season(s)`); }); logger.info(` Total seasons across all shows: ${totalSeasons}`); - logger.info(` This optimizes metadata fetching - show metadata will be fetched once per show\n`); + logger.info( + ` This optimizes metadata fetching - show metadata will be fetched once per show\n`, + ); } - + if (mediaEntries.length === 0 && notRecognized > 0) { - logger.warn(`āš ļø ${notRecognized} items were found but not recognized as media files. Enable debug logging to see details.`); - logger.warn(` Expected video extensions: ${fileExtensions.join(', ')}`); - + logger.warn( + `āš ļø ${notRecognized} items were found but not recognized as media files. Enable debug logging to see details.`, + ); + logger.warn(` Expected video extensions: ${fileExtensions.join(", ")}`); + if (sampleFiles.length > 0) { - logger.warn(` Sample files found in directory (first ${sampleFiles.length}):`); - sampleFiles.forEach(file => logger.warn(` - ${file}`)); + logger.warn( + ` Sample files found in directory (first ${sampleFiles.length}):`, + ); + sampleFiles.forEach((file) => logger.warn(` - ${file}`)); } } - + return mediaEntries; } - diff --git a/apps/api/src/domains/scan/helpers/index.ts b/apps/api/src/domains/scan/helpers/index.ts index 45918eb..bd2785b 100644 --- a/apps/api/src/domains/scan/helpers/index.ts +++ b/apps/api/src/domains/scan/helpers/index.ts @@ -9,4 +9,9 @@ export * from "./file-scanner.helper"; export * from "./metadata-fetcher.helper"; export * from "./database.helper"; export * from "./path-validator.helper"; - +export * from "./batch-scanner.helper"; +export * from "./timeout-helper"; +export * from "./scan-job-cleanup.helper"; +export * from "./media-type-detector.helper"; +export * from "./color-extraction.helper"; +export * from "./color-extraction-middleware.helper"; diff --git a/apps/api/src/domains/scan/helpers/media-type-detector.helper.ts b/apps/api/src/domains/scan/helpers/media-type-detector.helper.ts new file mode 100644 index 0000000..f23acdb --- /dev/null +++ b/apps/api/src/domains/scan/helpers/media-type-detector.helper.ts @@ -0,0 +1,155 @@ +/** + * Detects if a directory structure matches the expected media type + * Helps prevent accidentally scanning movies as TV shows and vice versa + */ + +import { readdirSync } from "fs"; +import { join } from "path"; +import { logger } from "@/lib/utils"; + +interface MediaTypeHints { + hasSeasonFolders: number; // Count of folders with "Season" pattern + hasEpisodeFiles: number; // Count of files with S##E## pattern + hasMovieFiles: number; // Count of files with year pattern (2020) + avgDepth: number; // Average nesting depth + sampleFiles: string[]; // Sample filenames for logging +} + +/** + * Analyze directory structure to detect media type hints + */ +export function analyzeDirectoryStructure( + rootPath: string, + maxSamples: number = 20, +): MediaTypeHints { + const hints: MediaTypeHints = { + hasSeasonFolders: 0, + hasEpisodeFiles: 0, + hasMovieFiles: 0, + avgDepth: 0, + sampleFiles: [], + }; + + const depths: number[] = []; + const seasonPattern = /season\s*\d+/i; + const episodePattern = /S\d{1,2}E\d{1,2}/i; + const yearPattern = /\(?\d{4}\)?/; // (2020) or 2020 + + function scan(currentPath: string, depth: number = 0): void { + // Limit depth to prevent long scans + if (depth > 4) return; + + // Limit total samples + if (hints.sampleFiles.length >= maxSamples) return; + + try { + const entries = readdirSync(currentPath, { withFileTypes: true }); + + for (const entry of entries) { + if (hints.sampleFiles.length >= maxSamples) break; + + const fullPath = join(currentPath, entry.name); + + if (entry.isDirectory()) { + // Check for season folders + if (seasonPattern.test(entry.name)) { + hints.hasSeasonFolders++; + } + scan(fullPath, depth + 1); + } else { + // Check file patterns + const filename = entry.name.toLowerCase(); + + // Video extensions only + if (!/\.(mkv|mp4|avi|mov)$/i.test(filename)) continue; + + depths.push(depth); + hints.sampleFiles.push(entry.name); + + if (episodePattern.test(entry.name)) { + hints.hasEpisodeFiles++; + } + if (yearPattern.test(entry.name)) { + hints.hasMovieFiles++; + } + } + } + } catch (error) { + // Silently skip inaccessible directories + } + } + + scan(rootPath); + + // Calculate average depth + if (depths.length > 0) { + hints.avgDepth = depths.reduce((a, b) => a + b, 0) / depths.length; + } + + return hints; +} + +/** + * Detect if specified media type matches directory structure + * Returns warning if mismatch detected + */ +export function detectMediaTypeMismatch( + rootPath: string, + specifiedType: "movie" | "tv", +): { mismatch: boolean; warning?: string; confidence: number } { + logger.info(`šŸ” Analyzing directory structure for media type validation...`); + + const hints = analyzeDirectoryStructure(rootPath); + + logger.info(` Season folders: ${hints.hasSeasonFolders}`); + logger.info(` Episode-pattern files: ${hints.hasEpisodeFiles}`); + logger.info(` Movie-pattern files: ${hints.hasMovieFiles}`); + logger.info(` Average depth: ${hints.avgDepth.toFixed(1)}`); + + if (hints.sampleFiles.length > 0) { + logger.info(` Sample files: ${hints.sampleFiles.slice(0, 3).join(", ")}`); + } + + // Calculate confidence scores (0-100) + const tvScore = + (hints.hasSeasonFolders > 0 ? 40 : 0) + + (hints.hasEpisodeFiles > 5 ? 30 : hints.hasEpisodeFiles * 6) + + (hints.avgDepth >= 3 ? 30 : 0); + + const movieScore = + (hints.hasMovieFiles > 5 ? 40 : hints.hasMovieFiles * 8) + + (hints.avgDepth <= 2 ? 30 : 0) + + (hints.hasSeasonFolders === 0 ? 30 : 0); + + // Determine mismatch + if (specifiedType === "tv") { + // Specified TV, but looks like movies + if (movieScore > tvScore + 20) { + return { + mismatch: true, + warning: + `āš ļø WARNING: You specified "TV Shows" but this directory looks like MOVIES!\n` + + ` Detected: ${hints.hasMovieFiles} movie-pattern files, avg depth ${hints.avgDepth.toFixed(1)}, ${hints.hasSeasonFolders} season folders\n` + + ` This may result in incorrect metadata matching. Consider scanning as "movie" type instead.`, + confidence: movieScore, + }; + } + } else if (specifiedType === "movie") { + // Specified movie, but looks like TV + if (tvScore > movieScore + 20) { + return { + mismatch: true, + warning: + `āš ļø WARNING: You specified "Movies" but this directory looks like TV SHOWS!\n` + + ` Detected: ${hints.hasSeasonFolders} season folders, ${hints.hasEpisodeFiles} episode files, avg depth ${hints.avgDepth.toFixed(1)}\n` + + ` This may result in incorrect metadata matching. Consider scanning as "tv" type instead.`, + confidence: tvScore, + }; + } + } + + return { + mismatch: false, + confidence: specifiedType === "tv" ? tvScore : movieScore, + }; +} diff --git a/apps/api/src/domains/scan/helpers/metadata-fetcher.helper.ts b/apps/api/src/domains/scan/helpers/metadata-fetcher.helper.ts index 4da7d16..9825be0 100644 --- a/apps/api/src/domains/scan/helpers/metadata-fetcher.helper.ts +++ b/apps/api/src/domains/scan/helpers/metadata-fetcher.helper.ts @@ -20,7 +20,7 @@ import prisma from "@/lib/database/prisma"; */ export async function fetchExistingMetadata( tmdbIds: string[], - libraryId: string + libraryId: string, ): Promise> { const existingMetadataMap = new Map(); @@ -90,20 +90,27 @@ export async function fetchMetadataForEntries( metadataCache: Map; existingMetadataMap: Map; libraryId: string; - } + }, ): Promise<{ metadataFromCache: number; metadataFromTMDB: number; totalFetched: number; }> { - const { mediaType, tmdbApiKey, rateLimiter, metadataCache, existingMetadataMap, libraryId } = options; + const { + mediaType, + tmdbApiKey, + rateLimiter, + metadataCache, + existingMetadataMap, + libraryId, + } = options; const metadataFetchPromises: Promise[] = []; let metadataFetched = 0; let metadataFromCache = 0; let metadataFromTMDB = 0; const totalMetadataToFetch = mediaEntries.filter( - (e) => e.extractedIds.tmdbId || e.extractedIds.title + (e) => e.extractedIds.tmdbId || e.extractedIds.title, ).length; // For TV shows, optimize by grouping episodes of the same show @@ -112,7 +119,7 @@ export async function fetchMetadataForEntries( // Group entries by TMDB ID and title const entriesByTmdbId = new Map(); const entriesByTitle = new Map(); - + for (const mediaEntry of mediaEntries) { if (mediaEntry.extractedIds.tmdbId) { if (!entriesByTmdbId.has(mediaEntry.extractedIds.tmdbId)) { @@ -121,35 +128,41 @@ export async function fetchMetadataForEntries( entriesByTmdbId.get(mediaEntry.extractedIds.tmdbId)!.push(mediaEntry); } else if (mediaEntry.extractedIds.title) { const titleKey = `${mediaEntry.extractedIds.title}-${mediaEntry.extractedIds.year || ""}`; - + if (!entriesByTitle.has(titleKey)) { entriesByTitle.set(titleKey, []); } entriesByTitle.get(titleKey)!.push(mediaEntry); } } - + const uniqueShows = entriesByTmdbId.size + entriesByTitle.size; logger.info(`\nšŸ“Š TV Show Optimization Enabled:`); - logger.info(` Found ${mediaEntries.length} episode(s) from ${uniqueShows} unique show(s)`); - logger.info(` Will fetch show metadata ${uniqueShows} time(s) instead of ${mediaEntries.length} time(s)\n`); - + logger.info( + ` Found ${mediaEntries.length} episode(s) from ${uniqueShows} unique show(s)`, + ); + logger.info( + ` Will fetch show metadata ${uniqueShows} time(s) instead of ${mediaEntries.length} time(s)\n`, + ); + // Fetch metadata for unique shows, then apply to all their episodes for (const [tmdbId, episodes] of entriesByTmdbId.entries()) { const representativeEntry = episodes[0]; - + const fetchPromise = rateLimiter.add(async () => { // Check cache first if (metadataCache.has(tmdbId)) { const cachedMetadata = metadataCache.get(tmdbId)!; // Apply cached metadata to all episodes of this show - episodes.forEach(ep => { ep.metadata = cachedMetadata; }); + episodes.forEach((ep) => { + ep.metadata = cachedMetadata; + }); metadataFetched += episodes.length; - + if (existingMetadataMap.has(tmdbId)) { metadataFromCache += episodes.length; logger.debug( - `ā­ļø Using existing metadata for ${episodes.length} episode(s) of "${cachedMetadata.title || cachedMetadata.name}"` + `ā­ļø Using existing metadata for ${episodes.length} episode(s) of "${cachedMetadata.title || cachedMetadata.name}"`, ); } return; @@ -157,69 +170,67 @@ export async function fetchMetadataForEntries( // Fetch from TMDB try { - const metadata = await tmdbServices.get( - tmdbId, - "tv" as TmdbType, - { - apiKey: tmdbApiKey, - extraParams: { - append_to_response: "credits", - }, - } - ); - + const metadata = await tmdbServices.get(tmdbId, "tv" as TmdbType, { + apiKey: tmdbApiKey, + extraParams: { + append_to_response: "credits", + }, + }); + if (metadata) { const typedMetadata = metadata as TmdbMetadata; metadataCache.set(tmdbId, typedMetadata); - + // Apply metadata to all episodes of this show - episodes.forEach(ep => { ep.metadata = typedMetadata; }); + episodes.forEach((ep) => { + ep.metadata = typedMetadata; + }); metadataFetched += episodes.length; metadataFromTMDB++; - + logger.info( - `āœ“ Fetched show metadata: "${typedMetadata.title || typedMetadata.name}" (applied to ${episodes.length} episode(s))` + `āœ“ Fetched show metadata: "${typedMetadata.title || typedMetadata.name}" (applied to ${episodes.length} episode(s))`, ); } } catch (error) { logger.error( - `āœ— Failed to fetch TMDB ID ${tmdbId}: ${error instanceof Error ? error.message : error}` + `āœ— Failed to fetch TMDB ID ${tmdbId}: ${error instanceof Error ? error.message : error}`, ); metadataFetched += episodes.length; } }); metadataFetchPromises.push(fetchPromise); } - + // Handle shows identified by title for (const [titleKey, episodes] of entriesByTitle.entries()) { const representativeEntry = episodes[0]; if (!representativeEntry?.extractedIds) continue; const { extractedIds } = representativeEntry; - + const searchPromise = rateLimiter.add(async () => { try { - const foundId = await tmdbServices.search( - extractedIds.title!, - "tv", - { - apiKey: tmdbApiKey, - year: extractedIds.year, - } - ); - + const foundId = await tmdbServices.search(extractedIds.title!, "tv", { + apiKey: tmdbApiKey, + year: extractedIds.year, + }); + if (foundId) { logger.info( - `āœ“ Search found TMDB ID ${foundId} for: "${extractedIds.title}"` + `āœ“ Search found TMDB ID ${foundId} for: "${extractedIds.title}"`, ); - + // Update all episodes with the found TMDB ID - episodes.forEach(ep => { ep.extractedIds.tmdbId = foundId; }); - + episodes.forEach((ep) => { + ep.extractedIds.tmdbId = foundId; + }); + // Check cache if (metadataCache.has(foundId)) { const cachedMetadata = metadataCache.get(foundId)!; - episodes.forEach(ep => { ep.metadata = cachedMetadata; }); + episodes.forEach((ep) => { + ep.metadata = cachedMetadata; + }); metadataFetched += episodes.length; } else { // Fetch metadata @@ -231,18 +242,20 @@ export async function fetchMetadataForEntries( extraParams: { append_to_response: "credits", }, - } + }, ); - + if (metadata) { const typedMetadata = metadata as TmdbMetadata; metadataCache.set(foundId, typedMetadata); - episodes.forEach(ep => { ep.metadata = typedMetadata; }); + episodes.forEach((ep) => { + ep.metadata = typedMetadata; + }); metadataFetched += episodes.length; metadataFromTMDB++; - + logger.info( - `āœ“ Fetched show metadata: "${typedMetadata.title || typedMetadata.name}" (applied to ${episodes.length} episode(s))` + `āœ“ Fetched show metadata: "${typedMetadata.title || typedMetadata.name}" (applied to ${episodes.length} episode(s))`, ); } } @@ -252,7 +265,7 @@ export async function fetchMetadataForEntries( } } catch (error) { logger.error( - `āœ— Failed to search for "${extractedIds.title}": ${error instanceof Error ? error.message : error}` + `āœ— Failed to search for "${extractedIds.title}": ${error instanceof Error ? error.message : error}`, ); metadataFetched += episodes.length; } @@ -264,168 +277,176 @@ export async function fetchMetadataForEntries( for (const mediaEntry of mediaEntries) { const { extractedIds } = mediaEntry; - // Fetch metadata if TMDB ID exists - if (extractedIds.tmdbId) { - const fetchPromise = rateLimiter.add(async () => { - // Check cache first - if (metadataCache.has(extractedIds.tmdbId!)) { - mediaEntry.metadata = metadataCache.get(extractedIds.tmdbId!)!; - metadataFetched++; + // Fetch metadata if TMDB ID exists + if (extractedIds.tmdbId) { + const fetchPromise = rateLimiter.add(async () => { + // Check cache first + if (metadataCache.has(extractedIds.tmdbId!)) { + mediaEntry.metadata = metadataCache.get(extractedIds.tmdbId!)!; + metadataFetched++; - // Log if this came from database - if (existingMetadataMap.has(extractedIds.tmdbId!)) { - metadataFromCache++; - logger.debug( - `ā­ļø Using existing metadata for ${mediaEntry.name} (use rescan=true to re-fetch)` - ); + // Log if this came from database + if (existingMetadataMap.has(extractedIds.tmdbId!)) { + metadataFromCache++; + logger.debug( + `ā­ļø Using existing metadata for ${mediaEntry.name} (use rescan=true to re-fetch)`, + ); + } + return; } - return; - } - // Fetch from TMDB if not cached - try { - const metadata = await tmdbServices.get( - extractedIds.tmdbId!, - mediaType as TmdbType, - { - apiKey: tmdbApiKey, - extraParams: { - append_to_response: "credits", + // Fetch from TMDB if not cached + try { + const metadata = await tmdbServices.get( + extractedIds.tmdbId!, + mediaType as TmdbType, + { + apiKey: tmdbApiKey, + extraParams: { + append_to_response: "credits", + }, }, - } - ); - if (metadata) { - const typedMetadata = metadata as TmdbMetadata; - - // Debug: Log genres from TMDB - const genres = (metadata as any).genres; - if (genres && genres.length > 0) { - logger.debug(`TMDB returned ${genres.length} genres for "${typedMetadata.title || typedMetadata.name}": ${genres.map((g: any) => g.name).join(', ')}`); - } else { - logger.warn(`TMDB returned NO genres for "${typedMetadata.title || typedMetadata.name}"`); - } - - metadataCache.set(extractedIds.tmdbId!, typedMetadata); - mediaEntry.metadata = typedMetadata; - metadataFetched++; - metadataFromTMDB++; + ); + if (metadata) { + const typedMetadata = metadata as TmdbMetadata; + + // Debug: Log genres from TMDB + const genres = (metadata as any).genres; + if (genres && genres.length > 0) { + logger.debug( + `TMDB returned ${genres.length} genres for "${typedMetadata.title || typedMetadata.name}": ${genres.map((g: any) => g.name).join(", ")}`, + ); + } else { + logger.warn( + `TMDB returned NO genres for "${typedMetadata.title || typedMetadata.name}"`, + ); + } - // Send progress update every 5 items or at 100% - if ( - metadataFetched % 5 === 0 || - metadataFetched === totalMetadataToFetch - ) { - const progress = - 25 + - Math.floor((metadataFetched / totalMetadataToFetch) * 25); - wsManager.sendScanProgress({ - phase: "fetching-metadata", - progress, - current: metadataFetched, - total: totalMetadataToFetch, - message: `Fetching metadata: ${metadataFetched}/${totalMetadataToFetch}`, - libraryId: libraryId, - }); - } + metadataCache.set(extractedIds.tmdbId!, typedMetadata); + mediaEntry.metadata = typedMetadata; + metadataFetched++; + metadataFromTMDB++; + + // Send progress update every 5 items or at 100% + if ( + metadataFetched % 5 === 0 || + metadataFetched === totalMetadataToFetch + ) { + const progress = + 25 + + Math.floor((metadataFetched / totalMetadataToFetch) * 25); + wsManager.sendScanProgress({ + phase: "fetching-metadata", + progress, + current: metadataFetched, + total: totalMetadataToFetch, + message: `Fetching metadata: ${metadataFetched}/${totalMetadataToFetch}`, + libraryId: libraryId, + }); + } - logger.info( - `āœ“ Fetched: ${typedMetadata.title || typedMetadata.name}` + logger.info( + `āœ“ Fetched: ${typedMetadata.title || typedMetadata.name}`, + ); + } + } catch (metadataError) { + logger.error( + `āœ— Failed to fetch TMDB ID ${extractedIds.tmdbId} (${mediaEntry.name}): ${metadataError instanceof Error ? metadataError.message : metadataError}`, ); + metadataFetched++; } - } catch (metadataError) { - logger.error( - `āœ— Failed to fetch TMDB ID ${extractedIds.tmdbId} (${mediaEntry.name}): ${metadataError instanceof Error ? metadataError.message : metadataError}` - ); - metadataFetched++; - } - }); - metadataFetchPromises.push(fetchPromise); - } - // Try to search by title if no TMDB ID - else if (extractedIds.title && !extractedIds.tmdbId) { - const searchPromise = rateLimiter.add(async () => { - try { - const foundId = await tmdbServices.search( - extractedIds.title!, - mediaType, - { - apiKey: tmdbApiKey, - year: extractedIds.year, - } - ); - if (foundId) { - logger.info( - `āœ“ Search found TMDB ID ${foundId} for: "${extractedIds.title}"` + }); + metadataFetchPromises.push(fetchPromise); + } + // Try to search by title if no TMDB ID + else if (extractedIds.title && !extractedIds.tmdbId) { + const searchPromise = rateLimiter.add(async () => { + try { + const foundId = await tmdbServices.search( + extractedIds.title!, + mediaType, + { + apiKey: tmdbApiKey, + year: extractedIds.year, + }, ); - extractedIds.tmdbId = foundId; - - // Check cache before fetching - if (metadataCache.has(foundId)) { - mediaEntry.metadata = metadataCache.get(foundId)!; - metadataFetched++; - } else { - const metadata = await tmdbServices.get( - foundId, - mediaType as TmdbType, - { - apiKey: tmdbApiKey, - extraParams: { - append_to_response: "credits", - }, - } + if (foundId) { + logger.info( + `āœ“ Search found TMDB ID ${foundId} for: "${extractedIds.title}"`, ); - if (metadata) { - const typedMetadata = metadata as TmdbMetadata; - - // Debug: Log genres from TMDB - const genres = (metadata as any).genres; - if (genres && genres.length > 0) { - logger.debug(`TMDB search returned ${genres.length} genres for "${typedMetadata.title || typedMetadata.name}": ${genres.map((g: any) => g.name).join(', ')}`); - } else { - logger.warn(`TMDB search returned NO genres for "${typedMetadata.title || typedMetadata.name}"`); - } - - metadataCache.set(foundId, typedMetadata); - mediaEntry.metadata = typedMetadata; - metadataFetched++; - metadataFromTMDB++; + extractedIds.tmdbId = foundId; - // Send progress update - if ( - metadataFetched % 5 === 0 || - metadataFetched === totalMetadataToFetch - ) { - const progress = - 25 + - Math.floor((metadataFetched / totalMetadataToFetch) * 25); - wsManager.sendScanProgress({ - phase: "fetching-metadata", - progress, - current: metadataFetched, - total: totalMetadataToFetch, - message: `Fetching metadata: ${metadataFetched}/${totalMetadataToFetch}`, - libraryId: libraryId, - }); - } - - logger.info( - `āœ“ Fetched: ${typedMetadata.title || typedMetadata.name}` + // Check cache before fetching + if (metadataCache.has(foundId)) { + mediaEntry.metadata = metadataCache.get(foundId)!; + metadataFetched++; + } else { + const metadata = await tmdbServices.get( + foundId, + mediaType as TmdbType, + { + apiKey: tmdbApiKey, + extraParams: { + append_to_response: "credits", + }, + }, ); + if (metadata) { + const typedMetadata = metadata as TmdbMetadata; + + // Debug: Log genres from TMDB + const genres = (metadata as any).genres; + if (genres && genres.length > 0) { + logger.debug( + `TMDB search returned ${genres.length} genres for "${typedMetadata.title || typedMetadata.name}": ${genres.map((g: any) => g.name).join(", ")}`, + ); + } else { + logger.warn( + `TMDB search returned NO genres for "${typedMetadata.title || typedMetadata.name}"`, + ); + } + + metadataCache.set(foundId, typedMetadata); + mediaEntry.metadata = typedMetadata; + metadataFetched++; + metadataFromTMDB++; + + // Send progress update + if ( + metadataFetched % 5 === 0 || + metadataFetched === totalMetadataToFetch + ) { + const progress = + 25 + + Math.floor((metadataFetched / totalMetadataToFetch) * 25); + wsManager.sendScanProgress({ + phase: "fetching-metadata", + progress, + current: metadataFetched, + total: totalMetadataToFetch, + message: `Fetching metadata: ${metadataFetched}/${totalMetadataToFetch}`, + libraryId: libraryId, + }); + } + + logger.info( + `āœ“ Fetched: ${typedMetadata.title || typedMetadata.name}`, + ); + } } + } else { + logger.warn(`āœ— No results found for: "${extractedIds.title}"`); + metadataFetched++; } - } else { - logger.warn(`āœ— No results found for: "${extractedIds.title}"`); + } catch (searchError) { + logger.error( + `āœ— Failed to search for "${extractedIds.title}": ${searchError instanceof Error ? searchError.message : searchError}`, + ); metadataFetched++; } - } catch (searchError) { - logger.error( - `āœ— Failed to search for "${extractedIds.title}": ${searchError instanceof Error ? searchError.message : searchError}` - ); - metadataFetched++; - } - }); - metadataFetchPromises.push(searchPromise); - } + }); + metadataFetchPromises.push(searchPromise); + } } } @@ -449,7 +470,7 @@ export async function fetchSeasonMetadata( rateLimiter: RateLimiter; episodeMetadataCache: Map; libraryId: string; - } + }, ): Promise { const { tmdbApiKey, rateLimiter, episodeMetadataCache, libraryId } = options; @@ -498,7 +519,7 @@ export async function fetchSeasonMetadata( const seasonMetadata = await tmdbServices.getSeason( tvId, seasonNumber, - { apiKey: tmdbApiKey } + { apiKey: tmdbApiKey }, ); if (seasonMetadata) { @@ -511,8 +532,7 @@ export async function fetchSeasonMetadata( episodesFetched === uniqueSeasons.length ) { const progress = - 50 + - Math.floor((episodesFetched / uniqueSeasons.length) * 25); + 50 + Math.floor((episodesFetched / uniqueSeasons.length) * 25); wsManager.sendScanProgress({ phase: "fetching-episodes", progress, @@ -524,12 +544,12 @@ export async function fetchSeasonMetadata( } logger.info( - `āœ“ Fetched season: S${seasonNumber} (${seasonMetadata.episodes?.length || 0} episodes)` + `āœ“ Fetched season: S${seasonNumber} (${seasonMetadata.episodes?.length || 0} episodes)`, ); } } catch (error) { logger.warn( - `Could not fetch season S${seasonNumber}: ${error instanceof Error ? error.message : error}` + `Could not fetch season S${seasonNumber}: ${error instanceof Error ? error.message : error}`, ); episodesFetched++; } @@ -540,4 +560,3 @@ export async function fetchSeasonMetadata( await Promise.allSettled(episodeFetchPromises); logger.info("\nāœ“ Season metadata fetching complete\n"); } - diff --git a/apps/api/src/domains/scan/helpers/path-validator.helper.ts b/apps/api/src/domains/scan/helpers/path-validator.helper.ts index 20b76e4..6296641 100644 --- a/apps/api/src/domains/scan/helpers/path-validator.helper.ts +++ b/apps/api/src/domains/scan/helpers/path-validator.helper.ts @@ -20,11 +20,13 @@ export interface PathValidationOptions { const DEPTH_CONSTRAINTS = { movie: { max: 2, - description: "Movies should be at most 2 levels deep (e.g., /movies/Avengers.mkv or /movies/Avengers/Avengers.mkv)", + description: + "Movies should be at most 2 levels deep (e.g., /movies/Avengers.mkv or /movies/Avengers/Avengers.mkv)", }, tv: { max: 4, - description: "TV shows should be at most 4 levels deep (e.g., /tvshows/ShowName/Season 1/S1E1.mkv)", + description: + "TV shows should be at most 4 levels deep (e.g., /tvshows/ShowName/Season 1/S1E1.mkv)", }, } as const; @@ -55,10 +57,10 @@ const DANGEROUS_ROOT_PATHS = [ */ export function isDangerousRootPath(path: string): boolean { const normalizedPath = path.replace(/\\/g, "/").toLowerCase(); - + return DANGEROUS_ROOT_PATHS.some((dangerousPath) => { const normalizedDangerous = dangerousPath.toLowerCase(); - + // Exact match or root drive match return ( normalizedPath === normalizedDangerous || @@ -72,7 +74,7 @@ export function isDangerousRootPath(path: string): boolean { /** * Check if a directory contains media files (videos) * Scans up to 2 levels deep to detect media content - * + * * @param dirPath - Directory path to check * @param currentDepth - Current recursion depth * @param maxDepth - Maximum depth to scan (default: 2) @@ -81,14 +83,14 @@ export function isDangerousRootPath(path: string): boolean { async function containsMediaFiles( dirPath: string, currentDepth: number = 0, - maxDepth: number = 2 + maxDepth: number = 2, ): Promise { if (currentDepth > maxDepth) return false; try { const { readdir } = await import("fs/promises"); const entries = await readdir(dirPath, { withFileTypes: true }); - + // Get video extensions from the centralized source const videoExtensions = getDefaultVideoExtensions(); @@ -96,7 +98,9 @@ async function containsMediaFiles( for (const entry of entries) { if (!entry.isDirectory()) { const nameLower = entry.name.toLowerCase(); - if (videoExtensions.some((ext) => nameLower.endsWith(ext.toLowerCase()))) { + if ( + videoExtensions.some((ext) => nameLower.endsWith(ext.toLowerCase())) + ) { return true; } } @@ -108,9 +112,13 @@ async function containsMediaFiles( if (entry.isDirectory()) { const { join } = await import("path"); const subDirPath = join(dirPath, entry.name); - + // Check if this subdirectory contains media - const hasMedia = await containsMediaFiles(subDirPath, currentDepth + 1, maxDepth); + const hasMedia = await containsMediaFiles( + subDirPath, + currentDepth + 1, + maxDepth, + ); if (hasMedia) { return true; } @@ -128,19 +136,19 @@ async function containsMediaFiles( /** * Check if a path contains multiple media collection folders * This indicates it's a media root that should not be scanned directly - * + * * Strategy: Only check shallow paths (≤ 4 levels deep). If the user is scanning * a deeply nested path, we assume they know what they're doing. - * + * * For shallow paths, if we find multiple subdirectories with media (3+), we suggest * scanning more specifically. This catches cases like: * - /Puff with Movies/, Shows/, Anime/ (broad media root) * - /Media with Movies/, TV/, Music/ (multiple collections) - * + * * But allows: * - /Users/alken/Mounts/Puff/Movies (deep path = specific intent) * - /Movies with Action/, Comedy/ (only 2 subdirectories) - * + * * @param path - Path to check * @returns Object with validation result and detected collections */ @@ -152,10 +160,10 @@ export async function isMediaRootPath(path: string): Promise<{ try { const { readdir } = await import("fs/promises"); const { join } = await import("path"); - + // Calculate path depth (number of directories from root) const pathDepth = path.split("/").filter(Boolean).length; - + // If path is deep (> 4 levels), assume user knows what they want // Example: /Users/alken/Mounts/Puff/Movies is 5 levels deep if (pathDepth > 4) { @@ -164,7 +172,7 @@ export async function isMediaRootPath(path: string): Promise<{ detectedCollections: [], }; } - + // For shallow paths, check if it has many media subdirectories const entries = await readdir(path, { withFileTypes: true }); const detectedCollections: string[] = []; @@ -174,13 +182,15 @@ export async function isMediaRootPath(path: string): Promise<{ for (const entry of entries) { if (entry.isDirectory()) { const dirPath = join(path, entry.name); - + // Check if this directory contains media files (up to 2 levels deep) - const checkPromise = containsMediaFiles(dirPath, 0, 2).then((hasMedia) => ({ - name: entry.name, - hasMedia, - })); - + const checkPromise = containsMediaFiles(dirPath, 0, 2).then( + (hasMedia) => ({ + name: entry.name, + hasMedia, + }), + ); + checkPromises.push(checkPromise); } } @@ -234,7 +244,7 @@ export function getRecommendedMaxDepth(mediaType: "movie" | "tv"): number { export function isDepthValid( depth: number, mediaType: "movie" | "tv", - maxDepth?: number + maxDepth?: number, ): boolean { const effectiveMaxDepth = maxDepth ?? DEPTH_CONSTRAINTS[mediaType].max; return depth <= effectiveMaxDepth; @@ -245,20 +255,20 @@ export function isDepthValid( * Valid patterns: * - /movies/Avengers.mkv (depth 1) * - /movies/Avengers/Avengers.mkv (depth 2) - * + * * Invalid: * - /movies/some/deep/nested/path/movie.mkv (depth > 2) */ export function validateMoviePath( rootPath: string, - filePath: string + filePath: string, ): { valid: boolean; reason?: string; relativeDepth: number } { const relativePath = relative(rootPath, filePath); const pathParts = relativePath.split("/").filter(Boolean); const relativeDepth = pathParts.length - 1; // Subtract 1 because the file itself doesn't count as depth - + const maxDepth = DEPTH_CONSTRAINTS.movie.max; - + if (relativeDepth > maxDepth) { return { valid: false, @@ -266,7 +276,7 @@ export function validateMoviePath( relativeDepth, }; } - + return { valid: true, relativeDepth }; } @@ -275,7 +285,7 @@ export function validateMoviePath( * Valid patterns: * - /tvshows/Gravity Falls/S1E1.mkv (depth 2) * - /tvshows/Gravity Falls/Season 1/S1E1.mkv (depth 3) - * + * * Invalid: * - /tvshows/some/deep/nested/path/episode.mkv (depth > 3) * - Files without proper parent show folder @@ -287,7 +297,7 @@ export function validateTvShowPath( season?: number; episode?: number; title?: string; - } + }, ): { valid: boolean; reason?: string; @@ -297,9 +307,9 @@ export function validateTvShowPath( const relativePath = relative(rootPath, filePath); const pathParts = relativePath.split("/").filter(Boolean); const relativeDepth = pathParts.length - 1; // Subtract 1 for the file itself - + const maxDepth = DEPTH_CONSTRAINTS.tv.max; - + // Check depth constraint if (relativeDepth > maxDepth) { return { @@ -308,33 +318,36 @@ export function validateTvShowPath( relativeDepth, }; } - + // For TV shows, we expect at least one parent folder (the show name) if (relativeDepth < 1) { return { valid: false, - reason: "TV show files must be inside a show folder (e.g., /tvshows/ShowName/S1E1.mkv)", + reason: + "TV show files must be inside a show folder (e.g., /tvshows/ShowName/S1E1.mkv)", relativeDepth, }; } - + // Check if file has episode information const hasEpisodeInfo = !!(extractedIds.season && extractedIds.episode); - + if (!hasEpisodeInfo) { return { valid: false, - reason: "File does not contain valid season/episode information (e.g., S1E1)", + reason: + "File does not contain valid season/episode information (e.g., S1E1)", relativeDepth, }; } - + // The show folder is typically: // - Parent folder if structure is /tvshows/ShowName/S1E1.mkv (depth 2) // - Grandparent folder if structure is /tvshows/ShowName/Season 1/S1E1.mkv (depth 3) - const showFolderIndex = relativeDepth >= 2 ? pathParts.length - 3 : pathParts.length - 2; + const showFolderIndex = + relativeDepth >= 2 ? pathParts.length - 3 : pathParts.length - 2; const showFolder = pathParts[showFolderIndex] || pathParts[0]; - + return { valid: true, relativeDepth, @@ -353,7 +366,7 @@ export function validateMediaPath( season?: number; episode?: number; title?: string; - } + }, ): { valid: boolean; reason?: string; metadata?: any } { if (mediaType === "movie") { return validateMoviePath(rootPath, filePath); @@ -374,20 +387,21 @@ export function logValidationStats(stats: { logger.info("\nšŸ“Š Path Validation Statistics:"); logger.info(` Total files scanned: ${stats.total}`); logger.info(` Valid files: ${stats.valid}`); - + if (stats.depthViolations > 0) { logger.warn(` āš ļø Depth violations (skipped): ${stats.depthViolations}`); } - + if (stats.structureViolations > 0) { - logger.warn(` āš ļø Structure violations (skipped): ${stats.structureViolations}`); + logger.warn( + ` āš ļø Structure violations (skipped): ${stats.structureViolations}`, + ); } - + const skipped = stats.depthViolations + stats.structureViolations; if (skipped > 0) { logger.info( - ` Hint: Ensure your media follows the recommended structure. Enable debug logging for details.` + ` Hint: Ensure your media follows the recommended structure. Enable debug logging for details.`, ); } } - diff --git a/apps/api/src/domains/scan/helpers/rate-limiter.helper.ts b/apps/api/src/domains/scan/helpers/rate-limiter.helper.ts index 5b4cfb9..f8edc30 100644 --- a/apps/api/src/domains/scan/helpers/rate-limiter.helper.ts +++ b/apps/api/src/domains/scan/helpers/rate-limiter.helper.ts @@ -9,14 +9,14 @@ export interface RateLimiter { /** * Creates a rate limiter for API calls - * + * * @param maxRequestsPer10Sec - Maximum requests allowed per 10 seconds (default: 38) * @param concurrency - Number of concurrent requests (default: 10) * @returns RateLimiter instance */ export function createRateLimiter( - maxRequestsPer10Sec: number = 38, - concurrency: number = 10 + maxRequestsPer10Sec: number = 30, // Reduced from 38 to be more conservative + concurrency: number = 8, // Reduced from 10 to avoid overwhelming TMDB ): RateLimiter { const queue: Array<() => Promise> = []; let processing = false; @@ -30,13 +30,13 @@ export function createRateLimiter( // Clean up old timestamps (older than 10 seconds) const now = Date.now(); requestTimestamps = requestTimestamps.filter( - (timestamp) => now - timestamp < 10000 + (timestamp) => now - timestamp < 10000, ); // Calculate how many requests we can make right now const availableSlots = Math.max( 0, - maxRequestsPer10Sec - requestTimestamps.length + maxRequestsPer10Sec - requestTimestamps.length, ); if (availableSlots === 0) { @@ -81,4 +81,3 @@ export function createRateLimiter( }, }; } - diff --git a/apps/api/src/domains/scan/helpers/scan-job-cleanup.helper.ts b/apps/api/src/domains/scan/helpers/scan-job-cleanup.helper.ts new file mode 100644 index 0000000..580aa81 --- /dev/null +++ b/apps/api/src/domains/scan/helpers/scan-job-cleanup.helper.ts @@ -0,0 +1,130 @@ +/** + * Scan job cleanup utilities + * Handles zombie/stale scan jobs that may be stuck after crashes + */ + +import { logger } from "@/lib/utils"; +import prisma from "@/lib/database/prisma"; +import { ScanJobStatus } from "@/lib/database"; + +/** + * Mark stale scan jobs as FAILED + * A scan job is considered stale if it's been IN_PROGRESS for more than the specified timeout + */ +export async function cleanupStaleJobs( + staleTimeoutMs: number = 6 * 60 * 60 * 1000, // 6 hours default +): Promise { + try { + const staleThreshold = new Date(Date.now() - staleTimeoutMs); + + // Find jobs that have been in progress for too long + const staleJobs = await prisma.scanJob.findMany({ + where: { + status: ScanJobStatus.IN_PROGRESS, + OR: [ + // Job started but no batch completed recently + { + lastBatchAt: { + lt: staleThreshold, + }, + }, + // Job started but never completed a batch + { + lastBatchAt: null, + startedAt: { + lt: staleThreshold, + }, + }, + ], + }, + include: { + library: { + select: { + name: true, + }, + }, + }, + }); + + if (staleJobs.length === 0) { + logger.debug("No stale scan jobs found"); + return 0; + } + + // Mark them as FAILED + const updatePromises = staleJobs.map((job) => + prisma.scanJob.update({ + where: { id: job.id }, + data: { + status: ScanJobStatus.FAILED, + errorMessage: `Scan job marked as failed due to inactivity (timeout: ${staleTimeoutMs / 1000 / 60} minutes). Last activity: ${job.lastBatchAt?.toISOString() || job.startedAt?.toISOString() || "unknown"}`, + completedAt: new Date(), + }, + }), + ); + + await Promise.all(updatePromises); + + logger.warn(`🧹 Cleaned up ${staleJobs.length} stale scan job(s):`); + staleJobs.forEach((job) => { + logger.warn( + ` - ${job.library.name} (${job.currentBatch}/${job.totalBatches} batches, ${job.processedCount}/${job.totalFolders} folders)`, + ); + }); + + return staleJobs.length; + } catch (error) { + logger.error( + `Failed to cleanup stale jobs: ${error instanceof Error ? error.message : error}`, + ); + return 0; + } +} + +/** + * Get scan job status with metadata + */ +export async function getScanJobStatus(scanJobId: string) { + const job = await prisma.scanJob.findUnique({ + where: { id: scanJobId }, + include: { + library: { + select: { + id: true, + name: true, + }, + }, + }, + }); + + if (!job) { + return null; + } + + const progressPercent = + job.totalFolders > 0 + ? Math.floor( + ((job.processedCount + job.failedCount) / job.totalFolders) * 100, + ) + : 0; + + return { + id: job.id, + status: job.status, + library: job.library, + progress: { + currentBatch: job.currentBatch, + totalBatches: job.totalBatches, + processedFolders: job.processedCount, + failedFolders: job.failedCount, + totalFolders: job.totalFolders, + percentComplete: progressPercent, + }, + timestamps: { + startedAt: job.startedAt, + lastBatchAt: job.lastBatchAt, + completedAt: job.completedAt, + }, + error: job.errorMessage, + }; +} diff --git a/apps/api/src/domains/scan/helpers/timeout-helper.ts b/apps/api/src/domains/scan/helpers/timeout-helper.ts new file mode 100644 index 0000000..dc17bd0 --- /dev/null +++ b/apps/api/src/domains/scan/helpers/timeout-helper.ts @@ -0,0 +1,106 @@ +/** + * Timeout utilities for handling slow/unresponsive mounted drives + * Works with any mount type: FTP, SMB, NFS, slow USB, network drives, etc. + */ + +import { logger } from "@/lib/utils"; + +/** + * Execute a promise with a timeout + * Useful for operations on slow or unresponsive mounted drives + */ +export async function withTimeout( + promise: Promise, + timeoutMs: number, + operationName: string, +): Promise { + let timeoutHandle: NodeJS.Timeout; + + const timeoutPromise = new Promise((_, reject) => { + timeoutHandle = setTimeout(() => { + reject( + new Error( + `Operation "${operationName}" timed out after ${timeoutMs}ms. This may indicate a slow or unresponsive mounted drive.`, + ), + ); + }, timeoutMs); + }); + + try { + const result = await Promise.race([promise, timeoutPromise]); + clearTimeout(timeoutHandle!); + return result; + } catch (error) { + clearTimeout(timeoutHandle!); + throw error; + } +} + +/** + * Retry an operation with exponential backoff + * Useful for transient failures on mounted drives + */ +export async function withRetry( + operation: () => Promise, + options: { + maxRetries?: number; + initialDelayMs?: number; + maxDelayMs?: number; + operationName: string; + }, +): Promise { + const { + maxRetries = 3, + initialDelayMs = 1000, + maxDelayMs = 10000, + operationName, + } = options; + + let lastError: Error | unknown; + + for (let attempt = 0; attempt <= maxRetries; attempt++) { + try { + return await operation(); + } catch (error) { + lastError = error; + + if (attempt === maxRetries) { + logger.error( + `Operation "${operationName}" failed after ${maxRetries + 1} attempts`, + ); + throw error; + } + + // Calculate delay with exponential backoff + const delay = Math.min(initialDelayMs * Math.pow(2, attempt), maxDelayMs); + + logger.warn( + `Operation "${operationName}" failed (attempt ${attempt + 1}/${maxRetries + 1}), retrying in ${delay}ms... Error: ${error instanceof Error ? error.message : String(error)}`, + ); + + await new Promise((resolve) => setTimeout(resolve, delay)); + } + } + + throw lastError; +} + +/** + * Execute operation with both timeout and retry logic + * Best for operations on potentially slow/unreliable mounted drives + */ +export async function withTimeoutAndRetry( + operation: () => Promise, + options: { + timeoutMs?: number; + maxRetries?: number; + operationName: string; + }, +): Promise { + const { timeoutMs = 60000, maxRetries = 3, operationName } = options; + + return withRetry(() => withTimeout(operation(), timeoutMs, operationName), { + maxRetries, + operationName, + }); +} diff --git a/apps/api/src/domains/scan/helpers/tmdb-image.helper.ts b/apps/api/src/domains/scan/helpers/tmdb-image.helper.ts index 343211b..74e0dd1 100644 --- a/apps/api/src/domains/scan/helpers/tmdb-image.helper.ts +++ b/apps/api/src/domains/scan/helpers/tmdb-image.helper.ts @@ -8,11 +8,13 @@ const TMDB_IMAGE_BASE_URL = "https://image.tmdb.org/t/p/original"; /** * Constructs a proper TMDB image URL from a path * Handles cases where the path might already be a full URL or be malformed with repeated prefixes - * + * * @param path - TMDB image path (can be partial path, full URL, or null) * @returns Full TMDB image URL or null */ -export function getTmdbImageUrl(path: string | null | undefined): string | null { +export function getTmdbImageUrl( + path: string | null | undefined, +): string | null { if (!path) return null; // If it's already a full URL, check if it's malformed with repeated prefixes @@ -22,7 +24,9 @@ export function getTmdbImageUrl(path: string | null | undefined): string | null // Extract the actual path part after the last occurrence of the base URL const lastIndex = path.lastIndexOf(TMDB_IMAGE_BASE_URL); if (lastIndex >= 0) { - const actualPath = path.substring(lastIndex + TMDB_IMAGE_BASE_URL.length); + const actualPath = path.substring( + lastIndex + TMDB_IMAGE_BASE_URL.length, + ); return `${TMDB_IMAGE_BASE_URL}${actualPath}`; } } @@ -38,12 +42,12 @@ export function getTmdbImageUrl(path: string | null | undefined): string | null /** * Extracts just the path from a TMDB URL or returns the original path * Useful for storing paths in database without the full URL - * + * * @param fullUrlOrPath - Full TMDB URL or partial path * @returns Extracted path or null */ export function extractTmdbPath( - fullUrlOrPath: string | null | undefined + fullUrlOrPath: string | null | undefined, ): string | null { if (!fullUrlOrPath) return null; @@ -55,4 +59,3 @@ export function extractTmdbPath( // Otherwise return as-is (should be just a path) return fullUrlOrPath.startsWith("/") ? fullUrlOrPath : `/${fullUrlOrPath}`; } - diff --git a/apps/api/src/domains/scan/scan.controller.ts b/apps/api/src/domains/scan/scan.controller.ts index 0a1dc1d..0be1d6c 100644 --- a/apps/api/src/domains/scan/scan.controller.ts +++ b/apps/api/src/domains/scan/scan.controller.ts @@ -6,16 +6,39 @@ import { z } from "zod"; import { logger, mapHostToContainerPath, - sendSuccess, asyncHandler, ValidationError, + sendSuccess, } from "@/lib/utils"; import { wsManager } from "@/lib/websocket"; -import { isDangerousRootPath, isMediaRootPath } from "./helpers/path-validator.helper"; +import { + isDangerousRootPath, + isMediaRootPath, + detectMediaTypeMismatch, +} from "./helpers"; import { existsSync, statSync } from "fs"; type ScanPathRequest = z.infer; +// Scan queue to prevent overwhelming slow mounts +let activeScan: Promise | null = null; +const scanQueue: Array<() => Promise> = []; + +async function processQueue() { + if (activeScan) { + logger.info("šŸ“‹ Scan queued - another scan is in progress"); + return; + } + + if (scanQueue.length === 0) return; + + const nextScan = scanQueue.shift()!; + activeScan = nextScan().finally(() => { + activeScan = null; + processQueue(); // Process next in queue + }); +} + export const scanControllers = { /** * Scan a path for media files @@ -26,25 +49,25 @@ export const scanControllers = { // Early validation: check if path is a dangerous root path if (isDangerousRootPath(path)) { throw new ValidationError( - "Cannot scan system root directories or entire drives. Please specify a media folder (e.g., /Users/username/Movies)" + "Cannot scan system root directories or entire drives. Please specify a media folder (e.g., /Users/username/Movies)", ); } // Map host path to container path if running in Docker const mappedPath = mapHostToContainerPath(path); - + // Validate that the path exists and is accessible try { if (!existsSync(mappedPath)) { throw new ValidationError( - `Path does not exist or is not accessible: ${path}` + `Path does not exist or is not accessible: ${path}`, ); } - + const stats = statSync(mappedPath); if (!stats.isDirectory()) { throw new ValidationError( - `Path must be a directory, not a file: ${path}` + `Path must be a directory, not a file: ${path}`, ); } } catch (error) { @@ -52,7 +75,7 @@ export const scanControllers = { throw error; } throw new ValidationError( - `Cannot access path: ${path}. Please check permissions and path validity.` + `Cannot access path: ${path}. Please check permissions and path validity.`, ); } @@ -60,25 +83,21 @@ export const scanControllers = { // Note: For TV shows, having multiple show folders is EXPECTED and normal // Only check for broad roots when mixing different media types const mediaType = options?.mediaType; - + // Only perform broad root check if media type is not TV // TV libraries naturally contain multiple shows in subdirectories - if (mediaType !== 'tv') { + if (mediaType !== "tv") { const mediaRootCheck = await isMediaRootPath(mappedPath); if (mediaRootCheck.isBroadMediaRoot) { + logger.warn(`āš ļø Detected broad media root path: ${path}`); logger.warn( - `āš ļø Detected broad media root path: ${path}` - ); - logger.warn( - ` Found collections: ${mediaRootCheck.detectedCollections.join(", ")}` - ); - logger.warn( - ` ${mediaRootCheck.recommendation}` + ` Found collections: ${mediaRootCheck.detectedCollections.join(", ")}`, ); - + logger.warn(` ${mediaRootCheck.recommendation}`); + throw new ValidationError( - mediaRootCheck.recommendation || - "This path contains multiple media collections. Please scan specific collections individually for better organization and performance." + mediaRootCheck.recommendation || + "This path contains multiple media collections. Please scan specific collections individually for better organization and performance.", ); } } @@ -86,7 +105,9 @@ export const scanControllers = { // Get TMDB API key from database settings const tmdbApiKey = await getTmdbApiKey(); if (!tmdbApiKey) { - throw new ValidationError("TMDB API key is required. Please configure it in settings."); + throw new ValidationError( + "TMDB API key is required. Please configure it in settings.", + ); } const finalOptions = { @@ -98,33 +119,186 @@ export const scanControllers = { logger.info(`Scanning path: ${mappedPath} (original: ${path})`); - // Start the scan in the background (don't await) - // This allows us to return immediately while the scan progresses - scanServices.post(mappedPath, finalOptions) + // Detect media type mismatch (warn if directory structure doesn't match specified type) + const effectiveMediaType = mediaType || "movie"; + const mismatchDetection = detectMediaTypeMismatch( + mappedPath, + effectiveMediaType, + ); + if (mismatchDetection.mismatch) { + logger.warn(mismatchDetection.warning); + // Don't throw error, just log warning - user might know what they're doing + } else { + logger.info( + `āœ“ Media type validation passed (confidence: ${mismatchDetection.confidence}%)`, + ); + } + + // Determine if we should use batch scanning + // Use batch scanning if: + // 1. Explicitly requested via options.batchScan = true + // 2. OR it's a TV show library (5 per batch) + // 3. OR it's a movie library (25 per batch - better for large/slow storage) + // Only disable if explicitly set to false + const useBatchScan = options?.batchScan !== false; + + if (useBatchScan) { + logger.info( + `šŸ”„ Using batch scanning mode (${mediaType === "tv" ? "5" : "25"} folders per batch)`, + ); + } else { + logger.info(`šŸ“ Using full directory scanning mode`); + } + + // Queue the scan to prevent overwhelming slow mounts + const scanTask = async () => { + const scanPromise = useBatchScan + ? scanServices.postBatched(mappedPath, finalOptions) + : scanServices.post(mappedPath, finalOptions); + + return scanPromise + .then((result) => { + if ("totalFiles" in result) { + logger.info( + `āœ… Scan completed: ${result.libraryName} (${result.totalSaved}/${result.totalFiles} items)`, + ); + } else { + logger.info(`āœ… Batch scan completed: ${result.libraryName}`); + logger.info( + ` šŸ“ Folders: ${result.foldersProcessed}/${result.totalFolders} processed, ${result.foldersFailed} failed`, + ); + logger.info( + ` šŸŽ¬ Media Items: ${result.totalItemsSaved} saved to database`, + ); + } + }) + .catch((error) => { + // Send error via WebSocket + const errorMessage = + error instanceof Error ? error.message : "Failed to scan path"; + logger.error(`āŒ Scan failed: ${errorMessage}`); + wsManager.sendScanError({ + error: errorMessage, + }); + }); + }; + + // Add to queue or start immediately + if (activeScan) { + scanQueue.push(scanTask); + logger.info(`šŸ“‹ Scan queued (${scanQueue.length} in queue)`); + return sendSuccess( + res, + { + path: path, + mediaType: options?.mediaType, + queued: true, + queuePosition: scanQueue.length, + }, + 202, + `Scan queued. ${scanQueue.length} scan(s) ahead in queue. Progress will be sent via WebSocket when started.`, + ); + } else { + activeScan = scanTask(); + processQueue(); // Start processing queue + + return sendSuccess( + res, + { + path: path, + mediaType: options?.mediaType, + queued: false, + }, + 202, + "Scan started successfully. Progress will be sent via WebSocket.", + ); + } + }), + + /** + * Resume a failed or paused scan job + */ + resumeScan: asyncHandler(async (req: Request, res: Response) => { + const { scanJobId } = req.params; + + if (!scanJobId) { + throw new ValidationError("Scan job ID is required"); + } + + // Get TMDB API key from database settings + const tmdbApiKey = await getTmdbApiKey(); + if (!tmdbApiKey) { + throw new ValidationError( + "TMDB API key is required. Please configure it in settings.", + ); + } + + logger.info(`Resuming scan job: ${scanJobId}`); + + // Start the resume in the background (don't await) + scanServices + .resumeScanJob(scanJobId, tmdbApiKey) .then((result) => { + logger.info(`āœ… Resumed scan completed: ${result.libraryName}`); + logger.info( + ` šŸ“ Folders: ${result.foldersProcessed}/${result.totalFolders} processed, ${result.foldersFailed} failed`, + ); logger.info( - `āœ… Scan completed: ${result.libraryName} (${result.totalSaved}/${result.totalFiles} items)` + ` šŸŽ¬ Media Items: ${result.totalItemsSaved} total in database`, ); }) .catch((error) => { // Send error via WebSocket const errorMessage = - error instanceof Error ? error.message : "Failed to scan path"; - logger.error(`āŒ Scan failed: ${errorMessage}`); + error instanceof Error ? error.message : "Failed to resume scan"; + logger.error(`āŒ Resume scan failed: ${errorMessage}`); wsManager.sendScanError({ error: errorMessage, + scanJobId, }); }); // Return immediately with 202 Accepted - // Client will receive progress updates via WebSocket - return res.status(202).json({ - success: true, - message: "Scan started successfully. Progress will be sent via WebSocket.", - data: { - path: path, - mediaType: options?.mediaType, - }, - }); + return sendSuccess( + res, + { scanJobId }, + 202, + "Scan resumed successfully. Progress will be sent via WebSocket.", + ); + }), + + /** + * Get scan job status + */ + getJobStatus: asyncHandler(async (req: Request, res: Response) => { + const { scanJobId } = req.params; + + if (!scanJobId) { + throw new ValidationError("Scan job ID is required"); + } + + const status = await scanServices.getJobStatus(scanJobId); + + if (!status) { + throw new ValidationError(`Scan job ${scanJobId} not found`); + } + + return sendSuccess(res, status); + }), + + /** + * Cleanup stale scan jobs + */ + cleanupStaleJobs: asyncHandler(async (req: Request, res: Response) => { + logger.info("Manual cleanup of stale scan jobs requested"); + + const result = await scanServices.cleanupStaleJobs(); + + return sendSuccess( + res, + result, + 200, + "Stale scan jobs cleaned up successfully", + ); }), }; diff --git a/apps/api/src/domains/scan/scan.routes.ts b/apps/api/src/domains/scan/scan.routes.ts index 3b268b2..a4af214 100644 --- a/apps/api/src/domains/scan/scan.routes.ts +++ b/apps/api/src/domains/scan/scan.routes.ts @@ -148,4 +148,102 @@ const router: Router = express.Router(); */ router.post("/path", validateBody(scanPathSchema), scanControllers.post); +/** + * @swagger + * /api/v1/scan/resume/{scanJobId}: + * post: + * summary: Resume a failed or paused scan job + * description: | + * Resumes a scan job that was previously paused or failed. + * - Continues processing from where it left off + * - Only processes remaining unscanned folders + * - Sends progress updates via WebSocket + * tags: [Scan] + * parameters: + * - in: path + * name: scanJobId + * required: true + * schema: + * type: string + * description: The ID of the scan job to resume + * example: "clxxxx1234567890abcdefgh" + * responses: + * 202: + * description: Scan resumed successfully + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * example: true + * message: + * type: string + * example: "Scan resumed successfully. Progress will be sent via WebSocket." + * data: + * type: object + * properties: + * scanJobId: + * type: string + * example: "clxxxx1234567890abcdefgh" + * 400: + * description: Bad request - Invalid scan job ID or cannot resume + * 404: + * description: Scan job not found + * 500: + * description: Internal server error + */ +router.post("/resume/:scanJobId", scanControllers.resumeScan); + +/** + * @swagger + * /api/v1/scan/job/{scanJobId}: + * get: + * summary: Get scan job status + * description: Get detailed status information about a scan job + * tags: [Scan] + * parameters: + * - in: path + * name: scanJobId + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Scan job status retrieved successfully + * 404: + * description: Scan job not found + */ +router.get("/job/:scanJobId", scanControllers.getJobStatus); + +/** + * @swagger + * /api/v1/scan/cleanup: + * post: + * summary: Cleanup stale scan jobs + * description: | + * Manually trigger cleanup of scan jobs that have been stuck in IN_PROGRESS state. + * Useful after API crashes or unexpected shutdowns. + * tags: [Scan] + * responses: + * 200: + * description: Cleanup completed + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * data: + * type: object + * properties: + * cleanedCount: + * type: number + * message: + * type: string + */ +router.post("/cleanup", scanControllers.cleanupStaleJobs); + export default router; diff --git a/apps/api/src/domains/scan/scan.schema.ts b/apps/api/src/domains/scan/scan.schema.ts index 04284b6..926a2a0 100644 --- a/apps/api/src/domains/scan/scan.schema.ts +++ b/apps/api/src/domains/scan/scan.schema.ts @@ -26,7 +26,7 @@ export const scanPathSchema = z.object({ }, { message: "Invalid or unsafe file path", - } + }, ) .refine( (path) => { @@ -36,7 +36,7 @@ export const scanPathSchema = z.object({ { message: "Cannot scan system root directories or entire drives. Please specify a media folder (e.g., /Users/username/Movies or C:\\Media\\Movies)", - } + }, ), options: z .object({ @@ -47,12 +47,18 @@ export const scanPathSchema = z.object({ .max(10) .optional() .describe( - "Maximum directory depth to scan. Defaults to 2 for movies, 4 for TV shows" + "Maximum directory depth to scan. Defaults to 2 for movies, 4 for TV shows", ), mediaType: z.enum(["movie", "tv"]).default("movie"), fileExtensions: z.array(sanitizedStringSchema).max(20).optional(), libraryName: z.string().min(1).max(100).optional(), rescan: z.boolean().optional(), + batchScan: z + .boolean() + .optional() + .describe( + "Enable batch scanning mode for large libraries. Automatically enabled for TV shows. Batches: 5 shows or 25 movies per batch.", + ), }) .optional(), }); diff --git a/apps/api/src/domains/scan/scan.services.ts b/apps/api/src/domains/scan/scan.services.ts index cc7e825..bc4b8e3 100644 --- a/apps/api/src/domains/scan/scan.services.ts +++ b/apps/api/src/domains/scan/scan.services.ts @@ -12,6 +12,13 @@ import { fetchMetadataForEntries, fetchSeasonMetadata, saveMediaToDatabase, + discoverFoldersToScan, + createScanJob, + getNextBatch, + markBatchProcessed, + processFolderBatch, + cleanupStaleJobs, + getScanJobStatus, } from "./helpers"; export const scanServices = { @@ -25,7 +32,7 @@ export const scanServices = { libraryName?: string; rescan?: boolean; originalPath?: string; // Store original path for database if different from scanning path - } + }, ) => { const { maxDepth, @@ -36,7 +43,7 @@ export const scanServices = { rescan = false, originalPath, } = options; - + // Set reasonable default maxDepth based on media type if not provided // Movies: max 2 levels (/movies/Avengers.mkv or /movies/Avengers/Avengers.mkv) // TV Shows: max 4 levels (/tvshows/ShowName/Season 1/S1E1.mkv) @@ -48,9 +55,10 @@ export const scanServices = { } // Use default extensions if none provided or if empty array - const finalFileExtensions = fileExtensions && fileExtensions.length > 0 - ? fileExtensions - : getDefaultVideoExtensions(); + const finalFileExtensions = + fileExtensions && fileExtensions.length > 0 + ? fileExtensions + : getDefaultVideoExtensions(); // Use original path for library name and database storage, but scanPath for actual scanning const displayPath = originalPath || rootPath; @@ -87,8 +95,10 @@ export const scanServices = { // Phase 1: Scan directory structure logger.info("šŸ“ Phase 1: Scanning directory structure..."); - logger.info(`Looking for extensions: ${finalFileExtensions.join(', ')}`); - logger.info(`Max depth: ${effectiveMaxDepth} (${mediaType === "tv" ? "TV show" : "movie"} mode)`); + logger.info(`Looking for extensions: ${finalFileExtensions.join(", ")}`); + logger.info( + `Max depth: ${effectiveMaxDepth} (${mediaType === "tv" ? "TV show" : "movie"} mode)`, + ); wsManager.sendScanProgress({ phase: "scanning", progress: 0, @@ -119,7 +129,7 @@ export const scanServices = { // Early exit if no media items found if (mediaEntries.length === 0) { logger.info("āš ļø No media items found. Scan complete.\n"); - + wsManager.sendScanComplete({ libraryId: library.id, totalItems: 0, @@ -140,7 +150,9 @@ export const scanServices = { } // Phase 2: Fetch metadata from TMDB - logger.info("🌐 Phase 2: Fetching metadata from TMDB (rate-limited parallel)..."); + logger.info( + "🌐 Phase 2: Fetching metadata from TMDB (rate-limited parallel)...", + ); wsManager.sendScanProgress({ phase: "fetching-metadata", progress: 25, @@ -158,14 +170,19 @@ export const scanServices = { .filter((e) => e.extractedIds.tmdbId) .map((e) => e.extractedIds.tmdbId!); - existingMetadataMap = await fetchExistingMetadata(tmdbIdsToCheck, library.id); - + existingMetadataMap = await fetchExistingMetadata( + tmdbIdsToCheck, + library.id, + ); + // Add existing metadata to cache existingMetadataMap.forEach((metadata, tmdbId) => { metadataCache.set(tmdbId, metadata); }); - logger.info(`Found ${existingMetadataMap.size} items with existing metadata`); + logger.info( + `Found ${existingMetadataMap.size} items with existing metadata`, + ); } // Fetch metadata for all entries @@ -179,7 +196,7 @@ export const scanServices = { }); logger.info( - `\nāœ“ Metadata fetching complete (${metadataStats.metadataFromCache} from cache, ${metadataStats.metadataFromTMDB} from TMDB)\n` + `\nāœ“ Metadata fetching complete (${metadataStats.metadataFromCache} from cache, ${metadataStats.metadataFromTMDB} from TMDB)\n`, ); // Phase 3: Fetch episode metadata for TV shows @@ -218,7 +235,7 @@ export const scanServices = { tmdbApiKey, episodeMetadataCache, library.id, - originalPath + originalPath, ); savedCount++; @@ -237,7 +254,7 @@ export const scanServices = { } } catch (error) { logger.error( - `Failed to save ${mediaEntry.name}: ${error instanceof Error ? error.message : error}` + `Failed to save ${mediaEntry.name}: ${error instanceof Error ? error.message : error}`, ); savedCount++; } @@ -265,4 +282,370 @@ export const scanServices = { }, }; }, + + /** + * Batch scanning service - processes large libraries in manageable batches + * Ideal for remote/slow storage (FTP, SMB, etc.) + */ + postBatched: async ( + rootPath: string, + options: { + maxDepth?: number; + tmdbApiKey: string; + mediaType?: "movie" | "tv"; + fileExtensions?: string[]; + libraryName?: string; + rescan?: boolean; + originalPath?: string; + }, + ) => { + const { + maxDepth, + tmdbApiKey, + mediaType = "movie", + fileExtensions, + libraryName, + rescan = false, + originalPath, + } = options; + + // Set reasonable default maxDepth based on media type if not provided + const defaultMaxDepth = mediaType === "tv" ? 4 : 2; + const effectiveMaxDepth = maxDepth ?? defaultMaxDepth; + + if (!tmdbApiKey) { + throw new Error("TMDB API key is required"); + } + + // Use default extensions if none provided or if empty array + const finalFileExtensions = + fileExtensions && fileExtensions.length > 0 + ? fileExtensions + : getDefaultVideoExtensions(); + + // Use original path for library name and database storage + const displayPath = originalPath || rootPath; + const finalLibraryName = libraryName || `Library - ${displayPath}`; + const librarySlug = finalLibraryName + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/(^-|-$)/g, ""); + + logger.info(`šŸ“š Creating/getting library: ${finalLibraryName}`); + + const library = await prisma.library.upsert({ + where: { slug: librarySlug }, + update: { + name: finalLibraryName, + libraryPath: displayPath, + libraryType: mediaType === "tv" ? MediaType.TV_SHOW : MediaType.MOVIE, + isLibrary: true, + }, + create: { + name: finalLibraryName, + slug: librarySlug, + libraryPath: displayPath, + libraryType: mediaType === "tv" ? MediaType.TV_SHOW : MediaType.MOVIE, + isLibrary: true, + }, + }); + + logger.info(`āœ“ Library ready: ${library.name} (ID: ${library.id})\n`); + + // Step 1: Discover folders to scan + logger.info("šŸ” Discovering folders to scan..."); + wsManager.sendScanProgress({ + phase: "discovering", + progress: 0, + current: 0, + total: 0, + message: "Discovering folders...", + libraryId: library.id, + }); + + const folders = await discoverFoldersToScan(rootPath, mediaType); + + if (folders.length === 0) { + logger.info("āš ļø No folders found to scan."); + wsManager.sendScanComplete({ + libraryId: library.id, + totalItems: 0, + message: `No ${mediaType === "tv" ? "shows" : "movies"} found in "${library.name}"`, + }); + + return { + libraryId: library.id, + libraryName: library.name, + totalFolders: 0, + totalSaved: 0, + scanJobId: null, + }; + } + + // Step 2: Create scan job + const scanJobId = await createScanJob( + library.id, + displayPath, + mediaType === "tv" ? MediaType.TV_SHOW : MediaType.MOVIE, + folders, + ); + + wsManager.sendScanProgress({ + phase: "batching", + progress: 5, + current: 0, + total: folders.length, + message: `Starting batch scan (${folders.length} folders)`, + libraryId: library.id, + scanJobId, + }); + + // Step 3: Process batches + let totalSaved = 0; + let batchNumber = 0; + + while (true) { + const batch = await getNextBatch(scanJobId); + + if (!batch || batch.length === 0) { + break; + } + + batchNumber++; + logger.info( + `\nšŸ“¦ Processing batch ${batchNumber} (${batch.length} folders)`, + ); + + const result = await processFolderBatch(scanJobId, batch, { + rootPath, + mediaType, + tmdbApiKey, + libraryId: library.id, + maxDepth: effectiveMaxDepth, + fileExtensions: finalFileExtensions, + rescan, + originalPath, + }); + + totalSaved += result.totalSaved; + + // Mark batch as processed + await markBatchProcessed( + scanJobId, + result.processedFolders, + result.failedFolders, + result.totalSaved, + ); + + // Send batch completion update + const scanJob = await prisma.scanJob.findUnique({ + where: { id: scanJobId }, + }); + + if (scanJob) { + const progressPercent = Math.floor( + ((scanJob.processedCount + scanJob.failedCount) / + scanJob.totalFolders) * + 100, + ); + + wsManager.sendScanProgress({ + phase: "batching", + progress: progressPercent, + current: scanJob.processedCount + scanJob.failedCount, + total: scanJob.totalFolders, + message: `Batch ${scanJob.currentBatch}/${scanJob.totalBatches} complete: ${result.processedFolders.length} success, ${result.failedFolders.length} failed (${scanJob.processedCount + scanJob.failedCount}/${scanJob.totalFolders} folders)`, + libraryId: library.id, + scanJobId, + }); + } + } + + logger.info("\nāœ… Batch scan complete!\n"); + + // Send final completion message + wsManager.sendScanComplete({ + libraryId: library.id, + totalItems: totalSaved, + message: `Batch scan complete! Saved ${totalSaved} items to library "${library.name}"`, + scanJobId, + }); + + // Get final scan job stats + const finalScanJob = await prisma.scanJob.findUnique({ + where: { id: scanJobId }, + }); + + return { + libraryId: library.id, + libraryName: library.name, + totalFolders: folders.length, + foldersProcessed: finalScanJob?.processedCount || 0, + foldersFailed: finalScanJob?.failedCount || 0, + totalItemsSaved: finalScanJob?.totalItemsSaved || 0, + scanJobId, + }; + }, + + /** + * Resume a failed or paused scan job + */ + resumeScanJob: async (scanJobId: string, tmdbApiKey: string) => { + // Get the scan job + const scanJob = await prisma.scanJob.findUnique({ + where: { id: scanJobId }, + include: { library: true }, + }); + + if (!scanJob) { + throw new Error(`Scan job ${scanJobId} not found`); + } + + if (scanJob.status === "COMPLETED") { + throw new Error("Cannot resume a completed scan job"); + } + + if (scanJob.status === "IN_PROGRESS") { + throw new Error("Scan job is already in progress"); + } + + logger.info( + `šŸ”„ Resuming scan job ${scanJobId} for library: ${scanJob.library.name}`, + ); + + // Update status to in progress + await prisma.scanJob.update({ + where: { id: scanJobId }, + data: { + status: "IN_PROGRESS", + startedAt: scanJob.startedAt || new Date(), + }, + }); + + const mediaType = scanJob.mediaType === MediaType.TV_SHOW ? "tv" : "movie"; + const rootPath = scanJob.scanPath; + + // Get default file extensions + const finalFileExtensions = getDefaultVideoExtensions(); + + // Set maxDepth based on media type + const effectiveMaxDepth = mediaType === "tv" ? 4 : 2; + + // Process remaining batches + let totalSaved = 0; + let batchNumber = 0; + + wsManager.sendScanProgress({ + phase: "batching", + progress: Math.floor( + ((scanJob.processedCount + scanJob.failedCount) / + scanJob.totalFolders) * + 100, + ), + current: scanJob.processedCount + scanJob.failedCount, + total: scanJob.totalFolders, + message: `Resuming scan job...`, + libraryId: scanJob.libraryId, + scanJobId, + }); + + while (true) { + const batch = await getNextBatch(scanJobId); + + if (!batch || batch.length === 0) { + break; + } + + batchNumber++; + logger.info( + `\nšŸ“¦ Processing batch ${batchNumber} (${batch.length} folders)`, + ); + + const result = await processFolderBatch(scanJobId, batch, { + rootPath, + mediaType, + tmdbApiKey, + libraryId: scanJob.libraryId, + maxDepth: effectiveMaxDepth, + fileExtensions: finalFileExtensions, + rescan: false, + }); + + totalSaved += result.totalSaved; + + // Mark batch as processed + await markBatchProcessed( + scanJobId, + result.processedFolders, + result.failedFolders, + result.totalSaved, + ); + + // Send batch completion update + const updatedScanJob = await prisma.scanJob.findUnique({ + where: { id: scanJobId }, + }); + + if (updatedScanJob) { + const progressPercent = Math.floor( + ((updatedScanJob.processedCount + updatedScanJob.failedCount) / + updatedScanJob.totalFolders) * + 100, + ); + + wsManager.sendScanProgress({ + phase: "batching", + progress: progressPercent, + current: updatedScanJob.processedCount + updatedScanJob.failedCount, + total: updatedScanJob.totalFolders, + message: `Batch ${updatedScanJob.currentBatch}/${updatedScanJob.totalBatches} complete: ${result.processedFolders.length} success, ${result.failedFolders.length} failed (${updatedScanJob.processedCount + updatedScanJob.failedCount}/${updatedScanJob.totalFolders} folders)`, + libraryId: scanJob.libraryId, + scanJobId, + }); + } + } + + logger.info("\nāœ… Resumed scan complete!\n"); + + // Get final scan job stats + const finalScanJob = await prisma.scanJob.findUnique({ + where: { id: scanJobId }, + }); + + // Send final completion message + wsManager.sendScanComplete({ + libraryId: scanJob.libraryId, + totalItems: finalScanJob?.totalItemsSaved || 0, + message: `Resumed scan complete! Total: ${finalScanJob?.totalItemsSaved || 0} items in library "${scanJob.library.name}"`, + scanJobId, + }); + + return { + libraryId: scanJob.libraryId, + libraryName: scanJob.library.name, + totalFolders: finalScanJob?.totalFolders || 0, + foldersProcessed: finalScanJob?.processedCount || 0, + foldersFailed: finalScanJob?.failedCount || 0, + totalItemsSaved: finalScanJob?.totalItemsSaved || 0, + scanJobId, + }; + }, + + /** + * Get scan job status + */ + getJobStatus: async (scanJobId: string) => { + return getScanJobStatus(scanJobId); + }, + + /** + * Manually cleanup stale jobs + */ + cleanupStaleJobs: async (staleTimeoutMs?: number) => { + const cleanedCount = await cleanupStaleJobs(staleTimeoutMs); + return { + cleanedCount, + message: `Cleaned up ${cleanedCount} stale scan job(s)`, + }; + }, }; diff --git a/apps/api/src/domains/search/index.ts b/apps/api/src/domains/search/index.ts index ae47204..355a467 100644 --- a/apps/api/src/domains/search/index.ts +++ b/apps/api/src/domains/search/index.ts @@ -2,4 +2,3 @@ export * from "./search.controller"; export * from "./search.routes"; export * from "./search.schema"; export * from "./search.services"; - diff --git a/apps/api/src/domains/search/search.controller.ts b/apps/api/src/domains/search/search.controller.ts index 0faf5e4..f0705dc 100644 --- a/apps/api/src/domains/search/search.controller.ts +++ b/apps/api/src/domains/search/search.controller.ts @@ -16,4 +16,3 @@ export const searchControllers = { return sendSuccess(res, results); }), }; - diff --git a/apps/api/src/domains/search/search.routes.ts b/apps/api/src/domains/search/search.routes.ts index 0496a88..cf26955 100644 --- a/apps/api/src/domains/search/search.routes.ts +++ b/apps/api/src/domains/search/search.routes.ts @@ -86,6 +86,12 @@ const router: Router = express.Router(); * backdropUrl: * type: string * nullable: true + * meshGradientColors: + * type: array + * items: + * type: string + * description: Hex color strings for mesh gradient (4 corners) + * example: ["#7C3AED", "#2563EB", "#EC4899", "#8B5CF6"] * releaseDate: * type: string * format: date-time @@ -137,6 +143,12 @@ const router: Router = express.Router(); * backdropUrl: * type: string * nullable: true + * meshGradientColors: + * type: array + * items: + * type: string + * description: Hex color strings for mesh gradient (4 corners) + * example: ["#7C3AED", "#2563EB", "#EC4899", "#8B5CF6"] * releaseDate: * type: string * format: date-time @@ -184,7 +196,10 @@ const router: Router = express.Router(); * type: string * example: "Failed to search media" */ -router.get("/", validateQuery(searchMediaSchema), searchControllers.searchMedia); +router.get( + "/", + validateQuery(searchMediaSchema), + searchControllers.searchMedia, +); export default router; - diff --git a/apps/api/src/domains/search/search.schema.ts b/apps/api/src/domains/search/search.schema.ts index 31b4363..956b7aa 100644 --- a/apps/api/src/domains/search/search.schema.ts +++ b/apps/api/src/domains/search/search.schema.ts @@ -8,4 +8,3 @@ export const searchMediaSchema = z.object({ }); export type SearchMediaQuery = z.infer; - diff --git a/apps/api/src/domains/search/search.services.ts b/apps/api/src/domains/search/search.services.ts index d9a5a89..74d4a76 100644 --- a/apps/api/src/domains/search/search.services.ts +++ b/apps/api/src/domains/search/search.services.ts @@ -7,10 +7,10 @@ export const searchServices = { searchMedia: async (query: string) => { // Total limit across all media types const TOTAL_LIMIT = 10; - + // Fetch more than needed to ensure we get a good mix const FETCH_LIMIT = 20; - + // Search the Media table directly by title and filter by type const [movies, tvShows] = await Promise.all([ // Search for movies @@ -62,6 +62,7 @@ export const searchServices = { description: media.description, posterUrl: media.posterUrl, backdropUrl: media.backdropUrl, + meshGradientColors: media.meshGradientColors, releaseDate: media.releaseDate, rating: media.rating, createdAt: media.createdAt, @@ -80,6 +81,7 @@ export const searchServices = { description: media.description, posterUrl: media.posterUrl, backdropUrl: media.backdropUrl, + meshGradientColors: media.meshGradientColors, releaseDate: media.releaseDate, rating: media.rating, createdAt: media.createdAt, @@ -90,19 +92,23 @@ export const searchServices = { // Interleave movies and TV shows, then limit to TOTAL_LIMIT const combined = []; const maxLength = Math.max(formattedMovies.length, formattedTvShows.length); - + for (let i = 0; i < maxLength && combined.length < TOTAL_LIMIT; i++) { if (i < formattedMovies.length && combined.length < TOTAL_LIMIT) { - combined.push({ type: 'movie', data: formattedMovies[i] }); + combined.push({ type: "movie", data: formattedMovies[i] }); } if (i < formattedTvShows.length && combined.length < TOTAL_LIMIT) { - combined.push({ type: 'tvShow', data: formattedTvShows[i] }); + combined.push({ type: "tvShow", data: formattedTvShows[i] }); } } // Separate back into movies and TV shows - const finalMovies = combined.filter(item => item.type === 'movie').map(item => item.data); - const finalTvShows = combined.filter(item => item.type === 'tvShow').map(item => item.data); + const finalMovies = combined + .filter((item) => item.type === "movie") + .map((item) => item.data); + const finalTvShows = combined + .filter((item) => item.type === "tvShow") + .map((item) => item.data); return { movies: finalMovies, @@ -110,4 +116,3 @@ export const searchServices = { }; }, }; - diff --git a/apps/api/src/domains/settings/settings.controller.ts b/apps/api/src/domains/settings/settings.controller.ts index 2003744..8629886 100644 --- a/apps/api/src/domains/settings/settings.controller.ts +++ b/apps/api/src/domains/settings/settings.controller.ts @@ -27,7 +27,7 @@ export const settingsControllers = { res, updatedSettings, 200, - "Settings updated successfully" + "Settings updated successfully", ); }), diff --git a/apps/api/src/domains/settings/settings.routes.ts b/apps/api/src/domains/settings/settings.routes.ts index 6d28503..06e8459 100644 --- a/apps/api/src/domains/settings/settings.routes.ts +++ b/apps/api/src/domains/settings/settings.routes.ts @@ -168,7 +168,7 @@ router.get("/", validate(getSettingsSchema, "query"), settingsControllers.get); router.put( "/", validate(updateSettingsSchema, "body"), - settingsControllers.update + settingsControllers.update, ); /** diff --git a/apps/api/src/domains/settings/settings.schema.ts b/apps/api/src/domains/settings/settings.schema.ts index 2a297b6..a768823 100644 --- a/apps/api/src/domains/settings/settings.schema.ts +++ b/apps/api/src/domains/settings/settings.schema.ts @@ -11,4 +11,3 @@ export const getSettingsSchema = z.object({}); export type UpdateSettingsRequest = z.infer; export type GetSettingsRequest = z.infer; - diff --git a/apps/api/src/domains/stream/stream.controller.ts b/apps/api/src/domains/stream/stream.controller.ts index f959a22..e1a044f 100644 --- a/apps/api/src/domains/stream/stream.controller.ts +++ b/apps/api/src/domains/stream/stream.controller.ts @@ -1,11 +1,11 @@ import { Request, Response } from "express"; import { streamServices } from "./stream.services"; -import { - logger, - mapHostToContainerPath, - asyncHandler, +import { + logger, + mapHostToContainerPath, + asyncHandler, NotFoundError, - getMimeType + getMimeType, } from "@/lib/utils"; import { z } from "zod"; import { streamMediaSchema } from "./stream.schema"; @@ -57,7 +57,7 @@ export const streamControllers = { throw new NotFoundError( "Media file", - `${id}. Host path: ${hostFilePath}, Container path: ${filePath}` + `${id}. Host path: ${hostFilePath}, Container path: ${filePath}`, ); } @@ -83,7 +83,23 @@ export const streamControllers = { if (!range) { // No range requested, send entire file res.status(200); - const fileStream = createReadStream(filePath); + const fileStream = createReadStream(filePath, { + highWaterMark: 1024 * 1024, // 1MB chunks for better buffering on slow mounts + }); + + // Handle stream errors + fileStream.on("error", (error) => { + logger.error(`Stream error for ${filePath}:`, error); + if (!res.headersSent) { + res.status(500).end(); + } + }); + + // Keep connection alive + res.on("close", () => { + fileStream.destroy(); + }); + return fileStream.pipe(res); } @@ -109,8 +125,12 @@ export const streamControllers = { res.setHeader("Content-Range", `bytes ${start}-${end}/${fileSize}`); res.setHeader("Content-Length", chunkSize); - // Create stream for the requested range - const fileStream = createReadStream(filePath, { start, end }); + // Create stream for the requested range with optimized settings for remote mounts + const fileStream = createReadStream(filePath, { + start, + end, + highWaterMark: 1024 * 1024, // 1MB chunks for better buffering on slow mounts + }); fileStream.on("error", (error) => { logger.error(`Stream error for ${filePath}:`, error); @@ -123,6 +143,12 @@ export const streamControllers = { } }); + // Keep connection alive and cleanup on close + res.on("close", () => { + logger.debug(`Client disconnected, destroying stream for ${filePath}`); + fileStream.destroy(); + }); + return fileStream.pipe(res); }), }; diff --git a/apps/api/src/domains/stream/stream.routes.ts b/apps/api/src/domains/stream/stream.routes.ts index a5b7108..43cc051 100644 --- a/apps/api/src/domains/stream/stream.routes.ts +++ b/apps/api/src/domains/stream/stream.routes.ts @@ -132,7 +132,7 @@ const router: Router = express.Router(); router.get( "/:id", validateParams(streamMediaSchema), - streamControllers.streamMedia + streamControllers.streamMedia, ); export default router; diff --git a/apps/api/src/domains/tvshows/tvshows.routes.ts b/apps/api/src/domains/tvshows/tvshows.routes.ts index b641f9f..e927396 100644 --- a/apps/api/src/domains/tvshows/tvshows.routes.ts +++ b/apps/api/src/domains/tvshows/tvshows.routes.ts @@ -65,6 +65,12 @@ const router: Router = express.Router(); * backdropUrl: * type: string * nullable: true + * meshGradientColors: + * type: array + * items: + * type: string + * description: Hex color strings for mesh gradient (4 corners) + * example: ["#7C3AED", "#2563EB", "#EC4899", "#8B5CF6"] * releaseDate: * type: string * format: date-time @@ -164,6 +170,12 @@ router.get("/", tvshowsControllers.getTVShows); * backdropUrl: * type: string * nullable: true + * meshGradientColors: + * type: array + * items: + * type: string + * description: Hex color strings for mesh gradient (4 corners) + * example: ["#7C3AED", "#2563EB", "#EC4899", "#8B5CF6"] * releaseDate: * type: string * format: date-time @@ -297,7 +309,7 @@ router.get("/", tvshowsControllers.getTVShows); router.get( "/:id", validateParams(getTVShowByIdSchema), - tvshowsControllers.getTVShowById + tvshowsControllers.getTVShowById, ); export default router; diff --git a/apps/api/src/domains/tvshows/tvshows.services.ts b/apps/api/src/domains/tvshows/tvshows.services.ts index d0faffe..3d7a35f 100644 --- a/apps/api/src/domains/tvshows/tvshows.services.ts +++ b/apps/api/src/domains/tvshows/tvshows.services.ts @@ -1,18 +1,46 @@ import prisma from "@/lib/database/prisma"; import { TVShowsListResponse, TVShowResponse } from "./tvshows.types"; -import { serializeBigInt, NotFoundError } from "@/lib/utils"; +import { serializeBigInt, NotFoundError, logger } from "@/lib/utils"; +import { + enrichMediaWithColors, + enrichMediaArrayWithColors, +} from "../scan/helpers"; export const tvshowsServices = { getTVShows: async (): Promise => { + logger.info("šŸ“ŗ Fetching TV shows list..."); + const tvshows = await prisma.tVShow.findMany({ include: { media: true, }, + orderBy: { + media: { + createdAt: "desc", // Most recent first + }, + }, + take: 10, // Limit to 10 most recent }); - return serializeBigInt(tvshows) as TVShowsListResponse; + + logger.info(`Found ${tvshows.length} TV shows, enriching with colors...`); + + // Enrich with mesh gradient colors on-demand + const enrichedMedia = await enrichMediaArrayWithColors( + tvshows.map((tv) => tv.media), + ); + + // Map back to tvshow structure + const tvshowsWithColors = tvshows.map((tvshow, index) => ({ + ...tvshow, + media: enrichedMedia[index], + })); + + return serializeBigInt(tvshowsWithColors) as TVShowsListResponse; }, getTVShowById: async (id: string): Promise => { + logger.info(`šŸ“ŗ Fetching TV show by ID: ${id}`); + const tvshow = await prisma.tVShow.findUnique({ where: { id }, include: { @@ -27,32 +55,46 @@ export const tvshowsServices = { if (!tvshow) { throw new NotFoundError("TV Show", id); } - const serialized = serializeBigInt(tvshow) as any; + + logger.info( + `Found TV show: "${tvshow.media.title}", enriching with colors...`, + ); + + // Enrich with mesh gradient colors on-demand + const enrichedMedia = await enrichMediaWithColors(tvshow.media); + const tvshowWithColors = { + ...tvshow, + media: enrichedMedia, + }; + + const serialized = serializeBigInt(tvshowWithColors) as any; // Transform seasons and episodes to match API schema - const seasonsWithTransformedData = serialized.seasons.map((season: any) => ({ - id: season.id, - seasonNumber: season.number, - name: `Season ${season.number}`, - overview: null, - airDate: null, - posterUrl: season.posterUrl, - tvShowId: season.tvShowId, - episodes: season.episodes.map((episode: any) => ({ - id: episode.id, - episodeNumber: episode.number, + const seasonsWithTransformedData = serialized.seasons.map( + (season: any) => ({ + id: season.id, seasonNumber: season.number, - title: episode.title, + name: `Season ${season.number}`, overview: null, - airDate: episode.airDate, - runtime: episode.duration, - stillUrl: episode.stillPath, - filePath: episode.filePath, - fileSize: episode.fileSize, - seasonId: episode.seasonId, - streamUrl: `/api/v1/stream/${episode.id}`, - })), - })); + airDate: null, + posterUrl: season.posterUrl, + tvShowId: season.tvShowId, + episodes: season.episodes.map((episode: any) => ({ + id: episode.id, + episodeNumber: episode.number, + seasonNumber: season.number, + title: episode.title, + overview: null, + airDate: episode.airDate, + runtime: episode.duration, + stillUrl: episode.stillPath, + filePath: episode.filePath, + fileSize: episode.fileSize, + seasonId: episode.seasonId, + streamUrl: `/api/v1/stream/${episode.id}`, + })), + }), + ); return { ...serialized, diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index bb8581f..26f3cf1 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -14,6 +14,14 @@ import { settingsManager } from "./core/config/settings"; const app = express(); const httpServer = createServer(app); +// Enable SO_REUSEADDR to allow port reuse immediately after restart +httpServer.on("listening", () => { + const address = httpServer.address(); + if (address && typeof address !== "string") { + // Socket is successfully bound + } +}); + setupMiddleware(app); setupRoutes(app); setupErrorHandling(app); @@ -26,11 +34,24 @@ const startServer = async () => { const isFirstRun = await settingsManager.isFirstRun(); const tmdbApiKey = await settingsManager.getTmdbApiKey(); + // Handle port already in use error + httpServer.on("error", (error: NodeJS.ErrnoException) => { + if (error.code === "EADDRINUSE") { + logger.error(`āŒ Port ${config.port} is already in use!`); + logger.error(`šŸ’” To kill the process using this port, run:`); + logger.error(` lsof -ti:${config.port} | xargs kill -9`); + process.exit(1); + } else { + logger.error("Server error:", error); + process.exit(1); + } + }); + httpServer.listen(config.port, "0.0.0.0", async () => { logger.info(`šŸš€ Server running on port ${config.port}`); logger.info(`šŸ“Š Health check: http://localhost:${config.port}/health`); logger.info( - `šŸ“š API Documentation: http://localhost:${config.port}/api/docs` + `šŸ“š API Documentation: http://localhost:${config.port}/api/docs`, ); logger.info(`šŸ”Œ WebSocket endpoint: ws://localhost:${config.port}/ws`); logger.info(`šŸ”§ Environment: ${config.nodeEnv}`); @@ -43,6 +64,77 @@ const startServer = async () => { } else { logger.info("āš ļø TMDB API key not configured - add it in settings"); } + + // Auto-resume interrupted scan jobs from previous session + logger.info( + "šŸ” Checking for interrupted scan jobs from previous session...", + ); + const interruptedJobs = await prisma.scanJob.findMany({ + where: { + status: { + in: ["IN_PROGRESS", "PENDING"], + }, + }, + include: { + library: { + select: { name: true }, + }, + }, + }); + + if (interruptedJobs.length > 0) { + logger.info( + `āøļø Found ${interruptedJobs.length} interrupted scan job(s) - auto-resuming...`, + ); + + // Import scanServices dynamically to avoid circular deps + const { scanServices } = await import( + "./domains/scan/scan.services.js" + ); + + for (const job of interruptedJobs) { + logger.info( + `šŸ”„ Resuming: ${job.library.name} (${job.processedCount}/${job.totalFolders} folders, Batch ${job.currentBatch}/${job.totalBatches})`, + ); + + // Get TMDB API key + const tmdbApiKey = await settingsManager.getTmdbApiKey(); + if (!tmdbApiKey) { + logger.warn( + ` āš ļø Skipping ${job.library.name} - TMDB API key not configured`, + ); + continue; + } + + // First, mark as FAILED so resumeScanJob can pick it up + await prisma.scanJob.update({ + where: { id: job.id }, + data: { status: "FAILED" }, + }); + + // Resume in background (small delay to ensure DB update propagates) + setTimeout(() => { + scanServices + .resumeScanJob(job.id, tmdbApiKey) + .then((result) => { + logger.info( + `āœ… Auto-resumed scan completed: ${result.libraryName} (${result.totalItemsSaved} additional items)`, + ); + }) + .catch((error: unknown) => { + logger.error( + `āŒ Auto-resume failed for ${job.library.name}: ${error instanceof Error ? error.message : error}`, + ); + }); + }, 100); + } + + logger.info( + `āœ… Started auto-resume for ${interruptedJobs.length} scan job(s)`, + ); + } else { + logger.info("āœ… No interrupted scans found"); + } }); } catch (error) { logger.error("Failed to start server:", error); @@ -50,14 +142,37 @@ const startServer = async () => { } }; -const gracefulShutdown = async () => { - logger.info("Shutting down gracefully..."); +const gracefulShutdown = async (signal: string) => { + logger.info(`Received ${signal}, shutting down gracefully...`); + + // Close HTTP server first + httpServer.close(() => { + logger.info("HTTP server closed"); + }); + + // Close WebSocket connections wsManager.close(); + + // Disconnect from database await prisma.$disconnect(); + + logger.info("Shutdown complete"); process.exit(0); }; -process.on("SIGTERM", gracefulShutdown); -process.on("SIGINT", gracefulShutdown); +process.on("SIGTERM", () => gracefulShutdown("SIGTERM")); +process.on("SIGINT", () => gracefulShutdown("SIGINT")); +process.on("SIGUSR2", () => gracefulShutdown("SIGUSR2")); // tsx/nodemon sends this + +// Handle uncaught exceptions to prevent zombie processes +process.on("uncaughtException", (error) => { + logger.error("Uncaught Exception:", error); + gracefulShutdown("uncaughtException"); +}); + +process.on("unhandledRejection", (reason) => { + logger.error("Unhandled Rejection:", reason); + gracefulShutdown("unhandledRejection"); +}); startServer(); diff --git a/apps/api/src/lib/config/swagger.ts b/apps/api/src/lib/config/swagger.ts index 5c0901f..3999061 100644 --- a/apps/api/src/lib/config/swagger.ts +++ b/apps/api/src/lib/config/swagger.ts @@ -4,6 +4,19 @@ import { config } from "../../core/config/env"; import { logger } from "../../lib/utils"; import path from "path"; import os from "os"; +import { readFileSync } from "fs"; + +// Read version from package.json +function getApiVersion(): string { + try { + const packageJson = JSON.parse( + readFileSync(path.join(__dirname, "../../../package.json"), "utf-8"), + ); + return packageJson.version; + } catch (error) { + return "0.1.0"; // Fallback version + } +} /// Get local machine IP address for LAN access function getLocalIpAddress(): string { @@ -22,13 +35,14 @@ function getLocalIpAddress(): string { } const localIp = getLocalIpAddress(); +const apiVersion = getApiVersion(); const options = { definition: { openapi: "3.0.0", info: { title: "DesterLib API", - version: "1.0.0", + version: apiVersion, description: "API documentation for DesterLib - A comprehensive media library management system", contact: { @@ -79,6 +93,10 @@ const options = { name: "Settings", description: "Application settings and configuration", }, + { + name: "Logs", + description: "API logs and debugging endpoints", + }, ], components: { schemas: { @@ -89,6 +107,11 @@ const options = { type: "string", example: "OK", }, + version: { + type: "string", + example: "0.1.0", + description: "API version", + }, timestamp: { type: "string", format: "date-time", @@ -218,7 +241,7 @@ let specs: SwaggerSpec; try { specs = swaggerJsdoc(options) as SwaggerSpec; logger.info( - `Swagger specs generated successfully with ${Object.keys(specs.paths || {}).length} paths` + `Swagger specs generated successfully with ${Object.keys(specs.paths || {}).length} paths`, ); } catch (error) { logger.error("Error generating Swagger specs:", error); @@ -227,7 +250,7 @@ try { openapi: "3.0.0", info: { title: "DesterLib API", - version: "1.0.0", + version: apiVersion, description: "API documentation for DesterLib", }, paths: {}, diff --git a/apps/api/src/lib/database/index.ts b/apps/api/src/lib/database/index.ts index 3a765e9..68dcd75 100644 --- a/apps/api/src/lib/database/index.ts +++ b/apps/api/src/lib/database/index.ts @@ -1,2 +1,2 @@ export { default as prisma } from "./prisma"; -export { MediaType } from "@prisma/client"; +export { MediaType, ScanJobStatus } from "@prisma/client"; diff --git a/apps/api/src/lib/database/prisma-types.ts b/apps/api/src/lib/database/prisma-types.ts index 4945aaf..22f46cb 100644 --- a/apps/api/src/lib/database/prisma-types.ts +++ b/apps/api/src/lib/database/prisma-types.ts @@ -18,4 +18,3 @@ export interface PrismaErrorEvent { message: string; target: string; } - diff --git a/apps/api/src/lib/middleware/error-handler.middleware.ts b/apps/api/src/lib/middleware/error-handler.middleware.ts index ddc1c3f..996efff 100644 --- a/apps/api/src/lib/middleware/error-handler.middleware.ts +++ b/apps/api/src/lib/middleware/error-handler.middleware.ts @@ -5,7 +5,7 @@ import { logger, ApiError, ValidationError } from "../utils"; // 404 handler export const notFoundHandler = ( req: express.Request, - res: express.Response + res: express.Response, ) => { res.status(404).json({ success: false, @@ -19,7 +19,7 @@ export const errorHandler = ( error: Error, req: express.Request, res: express.Response, - _next: express.NextFunction // eslint-disable-line @typescript-eslint/no-unused-vars + _next: express.NextFunction, // eslint-disable-line @typescript-eslint/no-unused-vars ) => { // Handle custom API errors if (error instanceof ApiError) { diff --git a/apps/api/src/lib/middleware/index.ts b/apps/api/src/lib/middleware/index.ts index e1ab157..95f5b6f 100644 --- a/apps/api/src/lib/middleware/index.ts +++ b/apps/api/src/lib/middleware/index.ts @@ -11,4 +11,5 @@ export { validateParams, } from "./validation.middleware"; export { sanitizeInput } from "./sanitization.middleware"; +export { validateVersion, addVersionHeader } from "./version.middleware"; // Auth middleware exports go here diff --git a/apps/api/src/lib/middleware/sanitization.middleware.ts b/apps/api/src/lib/middleware/sanitization.middleware.ts index f377e91..5543358 100644 --- a/apps/api/src/lib/middleware/sanitization.middleware.ts +++ b/apps/api/src/lib/middleware/sanitization.middleware.ts @@ -16,7 +16,7 @@ export function sanitizeInput( trimWhitespace?: boolean; maxLength?: number; }; - } = {} + } = {}, ) { const { sanitizeBody = true, @@ -40,7 +40,7 @@ export function sanitizeInput( if (sanitizeQuery && req.query && typeof req.query === "object") { req.query = sanitizeObject( req.query, - sanitizeOptions + sanitizeOptions, ) as typeof req.query; } @@ -48,14 +48,14 @@ export function sanitizeInput( if (sanitizeParams && req.params && typeof req.params === "object") { req.params = sanitizeObject( req.params, - sanitizeOptions + sanitizeOptions, ) as typeof req.params; } next(); } catch (error) { logger.error( - `Sanitization error: ${error instanceof Error ? error.message : error}` + `Sanitization error: ${error instanceof Error ? error.message : error}`, ); return res.status(400).json({ error: "Invalid input data", diff --git a/apps/api/src/lib/middleware/setup.middleware.ts b/apps/api/src/lib/middleware/setup.middleware.ts index 9c7a7f2..f93dc13 100644 --- a/apps/api/src/lib/middleware/setup.middleware.ts +++ b/apps/api/src/lib/middleware/setup.middleware.ts @@ -5,12 +5,32 @@ import compression from "compression"; import rateLimit from "express-rate-limit"; import os from "os"; import { config } from "../../core/config/env"; -import { sanitizeInput } from "."; +import { sanitizeInput, addVersionHeader, validateVersion } from "."; +import { logger } from "../utils"; const limiter = rateLimit({ windowMs: config.rateLimitWindowMs, max: config.rateLimitMax, message: "Too many requests from this IP, please try again later.", + // Skip rate limiting for localhost, scan routes, stream routes, and WebSocket + skip: (req) => { + // Skip for localhost/127.0.0.1 + const isLocalhost = + req.ip === "127.0.0.1" || + req.ip === "::1" || + req.ip === "localhost" || + req.hostname === "localhost" || + req.hostname === "127.0.0.1"; + + if (isLocalhost) return true; + + // Skip for specific routes + return ( + req.path.startsWith("/api/v1/scan") || + req.path.startsWith("/api/v1/stream") || + req.path.startsWith("/ws") // WebSocket + ); + }, }); // Get local machine IP address for LAN access @@ -32,6 +52,18 @@ function getLocalIpAddress(): string { const localIp = getLocalIpAddress(); export function setupMiddleware(app: express.Application) { + // HTTP Request logging (skip health checks and websocket) + app.use((req, res, next) => { + if ( + req.path !== "/health" && + req.path !== "/ws" && + !req.path.includes("/api/docs") + ) { + logger.debug(`${req.method} ${req.path}`); + } + next(); + }); + // Only trust loopback proxies to satisfy express-rate-limit validation app.set("trust proxy", "loopback"); app.use( @@ -39,7 +71,7 @@ export function setupMiddleware(app: express.Application) { // Disable HSTS in development to prevent HTTPS forcing hsts: config.nodeEnv === "production" ? undefined : false, contentSecurityPolicy: false, // Disable CSP to prevent upgrade-insecure-requests - }) + }), ); app.use(compression()); app.use(limiter); @@ -72,7 +104,7 @@ export function setupMiddleware(app: express.Application) { // Allow LAN IP access in all environments (192.168.x.x, 10.x.x.x, 172.16-31.x.x) const isLocalNetwork = /^https?:\/\/(192\.168\.\d{1,3}\.\d{1,3}|10\.\d{1,3}\.\d{1,3}\.\d{1,3}|172\.(1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3})(:\d+)?$/.test( - origin + origin, ); if (isLocalNetwork) { return callback(null, true); @@ -87,10 +119,22 @@ export function setupMiddleware(app: express.Application) { credentials: true, optionsSuccessStatus: 200, methods: ["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"], - allowedHeaders: ["Content-Type", "Authorization", "X-Requested-With"], - }) + allowedHeaders: [ + "Content-Type", + "Authorization", + "X-Requested-With", + "X-Client-Version", + ], + exposedHeaders: ["X-API-Version"], + }), ); + // Add API version to response headers + app.use(addVersionHeader); + + // Validate client version (only for /api/v1 routes) + app.use("/api/v1", validateVersion); + // JSON parsing middleware - skip for static assets and image/media files app.use((req, res, next) => { // Skip JSON parsing for static assets and media files @@ -127,6 +171,6 @@ export function setupMiddleware(app: express.Application) { trimWhitespace: true, maxLength: 50000, // Higher limit for global middleware }, - }) + }), ); } diff --git a/apps/api/src/lib/middleware/validation.middleware.ts b/apps/api/src/lib/middleware/validation.middleware.ts index 628993d..6ef933c 100644 --- a/apps/api/src/lib/middleware/validation.middleware.ts +++ b/apps/api/src/lib/middleware/validation.middleware.ts @@ -15,7 +15,7 @@ type ValidateOptions = { export function validate( schema: z.ZodTypeAny, dataSource: "body" | "query" | "params" = "body", - options: ValidateOptions = {} + options: ValidateOptions = {}, ) { return (req: Request, res: Response, next: NextFunction) => { try { @@ -72,6 +72,9 @@ export function validateQuery(schema: z.ZodTypeAny, options?: ValidateOptions) { /** * Utility function to validate route parameters with optional sanitization */ -export function validateParams(schema: z.ZodTypeAny, options?: ValidateOptions) { +export function validateParams( + schema: z.ZodTypeAny, + options?: ValidateOptions, +) { return validate(schema, "params", options); } diff --git a/apps/api/src/lib/middleware/version.middleware.ts b/apps/api/src/lib/middleware/version.middleware.ts new file mode 100644 index 0000000..4075e81 --- /dev/null +++ b/apps/api/src/lib/middleware/version.middleware.ts @@ -0,0 +1,102 @@ +import { Request, Response, NextFunction } from "express"; +import { readFileSync } from "fs"; +import { join } from "path"; +import { logger } from "../utils"; + +// Read version from package.json +function getApiVersion(): string { + try { + const packageJson = JSON.parse( + readFileSync(join(__dirname, "../../../package.json"), "utf-8"), + ); + return packageJson.version; + } catch (error) { + return "unknown"; + } +} + +// Parse semantic version string to compare +function parseVersion( + version: string, +): { major: number; minor: number; patch: number } | null { + const match = version.match(/^(\d+)\.(\d+)\.(\d+)/); + if (!match || !match[1] || !match[2] || !match[3]) return null; + return { + major: parseInt(match[1], 10), + minor: parseInt(match[2], 10), + patch: parseInt(match[3], 10), + }; +} + +// Check if client version is compatible with API version +function isVersionCompatible( + clientVersion: string, + apiVersion: string, +): boolean { + const client = parseVersion(clientVersion); + const api = parseVersion(apiVersion); + + if (!client || !api) return false; + + // Major version must match + if (client.major !== api.major) return false; + + // Minor version must match (we enforce strict minor version matching) + if (client.minor !== api.minor) return false; + + // Patch version can differ (backwards compatible) + return true; +} + +/** + * Middleware to validate client version compatibility + * Expects a 'X-Client-Version' header from the client + */ +export function validateVersion( + req: Request, + res: Response, + next: NextFunction, +): void { + const clientVersion = req.headers["x-client-version"] as string; + const apiVersion = getApiVersion(); + + // Skip validation if no client version is provided (for backwards compatibility) + if (!clientVersion) { + logger.warn("No client version provided in request headers"); + next(); + return; + } + + // Check version compatibility + if (!isVersionCompatible(clientVersion, apiVersion)) { + logger.warn( + `Version mismatch: Client version ${clientVersion} is not compatible with API version ${apiVersion}`, + ); + res.status(426).json({ + success: false, + error: "Version mismatch", + message: `Client version ${clientVersion} is not compatible with API version ${apiVersion}. Please update your client.`, + data: { + clientVersion, + apiVersion, + upgradeRequired: true, + }, + }); + return; + } + + next(); +} + +/** + * Add API version to response headers + */ +export function addVersionHeader( + req: Request, + res: Response, + next: NextFunction, +): void { + const apiVersion = getApiVersion(); + res.setHeader("X-API-Version", apiVersion); + next(); +} diff --git a/apps/api/src/lib/providers/tmdb/tmdb.services.ts b/apps/api/src/lib/providers/tmdb/tmdb.services.ts index caaeb70..7251250 100644 --- a/apps/api/src/lib/providers/tmdb/tmdb.services.ts +++ b/apps/api/src/lib/providers/tmdb/tmdb.services.ts @@ -15,7 +15,7 @@ export const tmdbServices = { lang?: string; abortSignal?: AbortSignal; extraParams?: Record; - } + }, ) => { try { const response = await axios.get( @@ -28,7 +28,7 @@ export const tmdbServices = { ...extraParams, }, timeout: 8000, - } + }, ); return response.data; } catch (error) { @@ -37,7 +37,7 @@ export const tmdbServices = { throw new Error( `TMDB ${type} ${id} failed (${error.response.status}): ${ error.response.data?.status_message || "Unknown error" - }` + }`, ); } throw error; @@ -53,7 +53,7 @@ export const tmdbServices = { }: { apiKey: string; lang?: string; - } + }, ) => { try { const response = await axios.get( @@ -64,7 +64,7 @@ export const tmdbServices = { language: lang, }, timeout: 8000, - } + }, ); return response.data; } catch (error) { @@ -73,7 +73,7 @@ export const tmdbServices = { throw new Error( `TMDB episode fetch failed (${error.response.status}): ${ error.response.data?.status_message || "Unknown error" - }` + }`, ); } throw error; @@ -88,7 +88,7 @@ export const tmdbServices = { }: { apiKey: string; lang?: string; - } + }, ) => { try { const response = await axios.get( @@ -99,7 +99,7 @@ export const tmdbServices = { language: lang, }, timeout: 8000, - } + }, ); return response.data; } catch (error) { @@ -108,7 +108,7 @@ export const tmdbServices = { throw new Error( `TMDB season fetch failed (${error.response.status}): ${ error.response.data?.status_message || "Unknown error" - }` + }`, ); } throw error; @@ -125,7 +125,7 @@ export const tmdbServices = { apiKey: string; year?: string; lang?: string; - } + }, ) => { try { const params: Record = { @@ -142,7 +142,7 @@ export const tmdbServices = { { params, timeout: 8000, - } + }, ); const results = response.data.results || []; @@ -153,7 +153,7 @@ export const tmdbServices = { throw new Error( `TMDB search failed (${error.response.status}): ${ error.response.data?.status_message || "Unknown error" - }` + }`, ); } throw error; diff --git a/apps/api/src/lib/utils/docker-path.util.ts b/apps/api/src/lib/utils/docker-path.util.ts index 59a3703..ecdce29 100644 --- a/apps/api/src/lib/utils/docker-path.util.ts +++ b/apps/api/src/lib/utils/docker-path.util.ts @@ -29,12 +29,12 @@ export function isRunningInDocker(): boolean { accessSync(PATH_CONFIG.DOCKER_CHECK_PATH); isDockerEnvironment = true; logger.info( - `āœ… Running in Docker container, ${PATH_CONFIG.DOCKER_CHECK_PATH} is accessible` + `āœ… Running in Docker container, ${PATH_CONFIG.DOCKER_CHECK_PATH} is accessible`, ); } catch { isDockerEnvironment = false; logger.info( - `ā„¹ļø Running locally (not in Docker), using direct file paths` + `ā„¹ļø Running locally (not in Docker), using direct file paths`, ); } } @@ -44,15 +44,15 @@ export function isRunningInDocker(): boolean { /** * Maps host paths to container paths for file system access * This is used when the API needs to access files in Docker - * + * * @param hostPath - Original path from the host system * @returns Mapped container path if in Docker, otherwise original path - * + * * @example * // In Docker: * mapHostToContainerPath("/Volumes/External/Library/Media/Movies/file.mp4") * // Returns: "/media/Movies/file.mp4" - * + * * // Outside Docker: * mapHostToContainerPath("/Volumes/External/Library/Media/Movies/file.mp4") * // Returns: "/Volumes/External/Library/Media/Movies/file.mp4" @@ -80,24 +80,30 @@ export function mapHostToContainerPath(hostPath: string): string { /** * Maps container paths back to host paths for database storage * This ensures we always store user-friendly host paths in the database - * + * * @param containerPath - Path from the container * @param originalHostPath - Optional original host path for reference * @returns Mapped host path - * + * * @example * mapContainerToHostPath("/media/Movies/file.mp4") * // Returns: "/Volumes/External/Library/Media/Movies/file.mp4" */ export function mapContainerToHostPath( containerPath: string, - originalHostPath?: string + originalHostPath?: string, ): string { // If we have the original host path and container path starts with container base - if (originalHostPath && containerPath.startsWith(PATH_CONFIG.CONTAINER_BASE)) { + if ( + originalHostPath && + containerPath.startsWith(PATH_CONFIG.CONTAINER_BASE) + ) { if (originalHostPath.startsWith(PATH_CONFIG.HOST_BASE)) { // Extract the relative path from the container path - const relativePath = containerPath.replace(PATH_CONFIG.CONTAINER_BASE, ""); + const relativePath = containerPath.replace( + PATH_CONFIG.CONTAINER_BASE, + "", + ); return `${PATH_CONFIG.HOST_BASE}${relativePath}`; } } @@ -111,4 +117,3 @@ export function mapContainerToHostPath( // Otherwise, return as-is return containerPath; } - diff --git a/apps/api/src/lib/utils/external-id.util.ts b/apps/api/src/lib/utils/external-id.util.ts index 1a03787..46c5c53 100644 --- a/apps/api/src/lib/utils/external-id.util.ts +++ b/apps/api/src/lib/utils/external-id.util.ts @@ -54,8 +54,14 @@ export function extractIds(name: string): ExtractedIds { // Remove release group tags at start [GroupName] .replace(/^\[[\w\s-]+\]\s*/i, "") // Remove quality/codec info in brackets/parentheses (1080p, AV1, BD, etc.) - .replace(/\([^)]*(?:1080p|720p|480p|2160p|4K|AV1|x264|x265|HEVC|BD|BluRay|WEB-?DL|WEBRip)[^)]*\)/gi, "") - .replace(/\[[^\]]*(?:1080p|720p|480p|2160p|4K|AV1|x264|x265|HEVC|BD|BluRay|WEB-?DL|WEBRip)[^\]]*\]/gi, "") + .replace( + /\([^)]*(?:1080p|720p|480p|2160p|4K|AV1|x264|x265|HEVC|BD|BluRay|WEB-?DL|WEBRip)[^)]*\)/gi, + "", + ) + .replace( + /\[[^\]]*(?:1080p|720p|480p|2160p|4K|AV1|x264|x265|HEVC|BD|BluRay|WEB-?DL|WEBRip)[^\]]*\]/gi, + "", + ) // Remove hash codes in brackets [A1B2C3D4] .replace(/\[[0-9A-F]{8}\]/gi, "") // Remove episode tags like (OAD1), (OVA), (Special), etc. @@ -82,7 +88,10 @@ export function extractIds(name: string): ExtractedIds { // Remove resolution and quality (2160p, 1080p, 720p, 480p, 4K, UHD, HD, SD, etc.) .replace(/\b(2160p|1080p|1440p|720p|480p|360p|4K|8K|UHD|FHD|HD|SD)\b/gi, "") // Remove source/release type (BluRay, BDRip, WEB-DL, WEBRip, HDTV, DVDRip, etc.) - .replace(/\b(BluRay|Blu-?Ray|BDRip|BD|BRRip|WEB-?DL|WEBRip|WEB|HDTV|DVDRip|DVD|AMZN|ATVP|MA|DS4K|35mm|IMAX)\b/gi, "") + .replace( + /\b(BluRay|Blu-?Ray|BDRip|BD|BRRip|WEB-?DL|WEBRip|WEB|HDTV|DVDRip|DVD|AMZN|ATVP|MA|DS4K|35mm|IMAX)\b/gi, + "", + ) // Remove video codecs (H.264, H.265, x264, x265, AV1, HEVC, etc.) .replace(/\b(H\.?26[45]|x26[45]|AV1|HEVC|AVC|10bit|8bit)\b/gi, "") // Remove audio codecs and channels - MUST handle both "5.1" and "5 1" formats (after dot-to-space conversion) @@ -93,7 +102,10 @@ export function extractIds(name: string): ExtractedIds { // Remove HDR/color info (HDR, HDR10, DV, Dolby Vision, SDR, etc.) .replace(/\b(HDR10\+?|HDR|DV|Dolby\s*Vision|SDR)\b/gi, "") // Remove remaster/cut/version info (REMASTERED, EXTENDED, IMAX, Director's Cut, etc.) - .replace(/\b(REMASTERED|EXTENDED|UNRATED|THEATRICAL|Director'?s?\s*Cut|Open\s*Matte|The\s*Super\s*Duper\s*Cut|PROPER)\b/gi, "") + .replace( + /\b(REMASTERED|EXTENDED|UNRATED|THEATRICAL|Director'?s?\s*Cut|Open\s*Matte|The\s*Super\s*Duper\s*Cut|PROPER)\b/gi, + "", + ) // Remove media type keywords .replace(/\b(bluray|brrip|webrip|web)\b/gi, "") // Remove common tags and metadata @@ -107,12 +119,12 @@ export function extractIds(name: string): ExtractedIds { // Remove multiple spaces .replace(/\s+/g, " ") .trim(); - + // Final cleanup: Remove release group tags at the end // They're usually all caps or mixed case names after a dash or space at the end // Examples: KIMJI, RAV1NE, PSA, FLUX, CRUCiBLE, Ralphy, etc. cleanTitle = cleanTitle.replace(/\s+[A-Z][A-Za-z0-9]*$/i, "").trim(); - + // Fix common movie title patterns that may have been mangled cleanTitle = cleanTitle // Fix possessives that got mangled (Sorcerer s -> Sorcerer's) diff --git a/apps/api/src/lib/utils/genre-mapping.util.ts b/apps/api/src/lib/utils/genre-mapping.util.ts index a30c996..b765237 100644 --- a/apps/api/src/lib/utils/genre-mapping.util.ts +++ b/apps/api/src/lib/utils/genre-mapping.util.ts @@ -62,7 +62,7 @@ export function createGenreSlug(genreName: string): string { * Normalize genres from any provider and remove duplicates */ export function normalizeGenres( - genres: Array<{ id: number | string; name: string }> + genres: Array<{ id: number | string; name: string }>, ): Array<{ name: string; slug: string }> { const uniqueGenres = new Map(); diff --git a/apps/api/src/lib/utils/logger.ts b/apps/api/src/lib/utils/logger.ts index ed97da0..a3f847e 100644 --- a/apps/api/src/lib/utils/logger.ts +++ b/apps/api/src/lib/utils/logger.ts @@ -1,4 +1,5 @@ import winston from "winston"; +import { WebSocketTransport } from "./websocket-transport"; // Define log levels const levels = { @@ -35,7 +36,7 @@ const format = winston.format.combine( } return msg; - }) + }), ); // Define which transports the logger must use @@ -49,6 +50,8 @@ const transports = [ }), // File transport for all logs new winston.transports.File({ filename: "logs/combined.log" }), + // WebSocket transport for real-time log streaming + new WebSocketTransport(), ]; // Create the logger diff --git a/apps/api/src/lib/utils/media-finder.util.ts b/apps/api/src/lib/utils/media-finder.util.ts index 73a3099..a11fd69 100644 --- a/apps/api/src/lib/utils/media-finder.util.ts +++ b/apps/api/src/lib/utils/media-finder.util.ts @@ -126,7 +126,7 @@ const MEDIA_QUERIES = [ /** * Find media file by ID across all media types - * + * * @param id - Media file ID * @returns Media file information * @throws NotFoundError if media file is not found @@ -137,11 +137,11 @@ export async function findMediaFileById(id: string): Promise { // Try each media type in sequence for (const query of MEDIA_QUERIES) { const result = await query.finder(id); - + // Type assertion needed because each mapper expects a specific Prisma result type // but TypeScript sees a union of all possible types from the array let mediaInfo: MediaFileInfo | null = null; - + if (query.type === "movie") { mediaInfo = query.mapper(result as MovieWithMedia); } else if (query.type === "episode") { @@ -162,4 +162,3 @@ export async function findMediaFileById(id: string): Promise { logger.error(`āŒ Media file not found with ID: ${id}`); throw new NotFoundError("Media file", id); } - diff --git a/apps/api/src/lib/utils/mime-types.util.ts b/apps/api/src/lib/utils/mime-types.util.ts index 2a54a6f..48e4c4b 100644 --- a/apps/api/src/lib/utils/mime-types.util.ts +++ b/apps/api/src/lib/utils/mime-types.util.ts @@ -16,7 +16,7 @@ const MIME_TYPES: Record = { ".webm": "video/webm", ".flv": "video/x-flv", ".ogv": "video/ogg", - + // Audio types ".mp3": "audio/mpeg", ".flac": "audio/flac", @@ -26,7 +26,7 @@ const MIME_TYPES: Record = { ".aac": "audio/aac", ".wma": "audio/x-ms-wma", ".opus": "audio/opus", - + // Image types ".jpg": "image/jpeg", ".jpeg": "image/jpeg", @@ -35,7 +35,7 @@ const MIME_TYPES: Record = { ".webp": "image/webp", ".svg": "image/svg+xml", ".bmp": "image/bmp", - + // Document types ".pdf": "application/pdf", ".epub": "application/epub+zip", @@ -59,7 +59,7 @@ export function getMimeType(extension: string): string { const normalizedExt = extension.toLowerCase().startsWith(".") ? extension.toLowerCase() : `.${extension.toLowerCase()}`; - + return MIME_TYPES[normalizedExt] || DEFAULT_MIME_TYPE; } @@ -73,12 +73,12 @@ export function getMimeTypeFromFilename(filename: string): string { if (parts.length < 2) { return DEFAULT_MIME_TYPE; } - + const lastPart = parts[parts.length - 1]; if (!lastPart) { return DEFAULT_MIME_TYPE; } - + const extension = `.${lastPart.toLowerCase()}`; return MIME_TYPES[extension] || DEFAULT_MIME_TYPE; } @@ -119,16 +119,16 @@ export function isImageFile(extension: string): boolean { * @returns Category: 'video', 'audio', 'image', 'document', or 'unknown' */ export function getMediaCategory( - extension: string + extension: string, ): "video" | "audio" | "image" | "document" | "unknown" { const mimeType = getMimeType(extension); - + if (!mimeType) return "unknown"; if (mimeType.startsWith("video/")) return "video"; if (mimeType.startsWith("audio/")) return "audio"; if (mimeType.startsWith("image/")) return "image"; if (mimeType.startsWith("application/")) return "document"; - + return "unknown"; } @@ -138,17 +138,16 @@ export function getMediaCategory( * @returns Array of extensions */ export function getSupportedExtensions( - mediaType: "video" | "audio" | "image" | "document" | "all" + mediaType: "video" | "audio" | "image" | "document" | "all", ): string[] { const extensions = Object.keys(MIME_TYPES); - + if (mediaType === "all") { return extensions; } - + return extensions.filter((ext) => { const mime = MIME_TYPES[ext]; return mime && mime.startsWith(`${mediaType}/`); }); } - diff --git a/apps/api/src/lib/utils/response-handlers.util.ts b/apps/api/src/lib/utils/response-handlers.util.ts index 5f64b4b..2a0867b 100644 --- a/apps/api/src/lib/utils/response-handlers.util.ts +++ b/apps/api/src/lib/utils/response-handlers.util.ts @@ -1,12 +1,12 @@ /** * Standardized API Response Handlers - * + * * This module provides utilities for consistent API response formatting * and centralized error handling across all controllers. */ import { Response, Request, NextFunction } from "express"; -import logger from "./logger"; +import logger from "./logger"; // ==================== Response Interfaces ==================== @@ -49,7 +49,7 @@ export class ApiError extends Error { constructor( message: string, public statusCode: number = 500, - public errorType: string = "Internal server error" + public errorType: string = "Internal server error", ) { super(message); this.name = "ApiError"; @@ -79,7 +79,7 @@ export class ValidationError extends ApiError { field: string; message: string; received?: unknown; - }> + }>, ) { super(message, 400, "Validation failed"); } @@ -125,7 +125,7 @@ export class UnprocessableEntityError extends ApiError { /** * Send a standardized success response - * + * * @param res - Express response object * @param data - Response data * @param statusCode - HTTP status code (default: 200) @@ -137,7 +137,7 @@ export function sendSuccess( data: T, statusCode: number = 200, message?: string, - meta?: SuccessResponse["meta"] + meta?: SuccessResponse["meta"], ): Response { const response: SuccessResponse = { success: true, @@ -157,7 +157,7 @@ export function sendSuccess( /** * Send a standardized error response - * + * * @param res - Express response object * @param error - Error object or string * @param statusCode - HTTP status code (default: 500) @@ -169,7 +169,7 @@ export function sendError( error: Error | string, statusCode: number = 500, errorType: string = "Internal server error", - details?: ErrorResponse["details"] + details?: ErrorResponse["details"], ): Response { const message = typeof error === "string" ? error : error.message; @@ -195,7 +195,7 @@ export function sendError( /** * Wrapper for async route handlers to catch errors and pass to error middleware - * + * * Usage: * ```typescript * router.get('/path', asyncHandler(async (req, res) => { @@ -205,7 +205,7 @@ export function sendError( * ``` */ export function asyncHandler( - fn: (req: Request, res: Response, next: NextFunction) => Promise + fn: (req: Request, res: Response, next: NextFunction) => Promise, ) { return (req: Request, res: Response, next: NextFunction) => { Promise.resolve(fn(req, res, next)).catch(next); @@ -216,7 +216,7 @@ export function asyncHandler( /** * Centralized error handling for controllers - * + * * @param error - Error object * @param res - Express response object * @param context - Context string for logging (e.g., "Get movies controller") @@ -225,7 +225,7 @@ export function asyncHandler( export function handleControllerError( error: unknown, res: Response, - context: string + context: string, ): Response { // Handle custom API errors if (error instanceof ApiError) { @@ -240,7 +240,7 @@ export function handleControllerError( error.message, error.statusCode, error.errorType, - error.details + error.details, ); } @@ -262,7 +262,7 @@ export function handleControllerError( /** * Extract pagination parameters from request query - * + * * @param req - Express request object * @param defaultLimit - Default page size (default: 20) * @param maxLimit - Maximum page size (default: 100) @@ -271,7 +271,7 @@ export function handleControllerError( export function getPaginationParams( req: Request, defaultLimit: number = 20, - maxLimit: number = 100 + maxLimit: number = 100, ): { page: number; limit: number; @@ -280,7 +280,7 @@ export function getPaginationParams( const page = Math.max(1, parseInt(req.query.page as string) || 1); const limit = Math.min( maxLimit, - Math.max(1, parseInt(req.query.limit as string) || defaultLimit) + Math.max(1, parseInt(req.query.limit as string) || defaultLimit), ); const skip = (page - 1) * limit; @@ -289,7 +289,7 @@ export function getPaginationParams( /** * Create pagination metadata - * + * * @param page - Current page number * @param limit - Items per page * @param total - Total number of items @@ -298,7 +298,7 @@ export function getPaginationParams( export function createPaginationMeta( page: number, limit: number, - total: number + total: number, ): { page: number; limit: number; @@ -318,4 +318,3 @@ export function createPaginationMeta( hasPrev: page > 1, }; } - diff --git a/apps/api/src/lib/utils/sanitization.util.ts b/apps/api/src/lib/utils/sanitization.util.ts index b1d975c..f71934c 100644 --- a/apps/api/src/lib/utils/sanitization.util.ts +++ b/apps/api/src/lib/utils/sanitization.util.ts @@ -21,7 +21,7 @@ export interface SanitizeOptions { */ export function sanitizeString( input: string, - options: SanitizeOptions = {} + options: SanitizeOptions = {}, ): string { const { stripHtml = true, @@ -46,13 +46,13 @@ export function sanitizeString( // Remove script tags and their content sanitized = sanitized.replace( /)<[^<]*)*<\/script>/gi, - "" + "", ); // Remove other potentially dangerous HTML tags sanitized = sanitized.replace( /<(iframe|object|embed|form|input|meta|link|style)\b[^>]*>.*?<\/\1>/gi, - "" + "", ); // Remove remaining HTML tags but keep content @@ -85,7 +85,7 @@ export function sanitizeString( // Using unicode escapes - intentionally includes control characters for security sanitized = sanitized.replace( /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, - "" + "", ); // Limit length @@ -104,7 +104,7 @@ export function sanitizeString( */ export function sanitizeObject( obj: unknown, - options: SanitizeOptions = {} + options: SanitizeOptions = {}, ): unknown { const { maxDepth = 10 } = options; @@ -122,7 +122,7 @@ export function sanitizeObject( if (Array.isArray(obj)) { return obj.map((item) => - sanitizeObject(item, { ...options, maxDepth: maxDepth - 1 }) + sanitizeObject(item, { ...options, maxDepth: maxDepth - 1 }), ); } diff --git a/apps/api/src/lib/utils/serialization.util.ts b/apps/api/src/lib/utils/serialization.util.ts index b2fd116..852f08c 100644 --- a/apps/api/src/lib/utils/serialization.util.ts +++ b/apps/api/src/lib/utils/serialization.util.ts @@ -6,7 +6,7 @@ * Cleans TMDB image URLs that might have repeated prefixes */ export function cleanTmdbImageUrl( - url: string | null | undefined + url: string | null | undefined, ): string | null { if (!url) return null; diff --git a/apps/api/src/lib/utils/websocket-transport.ts b/apps/api/src/lib/utils/websocket-transport.ts new file mode 100644 index 0000000..415ae86 --- /dev/null +++ b/apps/api/src/lib/utils/websocket-transport.ts @@ -0,0 +1,53 @@ +import winston from "winston"; +import TransportStream from "winston-transport"; +import { sendLogMessage } from "../websocket"; + +/** + * Custom Winston transport that broadcasts log messages via WebSocket + */ +export class WebSocketTransport extends TransportStream { + constructor(opts?: TransportStream.TransportStreamOptions) { + super(opts); + } + + log( + info: { + timestamp: string; + level: string; + message: string; + [key: string]: unknown; + }, + callback: () => void, + ) { + setImmediate(() => { + this.emit("logged", info); + }); + + // Extract relevant log information + const { timestamp, level, message, ...meta } = info; + + // Remove winston metadata from meta + const cleanMeta: Record = { ...meta }; + const levelSymbol = Symbol.for("level") as unknown as string; + const messageSymbol = Symbol.for("message") as unknown as string; + const splatSymbol = Symbol.for("splat") as unknown as string; + delete cleanMeta[levelSymbol]; + delete cleanMeta[messageSymbol]; + delete cleanMeta[splatSymbol]; + + // Broadcast log message to all connected WebSocket clients + try { + sendLogMessage({ + level: level as "error" | "warn" | "info" | "http" | "debug", + message: message as string, + timestamp: timestamp as string, + meta: Object.keys(cleanMeta).length > 0 ? cleanMeta : undefined, + }); + } catch { + // Silently fail if WebSocket is not available or broadcasting fails + // This prevents logger errors from cascading + } + + callback(); + } +} diff --git a/apps/api/src/lib/websocket/index.ts b/apps/api/src/lib/websocket/index.ts index b3902ba..b0b6289 100644 --- a/apps/api/src/lib/websocket/index.ts +++ b/apps/api/src/lib/websocket/index.ts @@ -4,12 +4,25 @@ import { logger } from "@/lib/utils"; interface ScanProgress { type: "scan:progress"; - phase: "scanning" | "fetching-metadata" | "fetching-episodes" | "saving"; + phase: + | "scanning" + | "fetching-metadata" + | "fetching-episodes" + | "saving" + | "discovering" + | "batching" + | "batch-complete"; progress: number; // 0-100 current: number; total: number; message: string; libraryId?: string; + scanJobId?: string; + batchItemComplete?: { + folderName: string; + itemsSaved: number; + totalItems: number; + }; } interface ScanComplete { @@ -17,15 +30,25 @@ interface ScanComplete { libraryId: string; totalItems: number; message: string; + scanJobId?: string; } interface ScanError { type: "scan:error"; libraryId?: string; + scanJobId?: string; error: string; } -type WebSocketMessage = ScanProgress | ScanComplete | ScanError; +interface LogMessage { + type: "log:message"; + level: "error" | "warn" | "info" | "http" | "debug"; + message: string; + timestamp: string; + meta?: Record; +} + +type WebSocketMessage = ScanProgress | ScanComplete | ScanError | LogMessage; // Module-level state let wss: WebSocketServer | null = null; @@ -53,7 +76,7 @@ export function initializeWebSocket(server: HTTPServer) { JSON.stringify({ type: "connection:established", message: "Connected to Dester API WebSocket", - }) + }), ); }); @@ -73,7 +96,7 @@ export function broadcast(message: WebSocketMessage) { } catch (error) { failCount++; logger.error( - `Failed to send message to client: ${error instanceof Error ? error.message : error}` + `Failed to send message to client: ${error instanceof Error ? error.message : error}`, ); } } @@ -81,7 +104,7 @@ export function broadcast(message: WebSocketMessage) { if (successCount > 0 || failCount > 0) { logger.debug( - `šŸ“” Broadcast: ${successCount} successful, ${failCount} failed` + `šŸ“” Broadcast: ${successCount} successful, ${failCount} failed`, ); } } @@ -107,6 +130,13 @@ export function sendScanError(data: Omit) { }); } +export function sendLogMessage(data: Omit) { + broadcast({ + type: "log:message", + ...data, + }); +} + export function getClientCount(): number { return clients.size; } @@ -128,7 +158,14 @@ export const wsManager = { sendScanProgress, sendScanComplete, sendScanError, + sendLogMessage, getClientCount, close: closeWebSocket, }; -export type { ScanProgress, ScanComplete, ScanError, WebSocketMessage }; +export type { + ScanProgress, + ScanComplete, + ScanError, + LogMessage, + WebSocketMessage, +}; diff --git a/apps/api/src/routes/index.ts b/apps/api/src/routes/index.ts index fd52221..8544126 100644 --- a/apps/api/src/routes/index.ts +++ b/apps/api/src/routes/index.ts @@ -1,13 +1,27 @@ import express, { Router } from "express"; +import { readFileSync } from "fs"; +import { join } from "path"; const router: Router = express.Router(); +// Read version from package.json +const getApiVersion = (): string => { + try { + const packageJson = JSON.parse( + readFileSync(join(__dirname, "../../package.json"), "utf-8"), + ); + return packageJson.version; + } catch (error) { + return "unknown"; + } +}; + /** * @swagger * /health: * get: * summary: Health check endpoint - * description: Returns the current status of the API + * description: Returns the current status of the API including version information * tags: [Health] * responses: * 200: @@ -20,6 +34,7 @@ const router: Router = express.Router(); router.get("/health", (req, res) => { res.status(200).json({ status: "OK", + version: getApiVersion(), timestamp: new Date().toISOString(), uptime: process.uptime(), }); diff --git a/apps/api/src/routes/v1/index.ts b/apps/api/src/routes/v1/index.ts index 5496da1..877367e 100644 --- a/apps/api/src/routes/v1/index.ts +++ b/apps/api/src/routes/v1/index.ts @@ -6,6 +6,7 @@ import tvshowsRoutes from "../../domains/tvshows/tvshows.routes"; import streamRoutes from "../../domains/stream/stream.routes"; import settingsRoutes from "../../domains/settings/settings.routes"; import searchRoutes from "../../domains/search/search.routes"; +import logsRoutes from "../../domains/logs/logs.routes"; const router: Router = express.Router(); @@ -30,4 +31,7 @@ router.use("/stream", streamRoutes); // Settings routes router.use("/settings", settingsRoutes); +// Logs routes +router.use("/logs", logsRoutes); + export default router; diff --git a/apps/docs/.prettierrc b/apps/docs/.prettierrc new file mode 100644 index 0000000..d87faa5 --- /dev/null +++ b/apps/docs/.prettierrc @@ -0,0 +1,11 @@ +{ + "plugins": ["prettier-plugin-astro"], + "overrides": [ + { + "files": "*.astro", + "options": { + "parser": "astro" + } + } + ] +} diff --git a/apps/docs/README.md b/apps/docs/README.md index 909d737..67e6ee5 100644 --- a/apps/docs/README.md +++ b/apps/docs/README.md @@ -1,49 +1,13 @@ -# Starlight Starter Kit: Basics +# DesterLib Documentation -[![Built with Starlight](https://astro.badg.es/v2/built-with-starlight/tiny.svg)](https://starlight.astro.build) +Documentation site for DesterLib, built with [Astro Starlight](https://starlight.astro.build). -``` -pnpm create astro@latest -- --template starlight -``` +## View Documentation -> šŸ§‘ā€šŸš€ **Seasoned astronaut?** Delete this file. Have fun! +🌐 **Live Site:** [https://desterlib.github.io/desterlib](https://desterlib.github.io/desterlib) -## šŸš€ Project Structure +## Documentation -Inside of your Astro + Starlight project, you'll see the following folders and files: +šŸ“– **[Full Documentation](https://desterlib.github.io/desterlib)** -``` -. -ā”œā”€ā”€ public/ -ā”œā”€ā”€ src/ -│ ā”œā”€ā”€ assets/ -│ ā”œā”€ā”€ content/ -│ │ └── docs/ -│ └── content.config.ts -ā”œā”€ā”€ astro.config.mjs -ā”œā”€ā”€ package.json -└── tsconfig.json -``` - -Starlight looks for `.md` or `.mdx` files in the `src/content/docs/` directory. Each file is exposed as a route based on its file name. - -Images can be added to `src/assets/` and embedded in Markdown with a relative link. - -Static assets, like favicons, can be placed in the `public/` directory. - -## šŸ§ž Commands - -All commands are run from the root of the project, from a terminal: - -| Command | Action | -| :------------------------ | :----------------------------------------------- | -| `pnpm install` | Installs dependencies | -| `pnpm dev` | Starts local dev server at `localhost:4321` | -| `pnpm build` | Build your production site to `./dist/` | -| `pnpm preview` | Preview your build locally, before deploying | -| `pnpm astro ...` | Run CLI commands like `astro add`, `astro check` | -| `pnpm astro -- --help` | Get help using the Astro CLI | - -## šŸ‘€ Want to learn more? - -Check out [Starlight’s docs](https://starlight.astro.build/), read [the Astro documentation](https://docs.astro.build), or jump into the [Astro Discord server](https://astro.build/chat). +For contributing to documentation, see the [contributing guide](https://desterlib.github.io/desterlib/development/contributing). diff --git a/apps/docs/astro.config.mjs b/apps/docs/astro.config.mjs index 11b06bf..1f32c38 100644 --- a/apps/docs/astro.config.mjs +++ b/apps/docs/astro.config.mjs @@ -1,67 +1,168 @@ // @ts-check -import { defineConfig } from 'astro/config'; -import starlight from '@astrojs/starlight'; +import { defineConfig } from "astro/config"; +import starlight from "@astrojs/starlight"; +import { readFileSync } from "fs"; +import { fileURLToPath } from "url"; +import { dirname, join } from "path"; + +import tailwindcss from "@tailwindcss/vite"; + +import react from "@astrojs/react"; + +// Read version from root package.json +const __dirname = dirname(fileURLToPath(import.meta.url)); +const rootPackagePath = join(__dirname, "../../package.json"); +const rootPackage = JSON.parse(readFileSync(rootPackagePath, "utf-8")); +const version = rootPackage.version; // https://astro.build/config export default defineConfig({ - site: 'https://docs.dester.in', - base: '/', - integrations: [ - starlight({ - title: 'DesterLib Docs', - description: 'Documentation for DesterLib - Your Personal Media Server', - logo: { - src: './src/assets/logo.png', - alt: 'DesterLib Logo', - }, - social: [ - { icon: 'github', label: 'GitHub', href: 'https://github.com/DesterLib/desterlib' }, - ], - sidebar: [ - { - label: 'Getting Started', - items: [ - { label: 'Introduction', slug: 'index' }, - { label: 'Quick Start', slug: 'getting-started/quick-start' }, - { label: 'Installation', slug: 'getting-started/installation' }, - ], - }, - { - label: 'Projects', - items: [ - { label: 'API Server', slug: 'api/overview' }, - { label: 'Client Applications', slug: 'clients/overview' }, - ], - }, - { - label: 'Client Platforms', - items: [ - { label: 'Platform Setup', slug: 'clients/flutter' }, - ], - }, - { - label: 'Development', - collapsed: false, - items: [ - { label: 'Contributing Guide', slug: 'development/contributing' }, - { label: 'Project Structure', slug: 'development/structure' }, - { label: 'Versioning Guide', slug: 'development/versioning' }, - { label: 'Quick Reference', slug: 'development/quick-reference' }, - { label: 'Commit Guidelines', slug: 'development/commit-guidelines' }, - ], - }, - { - label: 'API Reference', - items: [ - { label: 'Swagger Docs', link: 'http://localhost:3001/api/docs', attrs: { target: '_blank', rel: 'noopener noreferrer' } }, - ], - }, - // TODO: Add Deployment section when deployment guides are ready - // { - // label: 'Deployment', - // autogenerate: { directory: 'deployment' }, - // }, - ], - }), - ], + site: "https://docs.dester.in", + base: "/", + integrations: [ + starlight({ + title: "DesterLib Docs", + description: "Documentation for DesterLib - Your Personal Media Server", + logo: { + src: "./src/assets/logo.svg", + alt: "DesterLib Logo", + }, + social: [ + { + icon: "github", + label: "GitHub", + href: "https://github.com/DesterLib/desterlib", + }, + ], + customCss: ["./src/styles/global.css"], + expressiveCode: { + themes: ["dark-plus"], + }, + defaultLocale: "root", + locales: { + root: { + label: "English", + lang: "en", + }, + }, + components: { + Head: "./src/components/Head.astro", + PageFrame: "./src/components/PageFrame.astro", + Hero: "./src/components/Hero.astro", + }, + sidebar: [ + // GETTING STARTED + { + label: "Getting Started", + items: [ + { label: "Introduction", slug: "index" }, + { label: "Quick Start", slug: "getting-started/quick-start" }, + { label: "Installation", slug: "getting-started/installation" }, + ], + }, + + // PROJECTS & TOOLS + { + label: "Projects", + collapsed: false, + items: [ + { + label: "API Server", + collapsed: true, + items: [ + { label: "Overview", slug: "api/overview" }, + { + label: "Environment Variables", + slug: "api/environment-variables", + }, + { label: "Changelog", slug: "api/changelog" }, + ], + }, + { + label: "Client Apps", + collapsed: true, + items: [ + { label: "Overview", slug: "clients/overview" }, + { label: "Platform Setup", slug: "clients/flutter" }, + ], + }, + { + label: "CLI Tool", + collapsed: true, + items: [ + { label: "Overview", slug: "cli/overview" }, + { label: "Changelog", slug: "cli/changelog" }, + ], + }, + ], + }, + + // GUIDES + { + label: "Guides", + collapsed: true, + items: [ + { label: "TMDB Setup", slug: "guides/tmdb-setup" }, + { label: "Managing Server", slug: "guides/managing-server" }, + { label: "Updating DesterLib", slug: "guides/updating" }, + { label: "Backup & Restore", slug: "guides/backup-restore" }, + { label: "Remote Access", slug: "guides/remote-access" }, + ], + }, + + // DEPLOYMENT + { + label: "Deployment", + collapsed: true, + items: [ + { label: "Docker Production", slug: "deployment/docker" }, + { label: "Security Guide", slug: "deployment/security" }, + ], + }, + + // DEVELOPMENT + { + label: "Development", + collapsed: true, + items: [ + { label: "Contributing Guide", slug: "development/contributing" }, + { label: "Development Workflow", slug: "development/workflow" }, + { label: "Project Structure", slug: "development/structure" }, + { + label: "Commit Guidelines", + slug: "development/commit-guidelines", + }, + { label: "Versioning Guide", slug: "development/versioning" }, + { label: "Quick Reference", slug: "development/quick-reference" }, + { label: "Documentation Changelog", slug: "docs/changelog" }, + ], + }, + + // CHANGELOG + { + label: "Changelog", + items: [{ label: "Overview", slug: "changelog" }], + }, + + // EXTERNAL LINKS + { + label: "API Reference", + items: [ + { + label: "Interactive API Docs (Swagger)", + link: "http://localhost:3001/api/docs", + attrs: { target: "_blank", rel: "noopener noreferrer" }, + }, + ], + }, + ], + }), + react(), + ], + vite: { + plugins: [tailwindcss()], + define: { + "import.meta.env.VITE_APP_VERSION": JSON.stringify(version), + }, + }, }); diff --git a/apps/docs/package.json b/apps/docs/package.json index 1151252..a6f2213 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -6,18 +6,30 @@ "scripts": { "dev": "astro dev", "start": "astro dev", - "build": "astro check && astro build", + "build": "node ../../scripts/sync-changelog.js && astro check && astro build", "preview": "astro preview", "astro": "astro", "check-types": "astro check" }, "dependencies": { + "@astrojs/react": "^4.4.2", "@astrojs/starlight": "^0.36.1", + "@astrojs/starlight-tailwind": "^4.0.2", + "@tailwindcss/vite": "^4.1.17", + "@types/react": "^19.2.4", + "@types/react-dom": "^19.2.3", "astro": "^5.6.1", - "sharp": "^0.34.2" + "motion": "^12.23.24", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "sharp": "^0.34.2", + "tailwindcss": "^4.1.17" }, "devDependencies": { "@astrojs/check": "^0.9.0", - "typescript": "^5.9.2" + "prettier": "^3.6.2", + "prettier-plugin-astro": "0.14.1", + "typescript": "^5.9.2", + "vite": "^6.4.1" } -} \ No newline at end of file +} diff --git a/apps/docs/src/assets/background.png b/apps/docs/src/assets/background.png new file mode 100644 index 0000000..36e5d9d Binary files /dev/null and b/apps/docs/src/assets/background.png differ diff --git a/apps/docs/src/assets/houston.webp b/apps/docs/src/assets/houston.webp deleted file mode 100644 index 930c164..0000000 Binary files a/apps/docs/src/assets/houston.webp and /dev/null differ diff --git a/apps/docs/src/assets/laptop/f-macbook.png b/apps/docs/src/assets/laptop/f-macbook.png new file mode 100644 index 0000000..605609f Binary files /dev/null and b/apps/docs/src/assets/laptop/f-macbook.png differ diff --git a/apps/docs/src/assets/laptop/s-home.png b/apps/docs/src/assets/laptop/s-home.png new file mode 100644 index 0000000..7b36368 Binary files /dev/null and b/apps/docs/src/assets/laptop/s-home.png differ diff --git a/apps/docs/src/assets/logo.png b/apps/docs/src/assets/logo.png deleted file mode 100644 index 985136a..0000000 Binary files a/apps/docs/src/assets/logo.png and /dev/null differ diff --git a/apps/docs/src/assets/logo.svg b/apps/docs/src/assets/logo.svg new file mode 100644 index 0000000..949dc3e --- /dev/null +++ b/apps/docs/src/assets/logo.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/apps/docs/src/assets/mobile.png b/apps/docs/src/assets/mobile.png new file mode 100644 index 0000000..f2b68be Binary files /dev/null and b/apps/docs/src/assets/mobile.png differ diff --git a/apps/docs/src/assets/mobile/f-iphone.png b/apps/docs/src/assets/mobile/f-iphone.png new file mode 100644 index 0000000..2bfe71f Binary files /dev/null and b/apps/docs/src/assets/mobile/f-iphone.png differ diff --git a/apps/docs/src/assets/mobile/s-home.png b/apps/docs/src/assets/mobile/s-home.png new file mode 100644 index 0000000..62e2fab Binary files /dev/null and b/apps/docs/src/assets/mobile/s-home.png differ diff --git a/apps/docs/src/assets/tablet.png b/apps/docs/src/assets/tablet.png new file mode 100644 index 0000000..b57a579 Binary files /dev/null and b/apps/docs/src/assets/tablet.png differ diff --git a/apps/docs/src/assets/tablet/f-ipad.png b/apps/docs/src/assets/tablet/f-ipad.png new file mode 100644 index 0000000..9202f69 Binary files /dev/null and b/apps/docs/src/assets/tablet/f-ipad.png differ diff --git a/apps/docs/src/assets/tablet/s-home.png b/apps/docs/src/assets/tablet/s-home.png new file mode 100644 index 0000000..7809c81 Binary files /dev/null and b/apps/docs/src/assets/tablet/s-home.png differ diff --git a/apps/docs/src/components/FeatureCards.astro b/apps/docs/src/components/FeatureCards.astro new file mode 100644 index 0000000..306af2e --- /dev/null +++ b/apps/docs/src/components/FeatureCards.astro @@ -0,0 +1,138 @@ +--- +interface Feature { + title: string; + subtitle: string; + description: string; + icon?: string; + href?: string; +} + +// Icon mapping function +function getIconSvg(iconName: string): string { + const icons: Record = { + building: ``, + shield: ``, + zap: ``, + code: ``, + device: ``, + server: ``, + terminal: ``, + folder: ``, + rightArrow: ``, + }; + return icons[iconName] || ""; +} + +const features: Feature[] = [ + { + title: "Automatic Organization", + subtitle: "Intelligent media scanning", + description: + "Automatically scans your media library, organizes movies and TV shows, and enriches metadata from TMDB with artwork and details.", + icon: "folder", + href: "/getting-started/quick-start", + }, + { + title: "Easy Setup", + subtitle: "One command to get started", + description: + "Get up and running in minutes with our interactive CLI tool. No manual configuration needed - just run and go.", + icon: "terminal", + href: "/cli/overview", + }, + { + title: "Direct Streaming", + subtitle: "Efficient playback experience", + description: + "Stream your media files directly with HTTP range request support for seamless seeking and playback. No server-side processing needed.", + icon: "zap", + href: "/getting-started/quick-start", + }, + { + title: "Open Source", + subtitle: "Built with transparency", + description: + "Fully open source with active community development. Contribute, customize, and extend DesterLib to fit your needs.", + icon: "code", + href: "https://github.com/DesterLib/desterlib", + }, + { + title: "Multi-Platform", + subtitle: "Works everywhere", + description: + "Access your media library from any device. Native apps for iOS, Android, macOS, Linux, and Windows with seamless synchronization.", + icon: "device", + href: "/clients/overview", + }, + { + title: "Self-Hosted", + subtitle: "Your data, your control", + description: + "Full control over your media library. Host on your own infrastructure, maintain complete privacy, and own your data.", + icon: "server", + href: "/getting-started/installation", + }, +]; +--- + +
+ +
+ { + features.map((feature) => ( +
+

+ {feature.icon && } + + {feature.title} +

+

{feature.subtitle}

+

{feature.description}

+ + Learn more + + + + +
+ )) + } +
+
diff --git a/apps/docs/src/components/Head.astro b/apps/docs/src/components/Head.astro new file mode 100644 index 0000000..a8e7601 --- /dev/null +++ b/apps/docs/src/components/Head.astro @@ -0,0 +1,12 @@ +--- +import StarlightHead from "@astrojs/starlight/components/Head.astro"; +--- + + + diff --git a/apps/docs/src/components/Hero.astro b/apps/docs/src/components/Hero.astro new file mode 100644 index 0000000..cc6b846 --- /dev/null +++ b/apps/docs/src/components/Hero.astro @@ -0,0 +1,98 @@ +--- +const { data } = Astro.locals.starlightRoute.entry; +const { title = data.title, tagline, actions = [] } = data.hero || {}; +--- + +
+
+
+ v{import.meta.env.VITE_APP_VERSION} Alpha is live šŸŽ‰ +
+
+

+ { + tagline && ( +
+ ) + } +
+ { + actions.length > 0 && ( +
+ {actions.map(({ link: href, text, icon, variant }) => { + const isPrimary = variant === "primary" || !variant; + const buttonClass = isPrimary + ? "inline-flex items-center justify-center gap-2 px-6 py-3 md:px-8 md:py-3.5 bg-white text-gray-900 font-medium hover:bg-white/90 text-sm md:text-base" + : "inline-flex items-center justify-center gap-2 px-6 py-3 md:px-8 md:py-3.5 text-white font-medium hover:bg-white/10 text-sm md:text-base"; + return ( + + {text} + {icon?.html && } + + ); + })} +
+ ) + } +

+
+ + diff --git a/apps/docs/src/components/PageFrame.astro b/apps/docs/src/components/PageFrame.astro new file mode 100644 index 0000000..ea3f9c3 --- /dev/null +++ b/apps/docs/src/components/PageFrame.astro @@ -0,0 +1,170 @@ +--- +import MobileMenuToggle from "@astrojs/starlight/components/MobileMenuToggle.astro"; +import backgroundImage from "../assets/background.png"; +import logoSvg from "../assets/logo.svg?raw"; + +const isHomepage = Astro.locals.starlightRoute.id === ""; +const { hasSidebar } = Astro.locals.starlightRoute; +--- + +
+ { + isHomepage ? ( +
+
+ + +
+ ) : ( +
+ +
+ ) + } + { + hasSidebar && ( + + ) + } +
+
+ + diff --git a/apps/docs/src/components/react/Laptop.tsx b/apps/docs/src/components/react/Laptop.tsx new file mode 100644 index 0000000..79cb6ce --- /dev/null +++ b/apps/docs/src/components/react/Laptop.tsx @@ -0,0 +1,69 @@ +import homeScreen from "../../assets/laptop/s-home.png"; +import macbookFrame from "../../assets/laptop/f-macbook.png"; + +const Laptop = () => { + return ( +
+ + + + + + + + + + + + + + + +
+ ); +}; + +export default Laptop; diff --git a/apps/docs/src/components/react/Mobile.tsx b/apps/docs/src/components/react/Mobile.tsx new file mode 100644 index 0000000..3c1ddf5 --- /dev/null +++ b/apps/docs/src/components/react/Mobile.tsx @@ -0,0 +1,67 @@ +import homeScreen from "../../assets/mobile/s-home.png"; +import iphoneFrame from "../../assets/mobile/f-iphone.png"; + +const Mobile = () => { + return ( +
+ + + + + + + + + + + + + + +
+ ); +}; + +export default Mobile; diff --git a/apps/docs/src/components/react/Mockups.tsx b/apps/docs/src/components/react/Mockups.tsx new file mode 100644 index 0000000..2c155c8 --- /dev/null +++ b/apps/docs/src/components/react/Mockups.tsx @@ -0,0 +1,39 @@ +import Mobile from "./Mobile"; +import Laptop from "./Laptop"; +import Tablet from "./Tablet"; +import { motion } from "motion/react"; + +const Mockups = () => { + return ( + +
+ + + + + + + + + +
+
+ ); +}; + +export default Mockups; diff --git a/apps/docs/src/components/react/Tablet.tsx b/apps/docs/src/components/react/Tablet.tsx new file mode 100644 index 0000000..3755eae --- /dev/null +++ b/apps/docs/src/components/react/Tablet.tsx @@ -0,0 +1,69 @@ +import homeScreen from "../../assets/tablet/s-home.png"; +import tabletFrame from "../../assets/tablet/f-ipad.png"; + +const Tablet = () => { + return ( +
+ + + + + + + + + + + + + + + +
+ ); +}; + +export default Tablet; diff --git a/apps/docs/src/content.config.ts b/apps/docs/src/content.config.ts index d9ee8c9..7fbcf2c 100644 --- a/apps/docs/src/content.config.ts +++ b/apps/docs/src/content.config.ts @@ -1,7 +1,7 @@ -import { defineCollection } from 'astro:content'; -import { docsLoader } from '@astrojs/starlight/loaders'; -import { docsSchema } from '@astrojs/starlight/schema'; +import { defineCollection } from "astro:content"; +import { docsLoader } from "@astrojs/starlight/loaders"; +import { docsSchema } from "@astrojs/starlight/schema"; export const collections = { - docs: defineCollection({ loader: docsLoader(), schema: docsSchema() }), + docs: defineCollection({ loader: docsLoader(), schema: docsSchema() }), }; diff --git a/apps/docs/src/content/docs/api/changelog.md b/apps/docs/src/content/docs/api/changelog.md new file mode 100644 index 0000000..88deabf --- /dev/null +++ b/apps/docs/src/content/docs/api/changelog.md @@ -0,0 +1,14 @@ +--- +title: API Server Changelog +description: Changelog for the DesterLib API Server +--- + +All notable changes to API Server will be documented here. + +This changelog is automatically generated from [Changesets](https://github.com/changesets/changesets). + +See the [API Server Changelog on GitHub](https://github.com/DesterLib/desterlib/blob/main/apps/api/CHANGELOG.md) for the source file. + +--- + +_No changelog entries yet. Changelog will appear here once versions are bumped._ diff --git a/apps/docs/src/content/docs/api/environment-variables.md b/apps/docs/src/content/docs/api/environment-variables.md new file mode 100644 index 0000000..7134c0c --- /dev/null +++ b/apps/docs/src/content/docs/api/environment-variables.md @@ -0,0 +1,332 @@ +--- +title: Environment Variables +description: Environment variables used by the DesterLib API server +--- + +Complete reference for configuring the DesterLib API server via environment variables. + +## Required Variables + +### DATABASE_URL + +**PostgreSQL connection string** + +```env +DATABASE_URL=postgresql://username:password@host:port/database?schema=public +``` + +**Format:** `postgresql://USER:PASSWORD@HOST:PORT/DATABASE?schema=public` + +**Examples:** + +```env +# Docker Compose (default) +DATABASE_URL=postgresql://desterlib:password@postgres:5432/desterlib?schema=public + +# External database +DATABASE_URL=postgresql://user:pass@db.example.com:5432/desterlib?schema=public + +# Local development +DATABASE_URL=postgresql://postgres:postgres@localhost:5433/desterlib_test?schema=public +``` + +**Used by:** Prisma ORM for all database operations + +## Optional Variables + +### NODE_ENV + +**Application environment mode** + +```env +NODE_ENV=production +``` + +**Values:** + +- `production` - Production mode (default) +- `development` - Development mode with debug logging + +**Default:** `development` + +**Effects:** + +- Logging verbosity +- Error message details +- CORS policy (more permissive in dev) +- Database query logging + +### PORT + +**API server port** + +```env +PORT=3001 +``` + +**Valid range:** 1024-65535 +**Default:** `3001` (from database settings) + +**Used by:** Express HTTP server + +### RATE_LIMIT_WINDOW_MS + +**Rate limiting time window in milliseconds** + +```env +RATE_LIMIT_WINDOW_MS=900000 +``` + +**Default:** `900000` (15 minutes) + +**Purpose:** Prevents API abuse by limiting request frequency + +**Note:** Localhost, scan routes, and stream routes are exempt from rate limiting. + +### RATE_LIMIT_MAX + +**Maximum requests per window** + +```env +RATE_LIMIT_MAX=100 +``` + +**Default:** `100` requests + +**Purpose:** Maximum number of requests allowed per time window (see above) + +**Calculation:** With defaults, clients can make 100 requests per 15 minutes. + +## Docker-Specific Variables + +These are used by Docker Compose to configure the PostgreSQL container, **not** by the API directly: + +### POSTGRES_USER + +**Database username** + +```yaml +# In docker-compose.yml environment section +POSTGRES_USER: desterlib +``` + +**Used by:** PostgreSQL container initialization + +### POSTGRES_PASSWORD + +**Database password** + +```yaml +POSTGRES_PASSWORD: your_secure_password +``` + +**Used by:** PostgreSQL container initialization + +### POSTGRES_DB + +**Database name** + +```yaml +POSTGRES_DB: desterlib +``` + +**Used by:** PostgreSQL container initialization + +## Variables NOT Used + +These variables are **NOT** read by the DesterLib API: + +### āŒ FRONTEND_URL + +Not used. CORS is configured automatically for local network access. + +### āŒ JWT_SECRET + +Stored in database settings, not environment variables. + +### āŒ TMDB_API_KEY + +Configured via Settings API in the application, not environment variables. + +### āŒ POSTGRES_HOST / POSTGRES_PORT + +The API only uses `DATABASE_URL`. Individual postgres connection vars are not read. + +## Configuration Methods + +### Method 1: .env File (Recommended) + +Create `.env` in the API directory: + +**CLI installation:** + +```bash +nano ~/.desterlib/.env +``` + +**Git installation:** + +```bash +nano apps/api/.env +``` + +**Example .env:** + +```env +DATABASE_URL=postgresql://desterlib:password@postgres:5432/desterlib?schema=public +NODE_ENV=production +PORT=3001 +RATE_LIMIT_WINDOW_MS=900000 +RATE_LIMIT_MAX=100 +``` + +### Method 2: Docker Compose Environment + +Set directly in `docker-compose.yml`: + +```yaml +api: + image: desterlib/api:latest + environment: + DATABASE_URL: postgresql://... + NODE_ENV: production + PORT: 3001 + RATE_LIMIT_WINDOW_MS: 900000 + RATE_LIMIT_MAX: 100 +``` + +### Method 3: System Environment + +Export before running: + +```bash +export DATABASE_URL="postgresql://..." +export PORT=3001 +pnpm start +``` + +## Examples + +### Development Setup + +```env +DATABASE_URL=postgresql://postgres:postgres@localhost:5433/desterlib_test?schema=public +NODE_ENV=development +PORT=3001 +RATE_LIMIT_WINDOW_MS=60000 # 1 minute for testing +RATE_LIMIT_MAX=1000 # More lenient for development +``` + +### Production Setup + +```env +DATABASE_URL=postgresql://desterlib:STRONG_PASSWORD@postgres:5432/desterlib?schema=public +NODE_ENV=production +PORT=3001 +RATE_LIMIT_WINDOW_MS=900000 # 15 minutes +RATE_LIMIT_MAX=100 # Standard rate limiting +``` + +### High-Traffic Setup + +```env +DATABASE_URL=postgresql://... +NODE_ENV=production +PORT=3001 +RATE_LIMIT_WINDOW_MS=600000 # 10 minutes (shorter window) +RATE_LIMIT_MAX=200 # Allow more requests +``` + +## Validation + +### Check Current Configuration + +Start the server and check logs: + +```bash +docker-compose up -d +docker-compose logs api | head -20 +``` + +You should see: + +``` +šŸš€ Server running on port 3001 +šŸ”§ Environment: production +šŸ—„ļø Database: postgresql://desterlib:***@postgres:5432/desterlib +``` + +### Test Database Connection + +```bash +# Check if API can reach database +curl http://localhost:3001/health +``` + +Should return `{"status":"OK",...}` + +## Troubleshooting + +### Database Connection Failed + +**Error:** `Error: P1001: Can't reach database server` + +**Fixes:** + +1. Check `DATABASE_URL` format +2. Verify database is running: `docker ps | grep postgres` +3. Test connection: + ```bash + docker exec -it desterlib-postgres psql -U desterlib -d desterlib + ``` + +### Port Already in Use + +**Error:** `EADDRINUSE: address already in use :::3001` + +**Fixes:** + +1. Change `PORT` to different number (e.g., `3002`) +2. Or kill process using port 3001: + ```bash + lsof -ti:3001 | xargs kill -9 + ``` + +### Rate Limiting Too Aggressive + +If clients get rate limited often: + +```env +# Increase limits +RATE_LIMIT_WINDOW_MS=1800000 # 30 minutes +RATE_LIMIT_MAX=200 # 200 requests +``` + +Then restart: `docker-compose restart api` + +## Security Recommendations + +### Production Checklist + +- āœ… Use strong, random database passwords +- āœ… Don't commit `.env` to git (it's in `.gitignore`) +- āœ… Use `NODE_ENV=production` in production +- āœ… Keep rate limits reasonable +- āœ… Monitor logs for suspicious activity + +### Database Security + +**Generate secure password:** + +```bash +openssl rand -base64 32 +``` + +Use this for `POSTGRES_PASSWORD` in your database connection. + +## Related Documentation + +- [Installation Guide](/getting-started/installation/) - Initial setup +- [API Overview](/api/overview/) - API server documentation +- [Managing Server](/guides/managing-server/) - Server management +- [CLI Tool](/cli/overview/) - CLI documentation diff --git a/apps/docs/src/content/docs/api/overview.md b/apps/docs/src/content/docs/api/overview.md index ef45311..bc5a038 100644 --- a/apps/docs/src/content/docs/api/overview.md +++ b/apps/docs/src/content/docs/api/overview.md @@ -8,10 +8,11 @@ The DesterLib API Server is the backend that powers your personal media library. ## What is the API Server? The API Server handles: + - **Media Library Management** - Scan, organize, and index your media files - **Metadata Fetching** - Automatic metadata and artwork from TMDB -- **Video Streaming** - Adaptive streaming endpoints -- **Watch Progress** - Track viewing history and resume points +- **Video Streaming** - Direct video streaming endpoints +- **Search** - Search across movies and TV shows - **WebSocket Events** - Real-time updates for scans and library changes **Repository**: [desterlib](https://github.com/DesterLib/desterlib) (monorepo) @@ -23,6 +24,7 @@ The API is fully documented using **Swagger/OpenAPI**. When the API server is ru šŸ”— **[http://localhost:3001/api/docs](http://localhost:3001/api/docs)** The interactive documentation allows you to: + - šŸ“– Browse all available endpoints - 🧪 Test API requests directly from your browser - šŸ“‹ View request/response schemas @@ -54,85 +56,152 @@ pnpm start ## API Base URL -**Development:** `http://localhost:3001` -**Production:** Configure via `FRONTEND_URL` environment variable +All endpoints: `http://localhost:3001` ## Authentication -The API uses JWT (JSON Web Tokens) for authentication. See the Swagger docs for authentication endpoints and token management. +**Current Status:** Authentication is not yet implemented. The API is currently open for local network access. + +:::note[Planned Feature] +JWT authentication is planned and can be enabled via the `enableRouteGuards` setting. Currently disabled by default. +::: ## Rate Limiting API requests are rate-limited to prevent abuse. Default limits: + - **Window:** 15 minutes - **Max requests:** 100 requests per window Configure via environment variables: + - `RATE_LIMIT_WINDOW_MS` - `RATE_LIMIT_MAX` -## API Domains +## API Endpoints The API is organized into the following domains: -### šŸŽ¬ Movies -Manage your movie collection: -- List movies with filtering and sorting -- Get movie details -- Update metadata -- Delete movies -- Search movies - -### šŸ“ŗ TV Shows -Manage TV shows, seasons, and episodes: -- List TV shows -- Get show, season, and episode details -- Track episode progress -- Manage metadata - -### šŸ“š Library -Overall library management: -- Get library statistics -- Manage media libraries -- Configure library settings +### šŸ” `/api/v1/search` + +Search across your media library: + +- Search movies and TV shows by title +- Case-insensitive search +- Returns enriched metadata with mesh gradient colors + +### šŸ”¢ `/api/v1/scan` -### šŸ” Scan Media scanning and indexing: -- Trigger media scans -- Check scan status -- View scan history -- Configure scan settings - -### šŸŽžļø Stream -Video streaming endpoints: -- Stream video content -- Get video information -- Manage streaming sessions - -### āš™ļø Settings + +- Trigger media scans (movies or TV shows) +- Resume interrupted scans +- Check scan job status +- Cleanup stale jobs +- Real-time progress via WebSocket + +### šŸ“š `/api/v1/library` + +Library management: + +- Get library statistics +- List all libraries +- Create and delete libraries +- Get library details + +### šŸŽ¬ `/api/v1/movies` + +Movie management: + +- List all movies (10 most recent) +- Get movie details by ID +- Includes poster, backdrop, metadata +- Returns stream URL + +### šŸ“ŗ `/api/v1/tvshows` + +TV show management: + +- List all TV shows (10 most recent) +- Get show details by ID +- Season and episode information +- Enriched metadata + +### šŸŽžļø `/api/v1/stream` + +Video streaming: + +- Stream media files directly +- Supports range requests for seeking +- Optimized for playback + +### āš™ļø `/api/v1/settings` + Application settings: + - Get and update settings -- Configure TMDB integration +- Configure TMDB API key - Manage system preferences +- Enable/disable features + +### šŸ“‹ `/api/v1/logs` + +Server logs access: + +- View application logs +- Monitor system activity ## WebSocket API -DesterLib also provides WebSocket support for real-time updates: +Real-time updates for scan progress and library changes: + +**Connection:** `ws://localhost:3001/ws` + +**Events:** + +- `scan:progress` - Scan progress updates with phases and percentages +- `scan:complete` - Scan job completed +- `scan:error` - Scan job failed + +**Example:** ```javascript -const ws = new WebSocket('ws://localhost:3001/ws'); +const ws = new WebSocket("ws://localhost:3001/ws"); ws.onmessage = (event) => { const data = JSON.parse(event.data); - console.log('Received:', data); + if (data.type === "scan:progress") { + console.log(`${data.phase}: ${data.progress}%`); + } }; ``` -See the Swagger documentation for WebSocket event types and payloads. +## Special Features + +### Mesh Gradient Colors + +All media items include `meshGradientColors` - an array of 4 hex colors extracted from poster images for beautiful UI backgrounds: + +```json +{ + "media": { + "title": "The Matrix", + "meshGradientColors": ["#7C3AED", "#2563EB", "#EC4899", "#8B5CF6"] + } +} +``` + +These colors are generated on-demand when fetching media and cached for performance. ## CORS Configuration -The API supports CORS for cross-origin requests. Configure allowed origins via the `FRONTEND_URL` environment variable. +The API automatically allows: + +- **Localhost** - All localhost origins in development +- **Local Network** - LAN IPs (192.168.x.x, 10.x.x.x, 172.16-31.x.x) +- **Mobile Apps** - Requests with no origin header + +No configuration needed for local network access! ## API Versioning @@ -144,25 +213,50 @@ Example: `http://localhost:3001/api/v1/movies` ## Example Requests -### Get All Movies +### Search Media + +```bash +curl "http://localhost:3001/api/v1/search?query=matrix" +``` + +### List Movies ```bash curl http://localhost:3001/api/v1/movies ``` -### Get Movie by ID +### Get Movie Details ```bash curl http://localhost:3001/api/v1/movies/{movieId} ``` -### Get TV Shows +### Trigger Scan ```bash -curl http://localhost:3001/api/v1/tvshows +curl -X POST http://localhost:3001/api/v1/scan/path \ + -H "Content-Type: application/json" \ + -d '{"path": "/media/movies", "mediaType": "movie"}' ``` -For complete endpoint documentation with all parameters, request/response schemas, and interactive testing, visit the **[Swagger Documentation](http://localhost:3001/api/docs)** when your API server is running. +### Get Libraries + +```bash +curl http://localhost:3001/api/v1/library +``` + +## Complete API Reference + +For full endpoint documentation with all parameters, request/response schemas, and interactive testing: + +**[Swagger Documentation →](http://localhost:3001/api/docs)** + +The Swagger UI includes: + +- Complete API schema +- Request/response examples +- Try it out functionality +- Authentication details (when implemented) ## Client Libraries @@ -177,4 +271,3 @@ Currently, the DesterLib Flutter app uses the API directly. If you're building y - šŸ› [Report API Issues](https://github.com/DesterLib/desterlib/issues) - šŸ’¬ [Ask Questions](https://github.com/DesterLib/desterlib/discussions) - šŸ“– [View API Source](https://github.com/DesterLib/desterlib/tree/main/apps/api/src) - diff --git a/apps/docs/src/content/docs/changelog.md b/apps/docs/src/content/docs/changelog.md new file mode 100644 index 0000000..aaa70f3 --- /dev/null +++ b/apps/docs/src/content/docs/changelog.md @@ -0,0 +1,39 @@ +--- +title: Changelog +description: All notable changes to DesterLib +--- + +All notable changes to DesterLib are documented here. This project uses [Changesets](https://github.com/changesets/changesets) for version management. + +Package-specific changelogs are automatically generated in each package directory when versions are bumped and synced to the documentation site. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## Package Changelogs + +DesterLib consists of multiple packages, each with its own changelog: + +### [API Server Changelog](/api/changelog) + +Changelog for the DesterLib API Server. Contains all API changes, new features, bug fixes, and breaking changes. + +### [CLI Tool Changelog](/cli/changelog) + +Changelog for the DesterLib CLI Tool. Contains all CLI changes, new commands, and improvements. + +### [Documentation Changelog](/docs/changelog) + +Changelog for the DesterLib Documentation. Contains all documentation updates and improvements. + +## Viewing Changelogs + +- **On this site**: Use the links above to view each package's changelog +- **On GitHub**: View the source changelog files: + - [API Changelog](https://github.com/DesterLib/desterlib/blob/main/apps/api/CHANGELOG.md) + - [CLI Changelog](https://github.com/DesterLib/desterlib/blob/main/packages/cli/CHANGELOG.md) + - [Documentation Changelog](https://github.com/DesterLib/desterlib/blob/main/apps/docs/CHANGELOG.md) + +## Contributing + +When making changes, create a changeset using `pnpm changeset`. Changesets will automatically generate changelog entries when versions are bumped. diff --git a/apps/docs/src/content/docs/cli/changelog.md b/apps/docs/src/content/docs/cli/changelog.md new file mode 100644 index 0000000..ecf6158 --- /dev/null +++ b/apps/docs/src/content/docs/cli/changelog.md @@ -0,0 +1,14 @@ +--- +title: CLI Tool Changelog +description: Changelog for the DesterLib CLI Tool +--- + +All notable changes to CLI Tool will be documented here. + +This changelog is automatically generated from [Changesets](https://github.com/changesets/changesets). + +See the [CLI Tool Changelog on GitHub](https://github.com/DesterLib/desterlib/blob/main/packages/cli/CHANGELOG.md) for the source file. + +--- + +_No changelog entries yet. Changelog will appear here once versions are bumped._ diff --git a/apps/docs/src/content/docs/cli/overview.md b/apps/docs/src/content/docs/cli/overview.md new file mode 100644 index 0000000..e703b33 --- /dev/null +++ b/apps/docs/src/content/docs/cli/overview.md @@ -0,0 +1,261 @@ +--- +title: CLI Tool +description: DesterLib CLI - One-command installer for DesterLib +--- + +The DesterLib CLI is an interactive setup tool that installs and configures DesterLib with a single command. + +## Overview + +**Repository:** [@desterlib/cli](https://github.com/DesterLib/desterlib/tree/main/packages/cli) +**Package:** `@desterlib/cli` on npm + +### What It Does + +The CLI handles the entire setup process: + +- āœ… Checks Docker prerequisites +- āœ… Generates configuration files +- āœ… Pulls pre-built Docker images +- āœ… Starts your media server +- āœ… No Git or source code needed + +### Installation + +No installation required! Run directly with npx: + +```bash +npx @desterlib/cli +``` + +Or install globally: + +```bash +npm install -g @desterlib/cli +desterlib +``` + +## Quick Usage + +```bash +# Run the setup wizard +npx @desterlib/cli +``` + +The wizard will ask: + +1. šŸ“š **Media library path** - Where your movies/TV shows are +2. šŸ”Œ **Server port** - Default is 3001 +3. šŸ”’ **Database credentials** - Username and password + +That's it! Your server will be installed in `~/.desterlib/` + +## Commands + +### `desterlib` or `desterlib setup` + +Runs the interactive setup wizard. + +**Options:** + +- `--skip-docker-check` - Skip Docker installation verification (not recommended) + +**Example:** + +```bash +npx @desterlib/cli setup --skip-docker-check +``` + +## Configuration + +### What Gets Created + +The CLI creates these files in `~/.desterlib/`: + +**1. docker-compose.yml** (~50 lines) + +```yaml +services: + postgres: + image: postgres:15-alpine + # Database configuration + + api: + image: desterlib/api:latest + # API server configuration +``` + +**2. .env** (~10 lines) + +```env +DATABASE_URL=postgresql://... +NODE_ENV=production +PORT=3001 +RATE_LIMIT_WINDOW_MS=900000 +RATE_LIMIT_MAX=100 +``` + +**3. README.md** +Quick reference with management commands. + +### Installation Directory + +| OS | Location | +| ----------- | -------------------------- | +| macOS/Linux | `~/.desterlib` | +| Windows | `%USERPROFILE%\.desterlib` | + +## Managing Your Installation + +After setup, manage DesterLib from the installation directory: + +```bash +cd ~/.desterlib + +# View status +docker-compose ps + +# View logs +docker-compose logs -f + +# Restart +docker-compose restart + +# Stop +docker-compose down + +# Update to latest +docker-compose pull +docker-compose up -d +``` + +## Reconfiguring + +To update your configuration, run the CLI again: + +```bash +npx @desterlib/cli +``` + +You'll be prompted to: + +- **Reconfigure** - Update settings (overwrites config files) +- **Remove and start fresh** - Delete everything and reinstall +- **Cancel** - Keep current setup + +## Existing Installation + +If DesterLib is already installed, the CLI will: + +1. Detect the existing installation +2. Offer to reconfigure or reinstall +3. Preserve your database unless you choose full reinstall + +## Requirements + +- **Docker** and **Docker Compose** +- **Node.js** 18+ (only for running the CLI via npx) + +No Git or source code knowledge required! + +## Troubleshooting + +### Docker Not Found + +``` +āŒ Docker is not installed or not running. +``` + +**Solution:** + +- Install [Docker Desktop](https://www.docker.com/products/docker-desktop) +- Make sure Docker is running (check system tray/menu bar) + +### Docker Compose Not Available + +``` +āŒ Docker Compose is not available +``` + +**Solution:** + +- Docker Desktop includes Compose automatically +- Linux: `sudo apt install docker-compose-plugin` + +### Port Already in Use + +If port 3001 is taken: + +1. During setup, choose a different port when prompted +2. Or kill the process using 3001: + ```bash + lsof -ti:3001 | xargs kill -9 # Mac/Linux + ``` + +### Permission Errors + +If you can't access your media path: + +- Ensure the directory exists +- Check permissions: `ls -la /path/to/media` +- Choose a different directory during setup + +## Advanced Usage + +### Skip Docker Check + +Not recommended, but you can skip the Docker check: + +```bash +npx @desterlib/cli setup --skip-docker-check +``` + +### Custom Installation Directory + +Currently, the CLI installs to `~/.desterlib`. To use a custom location, you'll need to manually set up using the [Manual Installation Guide](/getting-started/installation/#option-2-manual-setup-for-developers). + +## Uninstalling + +To completely remove DesterLib: + +```bash +# Stop and remove containers +cd ~/.desterlib +docker-compose down -v # -v removes database data + +# Remove installation directory +cd ~ +rm -rf .desterlib +``` + +:::caution +The `-v` flag removes Docker volumes, which deletes your database. Omit it if you want to keep your data. +::: + +## For Developers + +The CLI source code is in the monorepo: + +```bash +# Clone the monorepo +git clone https://github.com/DesterLib/desterlib.git +cd desterlib/packages/cli + +# Install dependencies +pnpm install + +# Run in development mode +pnpm dev + +# Build +pnpm build +``` + +See the [Contributing Guide](/development/contributing/) for more details. + +## Related Documentation + +- [Quick Start](/getting-started/quick-start/) - Get running in 5 minutes +- [Installation Guide](/getting-started/installation/) - Complete setup guide +- [Managing Your Server](/guides/managing-server/) - Server management commands +- [Updating DesterLib](/guides/updating/) - How to update to latest version diff --git a/apps/docs/src/content/docs/clients/flutter.md b/apps/docs/src/content/docs/clients/flutter.md index c386a84..2461a1d 100644 --- a/apps/docs/src/content/docs/clients/flutter.md +++ b/apps/docs/src/content/docs/clients/flutter.md @@ -8,6 +8,7 @@ The Dester client is a cross-platform application for browsing and streaming you ## šŸ“± Supported Platforms ### Available Now + - **Android** - Phones and tablets (SDK 21+) - **iOS** - iPhone and iPad (iOS 12.0+) - **macOS** - Desktop application (10.14+) @@ -15,6 +16,7 @@ The Dester client is a cross-platform application for browsing and streaming you - **Windows** - Desktop application (Windows 10+) ### In Development + - **Android TV** - TV interface with remote control support - **Apple TV / tvOS** - Native TV experience @@ -242,6 +244,7 @@ flutter analyze --fatal-infos ### Connection Issues **Can't connect to server:** + - Check server URL is correct - Ensure API is running (`docker-compose up`) - Check firewall settings @@ -250,12 +253,14 @@ flutter analyze --fatal-infos ### Video Playback Issues **Videos won't play:** + - Check internet connection - Verify video codec support - Try different video file - Check server streaming configuration **Buffering issues:** + - Check network speed - Adjust video quality in settings - Check server performance @@ -263,6 +268,7 @@ flutter analyze --fatal-infos ### Build Issues **Flutter build fails:** + ```bash # Clean and rebuild flutter clean @@ -272,6 +278,7 @@ flutter run ``` **Platform-specific build issues:** + - Android: Check SDK version, update Gradle - iOS: Update Xcode, check provisioning profiles - Desktop: Check platform dependencies installed @@ -289,4 +296,3 @@ flutter run - [GitHub Issues](https://github.com/DesterLib/desterlib-flutter/issues) - [GitHub Discussions](https://github.com/DesterLib/desterlib-flutter/discussions) - [Main DesterLib Discussions](https://github.com/DesterLib/desterlib/discussions) - diff --git a/apps/docs/src/content/docs/clients/overview.md b/apps/docs/src/content/docs/clients/overview.md index a05b194..1d125c0 100644 --- a/apps/docs/src/content/docs/clients/overview.md +++ b/apps/docs/src/content/docs/clients/overview.md @@ -8,6 +8,7 @@ The DesterLib client is a cross-platform application that connects to your Deste ## What is the Client? The client provides: + - **Media Browsing** - Explore your movies and TV shows - **Video Streaming** - Watch your content with smooth playback - **Watch Progress** - Automatic progress tracking across devices @@ -19,15 +20,18 @@ The client provides: ## Supported Platforms ### šŸ“± Mobile + - **Android** - Phones and tablets - **iOS** - iPhone and iPad ### šŸ’» Desktop + - **macOS** - Native desktop application - **Linux** - Native desktop application - **Windows** - Native desktop application ### šŸ“ŗ TV (In Development) + - **Android TV** - TV interface with remote control - **Apple TV / tvOS** - Native TV experience @@ -54,9 +58,9 @@ The client communicates with the DesterLib API using REST API and WebSocket conn ### Version Compatibility -| Platform | Min API Version | Recommended API Version | -|----------|----------------|------------------------| -| All Platforms | 0.1.0+ | Latest | +| Platform | Min API Version | Recommended API Version | +| ------------- | --------------- | ----------------------- | +| All Platforms | 0.1.0+ | Latest | :::caution Keep your client updated to match your API version for the best experience and latest features. @@ -70,15 +74,17 @@ See the [contributing guide](https://github.com/DesterLib/desterlib-flutter/blob :::tip The client follows the same [DesterLib contribution guidelines](/development/contributing) as all other projects: + - Commit conventions - Version management - Code review process - Documentation standards -::: + ::: ### Platform-Specific Development Platform-specific setup and requirements: + - **Android**: Android Studio, Android SDK 21+ - **iOS**: Xcode 14+, iOS 12.0+ - **macOS**: Xcode, macOS 10.14+ @@ -93,19 +99,19 @@ Check out the [platform-specific setup guide](/clients/flutter) for detailed bui ## Platform Feature Status -| Feature | Mobile | Desktop | TV | -|---------|--------|---------|-----| -| Browse Library | āœ… | āœ… | šŸ”œ | -| Stream Videos | āœ… | āœ… | šŸ”œ | -| Search | āœ… | āœ… | šŸ”œ | -| Watch Progress | āœ… | āœ… | šŸ”œ | -| Offline Downloads | šŸ”œ | šŸ”œ | āŒ | -| Chromecast | šŸ”œ | šŸ”œ | N/A | -| Picture-in-Picture | šŸ”œ | šŸ”œ | N/A | -| System Integration | āœ… | āœ… | āœ… | -| Remote Control | Touch | KB/Mouse | šŸ”œ | +| Feature | Mobile | Desktop | TV | +| ------------------ | ------ | -------- | --- | +| Browse Library | āœ… | āœ… | šŸ”œ | +| Stream Videos | āœ… | āœ… | šŸ”œ | +| Search | āœ… | āœ… | šŸ”œ | +| Watch Progress | āœ… | āœ… | šŸ”œ | +| Offline Downloads | šŸ”œ | šŸ”œ | āŒ | +| Chromecast | šŸ”œ | šŸ”œ | N/A | +| Picture-in-Picture | šŸ”œ | šŸ”œ | N/A | +| System Integration | āœ… | āœ… | āœ… | +| Remote Control | Touch | KB/Mouse | šŸ”œ | -Legend: āœ… Available | šŸ”œ Planned | āŒ Not Available +**Legend:** āœ… Available | šŸ”œ Planned | āŒ Not Available ## Requesting Features @@ -123,4 +129,3 @@ Need help? - **General questions**: [GitHub Discussions](https://github.com/DesterLib/desterlib/discussions) - **Bug reports**: [Client Issues](https://github.com/DesterLib/desterlib-flutter/issues) - **Feature requests**: [Ideas Discussion](https://github.com/DesterLib/desterlib/discussions/categories/ideas) - diff --git a/apps/docs/src/content/docs/deployment/docker.md b/apps/docs/src/content/docs/deployment/docker.md new file mode 100644 index 0000000..78ef7bd --- /dev/null +++ b/apps/docs/src/content/docs/deployment/docker.md @@ -0,0 +1,415 @@ +--- +title: Docker Deployment +description: Deploy DesterLib with Docker in production +--- + +Production-ready Docker deployment guide for DesterLib. + +## Production Setup + +### Using CLI (Easiest) + +```bash +npx @desterlib/cli +``` + +The CLI configures production-ready settings by default: + +- Sets `NODE_ENV=production` +- Configures restart policies +- Sets up health checks +- Uses production database + +### Manual Setup + +```bash +git clone https://github.com/DesterLib/desterlib.git +cd desterlib + +# Create .env in apps/api/ +DATABASE_URL=postgresql://desterlib:STRONG_PASSWORD@postgres:5432/desterlib?schema=public +NODE_ENV=production +PORT=3001 + +# Start in production mode +docker-compose up -d +``` + +## Production Best Practices + +### Resource Limits + +Add resource limits to prevent overconsumption: + +**Edit docker-compose.yml:** + +```yaml +api: + image: desterlib/api:latest + deploy: + resources: + limits: + cpus: "2.0" + memory: 2G + reservations: + memory: 512M + +postgres: + image: postgres:15-alpine + deploy: + resources: + limits: + memory: 1G + reservations: + memory: 256M +``` + +### Persistent Volumes + +Ensure data persists across container restarts: + +```yaml +volumes: + postgres_data: + driver: local + driver_opts: + type: none + device: /path/to/persistent/storage + o: bind +``` + +### Health Checks + +Both services include health checks by default: + +```yaml +api: + healthcheck: + test: + [ + "CMD-SHELL", + "wget --no-verbose --tries=1 --spider http://localhost:3001/health || exit 1", + ] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + +postgres: + healthcheck: + test: ["CMD-SHELL", "pg_isready -U desterlib"] + interval: 10s + timeout: 5s + retries: 5 +``` + +## Networking + +### Expose to Network + +The default configuration binds to `0.0.0.0`: + +```yaml +ports: + - "0.0.0.0:3001:3001" +``` + +This allows connections from: + +- Local machine +- LAN devices +- Remote clients (if port forwarded) + +### Internal Network Only + +For localhost-only access: + +```yaml +ports: + - "127.0.0.1:3001:3001" +``` + +## Reverse Proxy + +### Nginx + +**nginx.conf:** + +```nginx +server { + listen 80; + server_name desterlib.yourdomain.com; + + location / { + proxy_pass http://localhost:3001; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_cache_bypass $http_upgrade; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } + + # WebSocket support + location /ws { + proxy_pass http://localhost:3001; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "Upgrade"; + } +} +``` + +### Caddy (Automatic HTTPS) + +**Caddyfile:** + +``` +desterlib.yourdomain.com { + reverse_proxy localhost:3001 +} +``` + +Caddy automatically handles: + +- HTTPS certificates (Let's Encrypt) +- Certificate renewal +- HTTP to HTTPS redirect + +### Traefik + +**docker-compose.yml:** + +```yaml +api: + image: desterlib/api:latest + labels: + - "traefik.enable=true" + - "traefik.http.routers.desterlib.rule=Host(`desterlib.yourdomain.com`)" + - "traefik.http.routers.desterlib.entrypoints=websecure" + - "traefik.http.routers.desterlib.tls.certresolver=myresolver" +``` + +## Logging + +### Configure Log Levels + +Set in environment: + +```env +NODE_ENV=production # Less verbose +# or +NODE_ENV=development # More verbose (debug logs) +``` + +### Log Drivers + +**Use JSON log format:** + +```yaml +api: + image: desterlib/api:latest + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" +``` + +### External Logging + +**To syslog:** + +```yaml +logging: + driver: syslog + options: + syslog-address: "tcp://192.168.1.100:514" +``` + +**To file:** + +```yaml +logging: + driver: json-file + options: + max-size: "100m" + max-file: "5" + compress: "true" +``` + +## Monitoring + +### Docker Health Checks + +View health status: + +```bash +docker ps +``` + +Look for "healthy" in the STATUS column. + +### Prometheus Metrics (Future) + +Prometheus integration is planned for future releases. + +## Security + +### Network Isolation + +Create isolated Docker network: + +```yaml +networks: + desterlib-net: + driver: bridge + internal: false + +services: + postgres: + networks: + - desterlib-net + + api: + networks: + - desterlib-net +``` + +### Read-Only Media Mount + +Media is mounted read-only for security: + +```yaml +volumes: + - /path/to/media:/media:ro # :ro = read-only +``` + +### Non-Root User (Future) + +Running as non-root user is planned for future releases. + +## Scaling + +### Single Server + +Current setup is designed for single-server deployment: + +- One API instance +- One PostgreSQL instance +- Suitable for personal use (1-100 users) + +### Future: Multi-Instance + +Horizontal scaling is planned for future releases with: + +- Multiple API instances behind load balancer +- Shared PostgreSQL database +- Redis for session management + +## Backup in Production + +### Automated Database Backups + +**Create backup service:** + +```yaml +# Add to docker-compose.yml +backup: + image: postgres:15-alpine + depends_on: + - postgres + volumes: + - ./backups:/backups + - postgres_data:/var/lib/postgresql/data:ro + command: > + sh -c "while true; do + pg_dump -U desterlib -h postgres desterlib > /backups/backup-$$(date +%Y%m%d-%H%M%S).sql + sleep 86400 + done" + restart: unless-stopped +``` + +This creates daily backups in `./backups/`. + +## Updating in Production + +### Zero-Downtime Updates (Future) + +Currently, updates require brief downtime: + +```bash +cd ~/.desterlib +docker-compose pull +docker-compose up -d +``` + +Downtime: ~10-30 seconds during container restart. + +### Scheduled Maintenance + +Recommend updating during low-usage times: + +```bash +# Schedule with cron at 3 AM +0 3 * * 0 cd ~/.desterlib && docker-compose pull && docker-compose up -d +``` + +## Troubleshooting + +### Container Keeps Restarting + +**Check logs:** + +```bash +docker-compose logs --tail=100 api +``` + +**Common causes:** + +- Database connection failure +- Invalid environment variables +- Port already in use +- Missing required config + +### High Memory Usage + +**Check resource usage:** + +```bash +docker stats +``` + +**If too high:** + +1. Add memory limits (see Resource Limits above) +2. Check for memory leaks in logs +3. Restart containers: `docker-compose restart` + +### Database Performance + +**For large libraries (10,000+ items):** + +1. **Increase database resources:** + + ```yaml + postgres: + deploy: + resources: + limits: + memory: 2G + ``` + +2. **Optimize PostgreSQL:** + ```yaml + postgres: + command: postgres -c shared_buffers=256MB -c max_connections=200 + ``` + +## Related Documentation + +- [Installation Guide](/getting-started/installation/) - Initial setup +- [Managing Server](/guides/managing-server/) - Day-to-day management +- [Remote Access](/guides/remote-access/) - External access options +- [Backup & Restore](/guides/backup-restore/) - Data protection diff --git a/apps/docs/src/content/docs/deployment/security.md b/apps/docs/src/content/docs/deployment/security.md new file mode 100644 index 0000000..a17c049 --- /dev/null +++ b/apps/docs/src/content/docs/deployment/security.md @@ -0,0 +1,349 @@ +--- +title: Security Guide +description: Best practices for securing your DesterLib installation +--- + +Security considerations for self-hosting DesterLib. + +## Current Security Status + +:::caution[Alpha Security] +DesterLib is in alpha and **does not yet have authentication**. The API is currently open to anyone on your network. + +**Planned features:** + +- JWT authentication +- User accounts +- Access control +- API keys + ::: + +## Network Security + +### Local Network Only (Safest) + +**Recommended for:** Most users + +Keep DesterLib on your local network: + +- āœ… No port forwarding +- āœ… No public exposure +- āœ… Access via LAN only +- āœ… Use Tailscale for remote access + +### Exposed to Internet + +**Only if necessary**, and follow these rules: + +1. āœ… **Use HTTPS** (reverse proxy required) +2. āœ… **Monitor access logs** regularly +3. āœ… **Use strong database passwords** +4. āœ… **Keep system updated** +5. āœ… **Enable authentication** (when available) + +## Database Security + +### Strong Passwords + +Generate secure password: + +```bash +openssl rand -base64 32 +``` + +Use in your configuration: + +```env +DATABASE_URL=postgresql://desterlib:GENERATED_PASSWORD_HERE@postgres:5432/desterlib?schema=public +``` + +### Don't Expose Database Port + +**Default (Secure):** + +```yaml +postgres: + ports: + - "127.0.0.1:5432:5432" # Localhost only +``` + +**Insecure (Avoid):** + +```yaml +postgres: + ports: + - "0.0.0.0:5432:5432" # Exposed to network āŒ +``` + +## Docker Security + +### Read-Only Media Mount + +Media files are mounted read-only: + +```yaml +volumes: + - /path/to/media:/media:ro # :ro prevents writes +``` + +DesterLib can't modify your media files. + +### Container Isolation + +Containers run in isolated network: + +```yaml +networks: + desterlib-net: + driver: bridge +``` + +### Regular Updates + +Keep Docker images updated: + +```bash +docker-compose pull +docker-compose up -d +``` + +## File Permissions + +### Media Directory + +Recommended permissions: + +```bash +# Server can read, but not write +chmod -R 755 /path/to/media +``` + +### Configuration Files + +Protect sensitive files: + +```bash +chmod 600 ~/.desterlib/.env # Only owner can read/write +chmod 644 ~/.desterlib/docker-compose.yml +``` + +## HTTPS Setup + +### Option 1: Caddy (Easiest) + +**Install Caddy:** + +```bash +sudo apt install caddy # Ubuntu/Debian +brew install caddy # macOS +``` + +**Configure (Caddyfile):** + +``` +desterlib.yourdomain.com { + reverse_proxy localhost:3001 +} +``` + +**Start:** + +```bash +sudo caddy start +``` + +Caddy automatically: + +- āœ… Gets Let's Encrypt certificate +- āœ… Renews certificates +- āœ… Redirects HTTP → HTTPS + +### Option 2: Nginx + Certbot + +**Install:** + +```bash +sudo apt install nginx certbot python3-certbot-nginx +``` + +**Configure nginx:** + +```nginx +server { + listen 80; + server_name desterlib.yourdomain.com; + + location / { + proxy_pass http://localhost:3001; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } +} +``` + +**Get certificate:** + +```bash +sudo certbot --nginx -d desterlib.yourdomain.com +``` + +## Firewall Rules + +### UFW (Ubuntu/Debian) + +```bash +# Allow SSH (don't lock yourself out!) +sudo ufw allow ssh + +# Allow DesterLib +sudo ufw allow 3001/tcp + +# If using HTTPS +sudo ufw allow 443/tcp + +# Enable firewall +sudo ufw enable +``` + +### firewalld (CentOS/RHEL) + +```bash +sudo firewall-cmd --permanent --add-port=3001/tcp +sudo firewall-cmd --reload +``` + +### macOS + +System Preferences → Security & Privacy → Firewall → Firewall Options + +- Add Docker.app +- Allow incoming connections + +## Monitoring & Alerts + +### Log Monitoring + +Watch for suspicious activity: + +```bash +# Monitor API access +docker-compose logs -f api | grep -E "POST|PUT|DELETE" + +# Monitor errors +docker-compose logs -f api | grep -i error +``` + +### Rate Limit Alerts + +Monitor rate limiting: + +```bash +docker-compose logs api | grep "Too many requests" +``` + +If you see many, someone may be abusing your API. + +## Authentication (Planned) + +When authentication is implemented: + +### Planned Features + +- JWT-based authentication +- User registration and login +- Role-based access control +- API key management +- Session management + +### Current Workaround + +Use network-level security: + +- Keep on private network +- Use VPN (Tailscale) +- Use reverse proxy with auth (Authelia, OAuth2 Proxy) + +## Data Protection + +### What's Sensitive + +**High priority:** + +- Database (has your library metadata) +- `.env` file (has credentials) + +**Low priority:** + +- Docker images (public) +- `docker-compose.yml` (no secrets if using .env) + +### Encryption + +**Database encryption:** + +- PostgreSQL doesn't encrypt by default +- Use disk encryption at OS level (LUKS, FileVault, BitLocker) + +**Transport encryption:** + +- Use HTTPS for remote access +- Tailscale encrypts all traffic automatically + +## Incident Response + +### If Compromised + +1. **Immediately:** + + ```bash + docker-compose down + ``` + +2. **Change all passwords:** + - Database password + - Update in `.env` and `docker-compose.yml` + +3. **Review logs:** + + ```bash + docker-compose logs api > incident-logs.txt + ``` + +4. **Restore from backup:** + - See [Backup Guide](/guides/backup-restore/) + +5. **Update everything:** + ```bash + docker-compose pull + docker-compose up -d + ``` + +## Security Checklist + +Before going to production: + +- [ ] Strong database password (32+ characters) +- [ ] `.env` file has correct permissions (600) +- [ ] Media mounted read-only (`:ro`) +- [ ] Firewall configured +- [ ] Using HTTPS (if exposed to internet) +- [ ] Regular backups configured +- [ ] Monitoring in place +- [ ] Keep containers updated weekly + +## Reporting Security Issues + +Found a security vulnerability? + +**DO NOT** open a public issue. + +Email: security@dester.in (or GitHub security advisory) + +We'll respond within 48 hours. + +## Related Documentation + +- [Docker Deployment](/deployment/docker/) - Production deployment +- [Remote Access](/guides/remote-access/) - Secure remote access +- [Environment Variables](/api/environment-variables/) - Configuration +- [Backup & Restore](/guides/backup-restore/) - Data protection diff --git a/apps/docs/src/content/docs/development/commit-guidelines.md b/apps/docs/src/content/docs/development/commit-guidelines.md index c5942fa..46d8897 100644 --- a/apps/docs/src/content/docs/development/commit-guidelines.md +++ b/apps/docs/src/content/docs/development/commit-guidelines.md @@ -29,25 +29,26 @@ This will guide you through creating a properly formatted commit message. The type must be one of the following: -| Type | Description | Example | -|------|-------------|---------| -| `feat` | ✨ A new feature | `feat(api): add user search endpoint` | -| `fix` | šŸ› A bug fix | `fix(auth): resolve JWT validation error` | -| `docs` | šŸ“ Documentation changes | `docs: update installation guide` | -| `style` | šŸ’„ Code style changes | `style(api): format with prettier` | -| `refactor` | ā™»ļø Code refactoring | `refactor(db): simplify query logic` | -| `perf` | ⚔ Performance improvements | `perf(stream): optimize video buffering` | -| `test` | āœ… Adding or updating tests | `test(api): add auth integration tests` | -| `build` | šŸ“¦ Build system changes | `build: update dependencies` | -| `ci` | šŸ‘· CI/CD changes | `ci: add automated release workflow` | -| `chore` | šŸ”§ Other changes | `chore: update gitignore` | -| `revert` | āŖ Revert a commit | `revert: revert "feat: add feature"` | +| Type | Description | Example | +| ---------- | --------------------------- | ----------------------------------------- | +| `feat` | ✨ A new feature | `feat(api): add user search endpoint` | +| `fix` | šŸ› A bug fix | `fix(auth): resolve JWT validation error` | +| `docs` | šŸ“ Documentation changes | `docs: update installation guide` | +| `style` | šŸ’„ Code style changes | `style(api): format with prettier` | +| `refactor` | ā™»ļø Code refactoring | `refactor(db): simplify query logic` | +| `perf` | ⚔ Performance improvements | `perf(stream): optimize video buffering` | +| `test` | āœ… Adding or updating tests | `test(api): add auth integration tests` | +| `build` | šŸ“¦ Build system changes | `build: update dependencies` | +| `ci` | šŸ‘· CI/CD changes | `ci: add automated release workflow` | +| `chore` | šŸ”§ Other changes | `chore: update gitignore` | +| `revert` | āŖ Revert a commit | `revert: revert "feat: add feature"` | ### Scope The scope is optional and represents the area of the codebase affected: **Available scopes:** + - `api` - Backend API - `database` - Database changes - `websocket` - WebSocket functionality @@ -78,6 +79,7 @@ The subject contains a succinct description of the change: - Keep it under 100 characters **Good examples:** + ``` add user authentication fix memory leak in stream handler @@ -85,6 +87,7 @@ update API documentation ``` **Bad examples:** + ``` Added user authentication (past tense) Fix Memory Leak (capitalized) @@ -94,6 +97,7 @@ updated API documentation. (period at end) ### Body The body is optional and should include: + - Motivation for the change - Contrast with previous behavior - Implementation details (if complex) @@ -103,6 +107,7 @@ Use `|` to break lines in the interactive commit tool. ### Footer The footer is optional and should include: + - Breaking changes (prefixed with `BREAKING CHANGE:`) - Issue references (e.g., `Closes #123`) @@ -111,11 +116,13 @@ The footer is optional and should include: Breaking changes must be indicated in two ways: 1. Add `!` after the type/scope: + ``` feat(api)!: redesign authentication API ``` 2. Add `BREAKING CHANGE:` in the footer: + ``` feat(api)!: redesign authentication API @@ -240,4 +247,3 @@ Commits are automatically validated using commitlint. If your commit message doe - [Commitizen](https://github.com/commitizen/cz-cli) - [Commitlint](https://commitlint.js.org/) - [Versioning Guide](/development/versioning/) - diff --git a/apps/docs/src/content/docs/development/contributing.md b/apps/docs/src/content/docs/development/contributing.md index 56a8701..6860996 100644 --- a/apps/docs/src/content/docs/development/contributing.md +++ b/apps/docs/src/content/docs/development/contributing.md @@ -7,14 +7,16 @@ Thank you for your interest in contributing to DesterLib! This guide will help y :::note[Applies to All Projects] This guide applies to **all DesterLib projects**: + - **API Server** - Backend and API development -- **Client Applications** - Mobile, desktop, and TV apps +- **Client Applications** - Mobile, desktop, and TV apps - **Documentation** - This documentation site For project-specific setup: + - [API Server Setup](/api/overview) - [Client Development](/clients/overview) -::: + ::: ## šŸŽÆ Quick Start @@ -133,13 +135,14 @@ pnpm pr:create During alpha development, we use a simplified workflow: **Feature Branches → Main**. Once we reach stable releases, we'll introduce a `dev` branch for staging. ::: -See the [Versioning Guide](/development/versioning/) for complete workflow. +See the [Development Workflow](/development/workflow/) for the complete workflow. ## šŸ” Finding Issues to Work On ### Good First Issues Look for issues labeled: + - `good first issue` - Perfect for newcomers - `help wanted` - Community help needed - `documentation` - Improve docs @@ -162,6 +165,7 @@ Don't see an issue you want? Create one! - ā™»ļø **Refactoring** - Improve code quality **Requirements:** + - Follow code style guidelines - Add tests if possible - Update documentation @@ -175,6 +179,7 @@ Don't see an issue you want? Create one! - šŸŒ **Translations** - Help internationalize **Requirements:** + - Clear and concise writing - Accurate information - Proper formatting @@ -187,6 +192,7 @@ Don't see an issue you want? Create one! - šŸ”§ **Fix flaky tests** - Improve reliability **Requirements:** + - Tests pass locally - Follow existing test patterns @@ -197,6 +203,7 @@ Don't see an issue you want? Create one! - šŸ–¼ļø **Assets** - Icons, images, etc. **Requirements:** + - Match existing design language - Responsive design - Accessibility considerations @@ -284,6 +291,7 @@ See [Versioning Guide](/development/versioning/) for more. ### 1. Automated Checks GitHub Actions will automatically: + - Validate changesets - Run linters - Check types @@ -292,6 +300,7 @@ GitHub Actions will automatically: ### 2. Code Review Maintainers will: + - Review your code - Provide feedback - Request changes (if needed) @@ -312,6 +321,7 @@ git push origin feat/your-feature-name ### 4. Merge Once approved: + - Maintainer merges to `main` - Your contribution is part of DesterLib! - Docs automatically deploy to GitHub Pages @@ -339,6 +349,7 @@ git push origin --delete feat/your-feature-name ## šŸ“š Additional Resources +- [Development Workflow](/development/workflow/) - Complete development workflow - [Commit Guidelines](/development/commit-guidelines/) - Commit message format - [Versioning Guide](/development/versioning/) - Changesets and releases - [Quick Reference](/development/quick-reference/) - Common commands @@ -355,6 +366,7 @@ Need assistance? ## šŸŽ‰ Recognition All contributors are recognized in: + - Project README - Release notes - GitHub contributors page @@ -366,12 +378,14 @@ Thank you for helping make DesterLib better! ā¤ļø We are committed to providing a welcoming and inclusive community. Be respectful, patient, and considerate of others. **Expected behavior:** + - Be welcoming and inclusive - Be respectful of differing viewpoints - Accept constructive criticism gracefully - Focus on what's best for the community **Unacceptable behavior:** + - Harassment or discriminatory language - Personal attacks or insults - Publishing others' private information @@ -384,4 +398,3 @@ Violations may result in temporary or permanent ban from the project. By contributing, you agree that your contributions will be licensed under the GNU Affero General Public License v3.0 (AGPL-3.0). This ensures DesterLib remains free and open source, with all derivatives also remaining open source. - diff --git a/apps/docs/src/content/docs/development/quick-reference.md b/apps/docs/src/content/docs/development/quick-reference.md index 98aa86f..d4a18ac 100644 --- a/apps/docs/src/content/docs/development/quick-reference.md +++ b/apps/docs/src/content/docs/development/quick-reference.md @@ -32,6 +32,7 @@ pnpm release - ⚔ Performance (no breaking change) **Example:** + ``` šŸ¦‹ Summary: Fix authentication token expiry bug šŸ¦‹ Bump: patch @@ -45,6 +46,7 @@ pnpm release - šŸ“¦ New optional parameters **Example:** + ``` šŸ¦‹ Summary: Add user profile image upload endpoint šŸ¦‹ Bump: minor @@ -58,6 +60,7 @@ pnpm release - āš ļø Required parameter changes **Example:** + ``` šŸ¦‹ Summary: Replace REST auth with OAuth2 (BREAKING) šŸ¦‹ Bump: major @@ -70,6 +73,10 @@ Make Changes → Create Changeset → Commit & Push → Create PR to main → Review & Merge → Auto Deploy (docs) → Version Bump → Release ``` +:::tip[Complete Workflow] +See the [Development Workflow](/development/workflow/) for the complete step-by-step guide. +::: + ## Example Changeset File ```markdown @@ -104,18 +111,21 @@ BREAKING CHANGE: Replace /auth/login with OAuth2 ## PR Workflow 1. **Create feature branch from `main`** + ```bash git checkout main && git pull git checkout -b feat/my-feature ``` 2. **Make changes and commit** + ```bash git add . pnpm commit ``` 3. **Add changeset** + ```bash pnpm changeset git add .changeset @@ -123,11 +133,13 @@ BREAKING CHANGE: Replace /auth/login with OAuth2 ``` 4. **Push and create PR** + ```bash git push -u origin feat/my-feature ``` 5. **After merge to main** + ```bash # Docs auto-deploy via GitHub Actions # Version bump via automated PR when ready @@ -177,6 +189,7 @@ cat .changeset/some-changeset.md ### Undo a Changeset Simply delete the changeset file: + ```bash rm .changeset/some-changeset.md ``` @@ -187,4 +200,3 @@ rm .changeset/some-changeset.md - šŸ“š [Commit Guidelines](/development/commit-guidelines/) - šŸ”— [Changesets Docs](https://github.com/changesets/changesets) - šŸ”— [Semantic Versioning](https://semver.org/) - diff --git a/apps/docs/src/content/docs/development/structure.md b/apps/docs/src/content/docs/development/structure.md index bbec4f3..954b072 100644 --- a/apps/docs/src/content/docs/development/structure.md +++ b/apps/docs/src/content/docs/development/structure.md @@ -68,6 +68,7 @@ Domains represent distinct features or business areas: - **settings/** - User and system settings Each domain typically contains: + - Controllers (route handlers) - Services (business logic) - Models/Types (data structures) @@ -86,6 +87,7 @@ The `lib/` directory contains code shared across domains: #### Core The `core/` directory contains: + - Application configuration - Service initialization - Dependency injection setup @@ -117,6 +119,7 @@ apps/docs/ Location: `packages/eslint-config/` Provides shared ESLint configurations: + - `base.js` - Base rules for all projects - `next.js` - Next.js specific rules - `react-internal.js` - React component rules @@ -126,6 +129,7 @@ Provides shared ESLint configurations: Location: `packages/typescript-config/` Provides shared TypeScript configurations: + - `base.json` - Base TypeScript config - `nextjs.json` - Next.js specific config - `react-library.json` - React library config @@ -210,10 +214,10 @@ Use TypeScript path aliases (configured in `tsconfig.json`): ```typescript // Instead of: -import { db } from '../../../lib/database/client'; +import { db } from "../../../lib/database/client"; // Use: -import { db } from '@/lib/database/client'; +import { db } from "@/lib/database/client"; ``` ## Build System @@ -245,4 +249,3 @@ pnpm dev - [Versioning Guide](/development/versioning/) - Learn how to contribute - [Quick Reference](/development/quick-reference/) - Common commands - [Commit Guidelines](/development/commit-guidelines/) - Commit message format - diff --git a/apps/docs/src/content/docs/development/versioning.md b/apps/docs/src/content/docs/development/versioning.md index 8d23ec7..ea4b420 100644 --- a/apps/docs/src/content/docs/development/versioning.md +++ b/apps/docs/src/content/docs/development/versioning.md @@ -1,469 +1,241 @@ --- -title: Versioning Guide -description: How to track changes and manage releases across all DesterLib projects +title: Version Management +description: How DesterLib manages versions across the API and clients --- -DesterLib uses different versioning tools for different projects, but all follow semantic versioning and conventional commits. +DesterLib uses **centralized version management** with strict version matching between the API and all client applications. -## Overview by Project +## Overview -### API Server (Node.js/TypeScript) -Uses **[Changesets](https://github.com/changesets/changesets)** to: -- Track changes across monorepo packages -- Generate changelogs automatically -- Version packages based on changes -- Simplify the release process +All versions are managed from a single source of truth: the root `package.json`. A sync script automatically propagates version changes to all dependent projects. -### Client Applications (Flutter/Dart) -Uses **conventional-changelog** to: -- Generate changelogs from commit history -- Automate version bumping in `pubspec.yaml` -- Create releases with automated builds -- Maintain semantic versioning +**Current Version:** `0.1.0` ---- - -## Semantic Versioning +## Version Format -All projects follow [Semantic Versioning 2.0.0](https://semver.org/): +We follow [Semantic Versioning 2.0.0](https://semver.org/): ``` MAJOR.MINOR.PATCH - -API Server: 1.2.3 -Client (Flutter): 1.2.3+4 (includes build number) ``` -| Version | When to Bump | Example | -|---------|-------------|---------| -| **MAJOR** | Breaking changes | 0.9.0 → 1.0.0 | -| **MINOR** | New features (backward-compatible) | 1.0.0 → 1.1.0 | -| **PATCH** | Bug fixes | 1.1.0 → 1.1.1 | -| **BUILD** | Flutter only - build number | 1.1.0+1 → 1.1.0+2 | - ---- +- **MAJOR**: Incompatible API changes +- **MINOR**: Backward-compatible functionality additions +- **PATCH**: Backward-compatible bug fixes -## API Server Versioning (Changesets) +## Version Matching Rules -Each significant change should have an associated changeset file that describes what changed and the impact level (patch, minor, or major). +The system enforces **strict semantic versioning**: -## Branching Strategy +- āœ… **Major.Minor** must match exactly +- āœ… **Patch** can differ (backwards compatible) -:::note[Alpha Development Workflow] -During alpha development, we use a simplified workflow. Once we reach stable releases (v1.0.0), we'll introduce a `dev` branch for staging. -::: +### Compatibility Examples -- **main** - Production code, auto-deploys docs, tagged releases -- **feat/** - Feature branches created from `main` -- **fix/** - Bug fix branches created from `main` -- **chore/** - Maintenance branches created from `main` -- **docs/** - Documentation branches created from `main` +| Client | API | Compatible? | Reason | +| ------ | ----- | ----------- | ---------------------- | +| 0.1.0 | 0.1.0 | āœ… Yes | Exact match | +| 0.1.0 | 0.1.5 | āœ… Yes | Patch difference OK | +| 0.1.0 | 0.2.0 | āŒ No | Minor version mismatch | +| 0.1.0 | 1.0.0 | āŒ No | Major version mismatch | -## Making Changes +## Updating Versions -### 1. Create a feature branch +### Quick Process ```bash -git checkout main -git pull origin main -git checkout -b feat/your-feature-name -``` - -### 2. Make your changes - -Write your code, tests, and documentation. - -### 3. Commit using conventional commits +# 1. Create a changeset for your changes +pnpm changeset -```bash +# 2. Commit changes and changeset pnpm commit -``` - -This will guide you through creating a properly formatted commit message. - -### 4. Create a changeset - -See the [Creating a Changeset](#creating-a-changeset) section below. +git push origin dev -## Creating a Changeset - -After making changes that affect users, create a changeset: - -```bash -pnpm changeset -``` +# 3. After merge, version packages (maintainers only) +pnpm version -Or: -```bash -pnpm changeset:add +# 4. Version bump generates package CHANGELOG.md files automatically ``` -This will prompt you to: -1. **Select packages** that were changed -2. **Choose bump type** (patch, minor, or major) for each package -3. **Write a summary** of the changes (this will appear in the changelog) - -### Example Changeset Flow - -```bash -$ pnpm changeset - -šŸ¦‹ Which packages would you like to include? -ā—‰ api -ā—Æ @repo/eslint-config -ā—Æ @repo/typescript-config - -šŸ¦‹ Which packages should have a major bump? -ā—Æ api - -šŸ¦‹ Which packages should have a minor bump? -ā—‰ api - -šŸ¦‹ Please enter a summary for this change: -Add WebSocket support for real-time notifications - -āœ” Changeset added! - see .changeset/random-words-here.md -``` +### What Gets Synced -### When to Create Changesets +The `pnpm version:sync` script automatically updates: -**Create a changeset for:** -- āœ… New features -- āœ… Bug fixes -- āœ… Breaking changes -- āœ… Performance improvements -- āœ… Dependency updates that affect users +- āœ… `apps/api/package.json` - API version +- āœ… `../desterlib-flutter/pubspec.yaml` - Flutter app version +- āœ… `../desterlib-flutter/lib/api/pubspec.yaml` - Generated client version +- āœ… `../desterlib-flutter/lib/core/config/api_config.dart` - Client version constant -**Skip changesets for:** -- āŒ Documentation updates -- āŒ Internal refactoring (no API changes) -- āŒ Test additions/updates -- āŒ CI/CD changes +### Changelog Management -## Version Bump Types +DesterLib uses [Changesets](https://github.com/changesets/changesets) for automatic changelog generation: -Follow [Semantic Versioning](https://semver.org/) (SemVer): +- šŸ“ Package changelogs are auto-generated in: + - `apps/api/CHANGELOG.md` - API changes + - `packages/cli/CHANGELOG.md` - CLI changes + - `apps/docs/CHANGELOG.md` - Documentation changes +- šŸ“ Aggregated changelog synced to docs site +- šŸ“ Use `pnpm changeset` to document your changes -### Patch (0.0.X) +## How It Works -Bug fixes and minor changes that don't affect the API: -- Bug fixes -- Documentation updates -- Internal refactoring -- Performance improvements (non-breaking) +### API Side -**Example:** `1.2.3` → `1.2.4` +1. **Version Exposure** + - `/health` endpoint returns: `{ status, version, timestamp, uptime }` + - All responses include `X-API-Version` header + - Swagger docs display current version -### Minor (0.X.0) +2. **Version Validation** + - Middleware checks `X-Client-Version` header on all `/api/v1` requests + - Returns HTTP 426 (Upgrade Required) if incompatible + - Provides clear error message with upgrade instructions -New features that are backward-compatible: -- New features -- New API endpoints -- New optional parameters -- Deprecations (with backward compatibility) +3. **Compatibility Check** + ```typescript + // Major and minor must match exactly + client.major === api.major && client.minor === api.minor; + ``` -**Example:** `1.2.3` → `1.3.0` +### Client Side -### Major (X.0.0) +1. **Version Declaration** + - All requests include `X-Client-Version: 0.1.0` header + - Version constant synced from root package.json -Breaking changes that require users to modify their code: -- Breaking API changes -- Removed features -- Changed behavior of existing features -- Required parameter changes +2. **Version Detection** + - Dio interceptor reads `X-API-Version` from responses + - Automatically updates version provider + - Handles HTTP 426 errors gracefully -**Example:** `1.2.3` → `2.0.0` +3. **User Experience** + - Shows friendly error when version mismatch occurs + - Suggests updating the app + - Provides clear instructions -## Versioning Packages +## Error Handling -When you're ready to create a new version: +### API Response (HTTP 426) -### 1. Check changeset status +When versions are incompatible: -```bash -pnpm changeset:status +```json +{ + "success": false, + "error": "Version mismatch", + "message": "Client version 0.1.0 is not compatible with API version 0.2.0. Please update your client.", + "data": { + "clientVersion": "0.1.0", + "apiVersion": "0.2.0", + "upgradeRequired": true + } +} ``` -### 2. Apply version bumps +### Client Behavior -```bash -pnpm version -``` +The Flutter client: -This command will: -- Read all changeset files in `.changeset/` -- Bump package versions according to semantic versioning -- Update `CHANGELOG.md` files -- Delete consumed changeset files -- Update lockfile +1. Detects version mismatch (HTTP 426) +2. Updates version provider +3. Shows user-friendly error +4. Suggests app update -### 3. Review the changes +## Version Sync Script -- Check updated `package.json` files -- Review generated `CHANGELOG.md` entries -- Verify version numbers are correct +### Usage -### 4. Commit version changes +Check and sync versions: ```bash -git add . -git commit -m "chore: version packages" -git push +pnpm version:sync ``` -## Publishing Releases - -### Manual Release - -1. Merge to main: - ```bash - # Not needed in alpha - PRs merge directly to main - # In the future with dev branch: - # git checkout main - # git merge dev - ``` - -2. Build and publish: - ```bash - pnpm release - ``` - -3. Push with tags: - ```bash - git push --follow-tags - ``` - -### Automated Release (Recommended) - -GitHub Actions automatically handles releases: - -1. Merge PR to `main` that contains version bump commits -2. GitHub Action automatically: - - Builds packages - - Publishes to npm (if configured) - - Creates GitHub releases - - Tags commits - -## Workflow Examples - -### Example 1: Adding a Feature +### Output Example ```bash -# 1. Create feature branch -git checkout main -git pull origin main -git checkout -b feat/add-user-search - -# 2. Make changes -# ... code changes ... - -# 3. Commit changes -git add . -pnpm commit -# Select: feat -# Scope: api -# Description: add user search endpoint - -# 4. Create changeset -pnpm changeset -# Select: api -# Bump: minor -# Summary: Add user search endpoint with filters +šŸ“¦ Syncing version: 0.1.0 +────────────────────────────────────────────────── +āœ… Updated apps/api/package.json: 0.1.0 +āœ… Updated desterlib-flutter/pubspec.yaml: 0.1.0 +āœ… Updated desterlib-flutter/lib/api/pubspec.yaml: 0.1.0 +āœ… Updated desterlib-flutter/lib/core/config/api_config.dart: 0.1.0 -# 5. Commit changeset -git add . -git commit -m "chore: add changeset for user search" - -# 6. Push and create PR -git push -u origin feat/add-user-search +────────────────────────────────────────────────── +āœ… Successfully updated 4 file(s) ``` -### Example 2: Fixing a Bug - -```bash -# 1. Create fix branch -git checkout main -git pull origin main -git checkout -b fix/authentication-error +## API Route Version vs Semantic Version -# 2. Fix the bug -# ... code changes ... +Don't confuse these two concepts: -# 3. Commit fix -git add . -pnpm commit -# Select: fix -# Scope: api -# Description: resolve JWT token validation error +- **API Route Version:** `v1` in `/api/v1/...` - Rarely changes, represents API schema version +- **Semantic Version:** `0.1.0` - Changes with each release, represents application version -# 4. Create changeset -pnpm changeset -# Select: api -# Bump: patch -# Summary: Fix JWT token validation error causing 401s +In `api_config.dart`: -# 5. Commit changeset -git add . -git commit -m "chore: add changeset for auth fix" - -# 6. Push and create PR -git push -u origin fix/authentication-error +```dart +static const String apiRouteVersion = 'v1'; // API endpoint version +static const String clientVersion = '0.1.0'; // Semantic version ``` -### Example 3: Breaking Change +## Development -```bash -# 1. Create feature branch -git checkout main -git checkout -b feat/api-v2-breaking +### Skipping Version Checks -# 2. Make breaking changes -# ... code changes ... +For local development, you can temporarily disable validation: -# 3. Commit with breaking change marker -git add . -pnpm commit -# Select: feat -# Scope: api -# Description: redesign authentication API -# Breaking change?: Yes - -# 4. Create changeset with major bump -pnpm changeset -# Select: api -# Bump: major -# Summary: | -# BREAKING CHANGE: Redesign authentication API -# -# - Replace /auth/login with OAuth2 flow -# - Remove /auth/register endpoint -# - Update response format - -# 5. Commit and push -git add . -git commit -m "chore: add changeset for breaking change" -git push -u origin feat/api-v2-breaking -``` - ---- - -## Client Versioning (Flutter) +1. Comment out `validateVersion` middleware in `setup.middleware.ts` +2. Or omit the `X-Client-Version` header (logs warning but allows request) -The Flutter client uses a simpler, automated workflow with `conventional-changelog`. +### Testing Version Mismatches -### Quick Workflow +To test the version mismatch flow: -```bash -# 1. Work on feature branch -git checkout -b feat/subtitle-support -# ... make changes ... -npm run commit # Conventional commits +1. Change `clientVersion` in `api_config.dart` to a different version +2. Make an API request +3. Verify HTTP 426 response +4. Verify client handles error appropriately -# 2. Create PR, get it merged to main - -# 3. After merge, create release from main -git checkout main -git pull origin main -npm run release # Interactive script -``` - -### Release Script - -The `npm run release` command: -1. āœ… Checks you're on `main` branch -2. āœ… Shows current version -3. āœ… Prompts for bump type (patch/minor/major/build) -4. āœ… Updates `pubspec.yaml` -5. āœ… Generates changelog from commits -6. āœ… Creates release commit and tag -7. āœ… Prompts you to push - -### Version Format - -```yaml -version: 1.2.3+4 -# │ │ │ │ -# │ │ │ └─ Build number (auto-incremented) -# │ │ └─── PATCH -# │ └───── MINOR -# └─────── MAJOR -``` +## Best Practices -### Automated Builds +1. **Create changesets** - Always run `pnpm changeset` for user-facing changes +2. **Use conventional commits** - Helps with changelog generation +3. **Test thoroughly** - Test both compatible and incompatible scenarios +4. **Clear communication** - Inform users about breaking changes +5. **Review generated changelogs** - Verify changesets generate correct entries -When you push a tag, GitHub Actions automatically: -- Creates GitHub Release with changelog -- Builds for all platforms (Android, iOS, macOS, Linux, Windows) -- Attaches builds to the release +## Troubleshooting -### Example: Feature Release +### Versions Out of Sync ```bash -# After PRs are merged to main -git checkout main -git pull origin main - -# Run release script -npm run release - -# Interactive prompts: -# Current version: 0.1.5+3 -# Select version bump type: -# 1) Patch (bug fixes) - 0.1.5 → 0.1.6+1 -# 2) Minor (new features) - 0.1.5 → 0.2.0+1 -# 3) Major (breaking changes) - 0.1.5 → 1.0.0+1 -# -# Enter choice: 2 - -# Changelog is generated from commits: -# ## [0.2.0] - 2024-10-27 -# -# ### Features -# * **player:** add subtitle support -# * **ui:** add dark mode theme -# -# ### Bug Fixes -# * **auth:** resolve token refresh - -# Confirm and push -git push origin main --tags - -# Check: https://github.com/DesterLib/desterlib-flutter/releases +# Fix it +pnpm version:sync ``` -### Client Commands Reference +### Script Not Found -| Command | Description | -|---------|-------------| -| `npm run commit` | Create conventional commit | -| `npm run release` | Interactive release (must be on main) | -| `npm run version:bump patch` | Manual version bump | -| `npm run changelog:generate` | Generate changelog only | - ---- - -## API Commands Reference +```bash +# Make executable +chmod +x scripts/sync-version.js -| Command | Description | -|---------|-------------| -| `pnpm changeset` | Create a new changeset | -| `pnpm changeset:add` | Alias for `pnpm changeset` | -| `pnpm changeset:status` | Check which packages will be versioned | -| `pnpm version` | Apply version bumps and update changelogs | -| `pnpm release` | Build and publish packages | +# Or run directly +node scripts/sync-version.js +``` -## Best Practices +### Manual Verification -1. **Write clear changeset summaries** - They become your changelog entries -2. **Create changesets per feature** - Don't bundle multiple features -3. **Version frequently** - Don't let changesets pile up -4. **Review generated CHANGELOGs** - Make sure they're clear for users -5. **Use conventional commits** - Makes history easier to understand +Check these files if sync seems incorrect: -## Additional Resources +1. `package.json` - Root version (source of truth) +2. `apps/api/package.json` - Should match root +3. `desterlib-flutter/pubspec.yaml` - Should match root +4. `desterlib-flutter/lib/api/pubspec.yaml` - Should match root +5. `desterlib-flutter/lib/core/config/api_config.dart` - `clientVersion` should match root -- [Changesets Documentation](https://github.com/changesets/changesets) -- [Semantic Versioning](https://semver.org/) -- [Conventional Commits](https://www.conventionalcommits.org/) -- [Quick Reference](/development/quick-reference/) for a shorter guide +## Related Documentation +- [Contributing Guide](/development/contributing) +- [Commit Guidelines](/development/commit-guidelines) +- [API Documentation](/api/overview) diff --git a/apps/docs/src/content/docs/development/workflow.md b/apps/docs/src/content/docs/development/workflow.md new file mode 100644 index 0000000..09531cb --- /dev/null +++ b/apps/docs/src/content/docs/development/workflow.md @@ -0,0 +1,200 @@ +--- +title: Development Workflow +description: Simple workflow for DesterLib development +--- + +Simple workflow for DesterLib development. + +## šŸš€ Daily Development Flow + +### 1. Make Changes + +```bash +# Create feature branch +git checkout main +git pull +git checkout -b feat/your-feature + +# Make your changes +# ... edit files ... +``` + +### 2. Format & Commit + +```bash +# Format code +pnpm format + +# Commit (interactive) +pnpm commit +``` + +### 3. Add Changeset (if user-facing) + +```bash +# For API changes, new features, bug fixes +pnpm changeset +# Select packages → Choose bump type → Write summary +git add .changeset +pnpm commit +``` + +### 4. Create PR + +```bash +# Push branch +git push origin feat/your-feature + +# Create PR (runs checks automatically) +pnpm pr:create +``` + +That's it! The `pnpm pr:create` command will: + +- āœ… Run lint check +- āœ… Run type check +- āœ… Check formatting +- āœ… Verify versioning setup +- āœ… Create PR with GitHub CLI + +## šŸ“‹ What Happens After PR Merge + +### Automatic (CI/CD) + +1. **Docs deploy** - Documentation automatically updates +2. **Changeset validation** - CI verifies changesets +3. **Version bump** - When ready, maintainers run `pnpm version` on `main` + +### Manual (Maintainers Only) + +```bash +# On main branch, after PRs are merged +pnpm version # Bump versions, generate changelogs +pnpm release # Build and publish (when ready) +``` + +## šŸ”„ Complete Flow Diagram + +``` +ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” +│ 1. Make Changes │ +│ git checkout -b feat/feature │ +│ ... edit files ... │ +ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¬ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ + │ + ā–¼ +ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” +│ 2. Format & Commit │ +│ pnpm format │ +│ pnpm commit │ +ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¬ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ + │ + ā–¼ +ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” +│ 3. Add Changeset (if needed) │ +│ pnpm changeset │ +│ git add .changeset │ +│ pnpm commit │ +ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¬ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ + │ + ā–¼ +ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” +│ 4. Create PR │ +│ git push origin feat/feature │ +│ pnpm pr:create │ +│ └─ Runs: lint, types, format, versioning checks │ +│ └─ Creates PR on GitHub │ +ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¬ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ + │ + ā–¼ +ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” +│ 5. Review & Merge │ +│ → CI runs checks │ +│ → Maintainer reviews │ +│ → PR merged to main │ +ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¬ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ + │ + ā–¼ +ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” +│ 6. Release (Maintainers) │ +│ pnpm version # Bump versions, generate changelogs │ +│ pnpm release # Build and publish │ +ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ +``` + +## šŸŽÆ Quick Reference + +### Essential Commands + +```bash +pnpm format # Format code +pnpm commit # Interactive commit +pnpm changeset # Create changeset +pnpm pre-pr # Run checks before PR +pnpm pr:create # Create PR (includes checks) +``` + +### Versioning Commands (Maintainers) + +```bash +pnpm changeset:status # Check changeset status +pnpm verify:versioning # Verify versioning setup +pnpm version # Bump versions +pnpm changelog:sync # Sync changelogs to docs +pnpm release # Build and publish +``` + +### Development Commands + +```bash +pnpm dev # Start dev servers +pnpm build # Build all packages +pnpm lint # Lint code +pnpm check-types # Type check +``` + +## ā“ When to Add a Changeset + +### āœ… Add Changeset For: + +- New features (minor bump) +- Bug fixes (patch bump) +- Breaking changes (major bump) +- API changes +- User-facing changes + +### āŒ Skip Changeset For: + +- Documentation only +- Internal refactoring +- Tests +- CI/CD changes +- Code style/formatting + +## 🚨 Common Issues + +### Pre-PR Checks Fail + +```bash +# Fix linting +pnpm lint:fix + +# Fix formatting +pnpm format + +# Fix types +# Check TypeScript errors and fix + +# Skip checks (not recommended) +pnpm pr:create --skip-checks +``` + +### No Changeset Needed + +If your PR doesn't need versioning (docs, tests, etc.), just skip the changeset step. The checks will still pass. + +## šŸ“š More Info + +- [Contributing Guide](/development/contributing) - Detailed contribution guide +- [Versioning Guide](/development/versioning) - Version management details +- [Quick Reference](/development/quick-reference) - Common commands reference diff --git a/apps/docs/src/content/docs/docs/changelog.md b/apps/docs/src/content/docs/docs/changelog.md new file mode 100644 index 0000000..69a452a --- /dev/null +++ b/apps/docs/src/content/docs/docs/changelog.md @@ -0,0 +1,14 @@ +--- +title: Documentation Changelog +description: Changelog for the DesterLib Documentation +--- + +All notable changes to Documentation will be documented here. + +This changelog is automatically generated from [Changesets](https://github.com/changesets/changesets). + +See the [Documentation Changelog on GitHub](https://github.com/DesterLib/desterlib/blob/main/apps/docs/CHANGELOG.md) for the source file. + +--- + +_No changelog entries yet. Changelog will appear here once versions are bumped._ diff --git a/apps/docs/src/content/docs/getting-started/installation.md b/apps/docs/src/content/docs/getting-started/installation.md index 772b0a1..be65544 100644 --- a/apps/docs/src/content/docs/getting-started/installation.md +++ b/apps/docs/src/content/docs/getting-started/installation.md @@ -3,123 +3,130 @@ title: Installation Guide description: Complete installation guide for DesterLib server and clients --- -This guide covers complete installation for both the server and client applications. +Complete guide for installing and configuring DesterLib. + +:::tip[Just want to get started quickly?] +See the [Quick Start Guide](/getting-started/quick-start/) for a 5-minute setup! +::: ## System Requirements -### Server Requirements +### Server + - **CPU**: 2 cores minimum - **RAM**: 2GB minimum -- **Storage**: Your media collection size + 10% extra -- **OS**: Linux, macOS, or Windows with Docker support -- **Network**: Local network or internet access for remote streaming +- **Storage**: Your media collection size + 10GB +- **OS**: Linux, macOS, or Windows +- **Docker**: Version 20.10 or higher -### Software Requirements -- **Docker** 20.10 or higher -- **Node.js** 18+ and **pnpm** 9.0+ (only for development) +### Client Devices -## Part 1: Server Installation +- Android 5.0+, iOS 12+, macOS 10.15+, Windows 10+, or Linux -### Docker Setup (Recommended) +## Server Installation -**Best for:** Everyone - simplest way to get started +### Option 1: CLI Setup (Recommended) -1. **Clone the repository:** - ```bash - git clone https://github.com/DesterLib/desterlib.git - cd desterlib - ``` +**Perfect for:** End users who want it working fast -2. **Start the server:** - ```bash - docker-compose up -d - ``` +```bash +npx @desterlib/cli +``` -3. **Verify server is running:** - ```bash - curl http://localhost:3001/health - ``` - - Expected response: - ```json - { - "status": "ok", - "timestamp": "2024-01-01T00:00:00.000Z" - } - ``` +The interactive wizard will guide you through: -That's it! Your server is ready. Now install the client app to scan your library and start watching. +1. Media library location +2. Server port (default: 3001) +3. Database credentials -### Optional: Environment Configuration +Your server will be installed in `~/.desterlib/` and started automatically. -To customize settings, create `.env` in `apps/api/`: +**Verify it's working:** ```bash -# Database (uses default Docker settings) -DATABASE_URL=postgresql://postgres:postgres@db:5432/desterlib +curl http://localhost:3001/health +# Should return: {"status":"ok",...} +``` -# Server -NODE_ENV=production -PORT=3001 +### Option 2: Manual Setup (For Developers) -# Rate Limiting -RATE_LIMIT_WINDOW_MS=900000 -RATE_LIMIT_MAX=100 +**Perfect for:** Contributors and advanced users + +```bash +# 1. Clone the repository +git clone https://github.com/DesterLib/desterlib.git +cd desterlib + +# 2. Start with Docker Compose +docker-compose up -d + +# 3. Access at http://localhost:3001 ``` -## Part 2: Client Installation +**Optional:** Customize with `.env` file in `apps/api/`: -### Quick Download (Recommended) +```env +DATABASE_URL=postgresql://postgres:postgres@postgres:5432/desterlib +NODE_ENV=production +PORT=3001 +``` -šŸ“„ **[Download Latest Alpha Build](https://github.com/DesterLib/Dester-Flutter/releases/latest)** +## Client Installation -āš ļø **Alpha Release**: DesterLib is currently in alpha development. Expect bugs, missing features, and frequent updates! +### Pre-built Apps -Choose your platform: -- **Android (Phone/Tablet)**: Download `Dester-*-Android-arm64-v8a.apk` and install -- **Android TV**: Download `Dester-*-AndroidTV-arm64.apk` for TV with remote support -- **macOS**: Download `Dester-*-macOS.dmg` and drag to Applications -- **Linux**: Download `Dester-*-Linux-x64.tar.gz` and extract -- **Windows**: Download `Dester-*-Windows-x64.zip` and extract -- **iOS**: See [Building from Source](#building-from-source) below +šŸ“„ **[Download from Releases](https://github.com/DesterLib/Dester-Flutter/releases/latest)** -:::tip -The `*` in filenames represents the version number (e.g., `v0.1.0-alpha`). -Download the file matching your platform from the latest release. +:::caution[Alpha Software] +DesterLib is in alpha. Expect bugs and frequent updates! ::: -After installing: +**Available platforms:** + +| Platform | File | +| -------------------- | -------------------------------- | +| Android Phone/Tablet | `Dester-*-Android-arm64-v8a.apk` | +| Android TV | `Dester-*-AndroidTV-arm64.apk` | +| macOS | `Dester-*-macOS.dmg` | +| Windows | `Dester-*-Windows-x64.zip` | +| Linux | `Dester-*-Linux-x64.tar.gz` | +| iOS | Build from source (see below) | + +**After installing:** + 1. Open the app -2. Enter server address: `http://YOUR_SERVER_IP:3001` -3. Go to Settings → Library Management -4. Tap "Scan Library" to index your media -5. Start browsing and streaming! +2. Enter: `http://YOUR_SERVER_IP:3001` +3. Scan Library from Settings +4. Start watching! -### Building from Source +### Build from Source -If you prefer to build from source or need iOS builds: +For iOS or if you prefer building yourself: #### Android 1. **Clone the Flutter app repository:** + ```bash git clone https://github.com/DesterLib/Dester-Flutter.git cd Dester-Flutter ``` 2. **Install Flutter dependencies:** + ```bash flutter pub get ``` 3. **Build and install APK:** + ```bash # Build APK flutter build apk --release - + # APK will be at: build/app/outputs/flutter-apk/app-release.apk # Transfer to your Android device and install - + # Or install directly if device is connected: flutter install ``` @@ -131,6 +138,7 @@ If you prefer to build from source or need iOS builds: - Apple Developer account (for device deployment) 2. **Clone and setup:** + ```bash git clone https://github.com/DesterLib/Dester-Flutter.git cd Dester-Flutter @@ -138,6 +146,7 @@ If you prefer to build from source or need iOS builds: ``` 3. **Install CocoaPods dependencies:** + ```bash cd ios pod install @@ -145,6 +154,7 @@ If you prefer to build from source or need iOS builds: ``` 4. **Open in Xcode:** + ```bash open ios/Runner.xcworkspace ``` @@ -157,6 +167,7 @@ If you prefer to build from source or need iOS builds: #### Desktop Platforms **macOS:** + ```bash git clone https://github.com/DesterLib/Dester-Flutter.git cd Dester-Flutter @@ -167,6 +178,7 @@ flutter build macos --release ``` **Linux:** + ```bash # Install dependencies first sudo apt-get install clang cmake ninja-build pkg-config libgtk-3-dev liblzma-dev libmpv-dev mpv @@ -180,6 +192,7 @@ flutter build linux --release ``` **Windows:** + ```bash git clone https://github.com/DesterLib/Dester-Flutter.git cd Dester-Flutter @@ -189,256 +202,172 @@ flutter build windows --release # App will be at: build/windows/x64/runner/Release/ ``` -## Part 3: Connecting Everything +## Managing Your Server -### Finding Your Server IP +Commands depend on your installation method: -**On the server machine:** +**If installed via CLI:** ```bash -# macOS/Linux -ifconfig | grep "inet " +cd ~/.desterlib -# Windows -ipconfig +docker-compose ps # View status +docker-compose logs -f # View logs +docker-compose restart # Restart +docker-compose down # Stop +docker-compose pull && docker-compose up -d # Update ``` -Look for your local IP (usually starts with `192.168.x.x` or `10.0.x.x`) - -### Configure Client App - -1. Open the mobile/desktop app -2. Go to Settings or initial setup -3. Enter: `http://YOUR_SERVER_IP:3001` - - Example: `http://192.168.1.100:3001` - - If on same machine: `http://localhost:3001` -4. Save and connect - -### Test Connection - -From the client device, test if the server is reachable: +**If installed via Git:** ```bash -# Replace with your server IP -curl http://192.168.1.100:3001/health -``` +cd desterlib -Should return: -```json -{"status": "ok", "timestamp": "..."} +docker ps # View status +docker-compose logs -f # View logs +docker-compose restart # Restart +docker-compose down # Stop +git pull && docker-compose up -d --build # Update ``` -## Advanced Setup +## Development Setup -### For Developers +For contributors who want to modify the code: -If you want to develop and contribute: +```bash +# 1. Clone and install +git clone https://github.com/DesterLib/desterlib.git +cd desterlib +pnpm install -1. **Clone the repository:** - ```bash - git clone https://github.com/DesterLib/desterlib.git - cd desterlib - ``` +# 2. Start test database +docker-compose -f docker-compose.test.yml up -d -2. **Install dependencies:** - ```bash - pnpm install - ``` +# 3. Configure .env in apps/api/ +DATABASE_URL=postgresql://postgres:postgres@localhost:5433/desterlib_test +NODE_ENV=development +PORT=3001 -3. **Start test database:** - ```bash - docker-compose -f docker-compose.test.yml up -d - ``` +# 4. Run the API +cd apps/api +pnpm db:generate +pnpm db:push +pnpm dev +``` -4. **Configure environment** (`.env` in `apps/api/`): - ```bash - DATABASE_URL=postgresql://postgres:postgres@localhost:5433/desterlib_test - NODE_ENV=development - PORT=3001 - ``` +See [Contributing Guide](/development/contributing/) for more details. -5. **Run the API:** - ```bash - cd apps/api - pnpm db:generate - pnpm db:push - pnpm dev - ``` +## Remote Access -6. **Run the docs (optional):** - ```bash - cd apps/docs - pnpm dev - ``` +To access from outside your network: -### Remote Access Setup +**Option 1: Port Forwarding** -To access your server from outside your home network: +- Forward port 3001 on your router +- Use dynamic DNS (e.g., DuckDNS, No-IP) -1. **Port Forwarding:** - - Forward port 3001 on your router to your server - - Use your public IP or a dynamic DNS service +**Option 2: Tunneling (Easier)** -2. **Security (Recommended):** - - Set up HTTPS with Let's Encrypt - - Use a reverse proxy (nginx/Caddy) - - Enable authentication +- [Tailscale](https://tailscale.com/) (recommended) +- [ngrok](https://ngrok.com/): `ngrok http 3001` +- [Cloudflare Tunnel](https://developers.cloudflare.com/cloudflare-one/connections/connect-apps/) -3. **Or use tunneling services:** - - ngrok: `ngrok http 3001` - - Cloudflare Tunnel - - Tailscale +:::caution[Security] +If exposing to internet, use HTTPS and enable authentication! +::: ## Maintenance -### Updating Server +### Database Backup ```bash -cd desterlib -git pull origin main -docker-compose down -docker-compose build -docker-compose up -d +docker exec -t desterlib-postgres pg_dump -U desterlib desterlib > backup.sql ``` -### Updating Client Apps - -Rebuild from source using the same installation steps. - -### Backup Database +### Database Restore ```bash -docker exec -t desterlib-db pg_dump -U postgres desterlib > backup.sql +cat backup.sql | docker exec -i desterlib-postgres psql -U desterlib desterlib ``` -### Restore Database - -```bash -cat backup.sql | docker exec -i desterlib-db psql -U postgres desterlib -``` +### Uninstall -### View Logs +**CLI installation:** ```bash -# Server logs -docker logs desterlib-api - -# Follow logs -docker logs -f desterlib-api - -# Database logs -docker logs desterlib-db +cd ~/.desterlib && docker-compose down -v +rm -rf ~/.desterlib ``` -## Uninstallation - -### Remove Server +**Git installation:** ```bash -cd desterlib -docker-compose down -v # -v removes volumes (database data) -cd .. -rm -rf desterlib +cd desterlib && docker-compose down -v +cd .. && rm -rf desterlib ``` -### Remove Client Apps - -- **Android/iOS:** Uninstall like any other app -- **Desktop:** Delete the app from Applications/Programs folder - ## Troubleshooting -### Server Issues +### Server Won't Start **Port already in use:** + ```bash -# Change port in docker-compose.yml or .env -PORT=3002 +# Find and kill process using port 3001 +lsof -ti:3001 | xargs kill -9 # Mac/Linux +netstat -ano | findstr :3001 # Windows ``` -**Server won't start:** +**Check logs:** + ```bash -# Check logs -docker logs desterlib-api +cd ~/.desterlib # or your install directory +docker-compose logs -f +``` -# Restart containers -docker-compose restart +**Full reset:** -# Full reset +```bash +cd ~/.desterlib docker-compose down -v docker-compose up -d ``` -**Database connection failed:** -```bash -# Check if database is running -docker ps | grep postgres +### Can't Connect from Client -# Restart database -docker-compose restart db -``` +**Checklist:** -### Client Connection Issues +1. Server running? → `docker ps | grep desterlib` +2. Test connection → `curl http://SERVER_IP:3001/health` +3. Find server IP: + - macOS/Linux: `ifconfig | grep "inet "` + - Windows: `ipconfig` +4. Check firewall → Allow port 3001 +5. Use IP not hostname → `192.168.1.100:3001` not `my-computer:3001` -**Can't connect to server:** -- Verify server is running: `docker ps` -- Test connection: `curl http://YOUR_IP:3001/health` -- Check firewall settings -- Ensure both devices are on same network -- Try using server's IP instead of hostname +### Movies Not Showing -**Movies not showing:** -- Scan your library from the Flutter app (Settings → Library Management → Scan Library) -- Check media folder is mounted in `docker-compose.yml` -- Verify file naming (e.g., `Movie Name (2023).mp4`) -- Check scan status in the app or API logs for errors +**Steps:** -**Slow streaming:** -- Check network bandwidth -- Reduce video quality in app settings -- Consider transcoding large files -- Ensure server has adequate resources +1. Scan library → Settings → Library Management → Scan Library +2. Check media mounted → Verify path in `~/.desterlib/docker-compose.yml` +3. File naming → Use `Movie Name (2023).mp4` format +4. Check logs → `docker-compose logs -f api` for errors +5. Verify TMDB key → Set in app Settings if not already configured ### Build Issues (Development) ```bash -# Clear caches -pnpm clean -rm -rf node_modules -pnpm install +# API +pnpm clean && rm -rf node_modules && pnpm install -# Flutter issues -cd desterlib-flutter -flutter clean -flutter pub get +# Flutter +flutter clean && flutter pub get ``` -### Permission Issues - -**Docker permission denied:** -```bash -# Add user to docker group -sudo usermod -aG docker $USER - -# Log out and back in, or: -newgrp docker -``` - -## Version Information - -### Current Limitation -āš ļø **Known Issue**: The app version shown in Settings currently doesn't match the release tag. For example, a `v1.0.0` release might still show `0.1.1` internally. This will be fixed in future releases. Check the release page or filename for the actual version. - -### Checking Your Version -- **Release filename**: Check the downloaded file name (e.g., `Dester-v1.0.0-alpha.1-Android-arm64.apk`) -- **GitHub Release page**: [View all releases](https://github.com/DesterLib/Dester-Flutter/releases) -- **In-app** (currently shows dev version): Settings → About - -## Next Steps - -- šŸŽ¬ Start watching your media! -- šŸ“– Explore the [API Documentation](http://localhost:3001/api/docs) -- šŸ› ļø [Project Structure](/development/structure/) if you want to contribute -- šŸ”„ [Versioning Guide](/development/versioning/) for contribution guidelines -- ā“ [Quick Start](/getting-started/quick-start/) for a faster overview +## Need Help? +- šŸ“– [Quick Start](/getting-started/quick-start/) - 5-minute setup guide +- šŸ”§ [API Documentation](http://localhost:3001/api/docs) - Full API reference +- šŸ’¬ [GitHub Discussions](https://github.com/DesterLib/desterlib/discussions) - Ask questions +- šŸ› [Report Issues](https://github.com/DesterLib/desterlib/issues) - Bug reports diff --git a/apps/docs/src/content/docs/getting-started/quick-start.md b/apps/docs/src/content/docs/getting-started/quick-start.md index 64698da..67319a8 100644 --- a/apps/docs/src/content/docs/getting-started/quick-start.md +++ b/apps/docs/src/content/docs/getting-started/quick-start.md @@ -1,137 +1,70 @@ --- title: Quick Start -description: Get DesterLib up and running in minutes +description: Get DesterLib running in 5 minutes --- -Get DesterLib streaming your media in just a few minutes! +Get DesterLib streaming in 5 minutes! ⚔ -## Prerequisites +## What You Need -- **Docker** installed ([Get Docker](https://www.docker.com/products/docker-desktop)) -- A folder with your movies/TV shows -- Your phone/tablet/computer to watch on +- 🐳 Docker installed ([Get Docker](https://www.docker.com/products/docker-desktop)) +- šŸ“ A folder with your media files +- šŸ“± A device to watch on -## Setup in 2 Steps šŸš€ +## Two Simple Steps -### Step 1: Start the Server +### 1. Install the Server -```bash -# Clone the repository -git clone https://github.com/DesterLib/desterlib.git -cd desterlib +Run this one command: -# Start everything with Docker -docker-compose up -d +```bash +npx @desterlib/cli ``` -That's it! Docker will: -- 🐘 Start a PostgreSQL database -- šŸ”§ Build and run the API -- šŸ“ Set up your media server - -The server will be running at `http://localhost:3001` - -### Step 2: Connect with Flutter App - -1. **Download the Flutter app** on your device: - - šŸ“„ **[Download Latest Alpha Build](https://github.com/DesterLib/Dester-Flutter/releases/latest)** - - āš ļø **Alpha Release**: DesterLib is currently in alpha. Expect bugs and frequent updates! - - Choose your platform: - - **Android (Phone/Tablet)**: `Dester-*-Android-arm64-v8a.apk` - - **Android TV**: `Dester-*-AndroidTV-arm64.apk` - - **macOS**: `Dester-*-macOS.dmg` or `.zip` - - **Linux**: `Dester-*-Linux-x64.tar.gz` - - **Windows**: `Dester-*-Windows-x64.zip` - - **iOS**: Build from source (App Store coming soon) - - Or [build from source](https://github.com/DesterLib/Dester-Flutter#readme) if you prefer - -2. **Configure the app**: - - Open the app - - Enter your server address: `http://YOUR_IP:3001` - - For same device: `http://localhost:3001` - - For network: `http://192.168.1.XXX:3001` (your server's IP) - - The app will connect to your server - -3. **Scan your media library:** - - Go to Settings in the app - - Navigate to Library Management - - Tap "Scan Library" to index your media files - - Wait for the scan to complete - -4. **Start watching!** šŸŽ‰ - - Browse your movies and TV shows - - Tap to play - - Enjoy your personal streaming service - -## Access Points - -- **Server API**: `http://localhost:3001` -- **API Documentation**: `http://localhost:3001/api/docs` -- **Health Check**: `http://localhost:3001/health` +The wizard will ask you 3 quick questions: -## For Developers +- šŸ“š Where are your media files? +- šŸ”Œ What port to use? (default: 3001) +- šŸ”’ Database password -If you want to develop and contribute, see the [Development Setup](#development-setup) below. +That's it! Your server will be running at `http://localhost:3001` -### Development Setup +:::tip[What just happened?] +The CLI downloaded everything needed and started your media server in `~/.desterlib/` +::: -This setup runs only the database in Docker, while you run the API with pnpm for fast development: +### 2. Install the Client App -```bash -# Start test database -docker-compose -f docker-compose.test.yml up -d - -# Install dependencies -pnpm install - -# Configure environment (.env in apps/api/) -DATABASE_URL=postgresql://postgres:postgres@localhost:5433/desterlib_test -NODE_ENV=development -PORT=3001 - -# Run the API -cd apps/api -pnpm dev -``` +**Download for your device:** -Access at `http://localhost:3001` +šŸ“„ [Get the Latest Release](https://github.com/DesterLib/Dester-Flutter/releases/latest) -## Troubleshooting +- Android: `Dester-*-Android-arm64-v8a.apk` +- macOS: `Dester-*-macOS.dmg` +- Windows/Linux: Check the releases page -### Can't connect from mobile app +**Then:** -- Make sure the server is running: `docker ps` -- Use your server's IP address, not `localhost` (unless on same device) -- Check your firewall allows connections on port 3001 -- Ensure both devices are on the same network +1. Open the app +2. Enter `http://YOUR_SERVER_IP:3001` +3. Go to Settings → Scan Library +4. Start watching! šŸŽ‰ -### Movies aren't showing up +:::note[Finding Your Server IP] -- Make sure you've scanned your library from the Flutter app (Settings → Library Management → Scan Library) -- Check that your media folder is properly mounted in `docker-compose.yml` -- Verify file naming follows standard formats (e.g., `Movie Name (2023).mp4`) -- Check scan status in the app +- Same device: Use `http://localhost:3001` +- Different device: Find your IP with `ifconfig` (Mac/Linux) or `ipconfig` (Windows) + ::: -### Server won't start +## That's It! -- Check if port 3001 is already in use -- Verify Docker is running: `docker ps` -- Check logs: `docker logs desterlib-api` +You're done! Browse your movies and TV shows. -### Reset everything - -```bash -docker-compose down -v -docker-compose up -d -``` - -## Next Steps +--- -- [Full Installation Guide](/getting-started/installation/) for detailed setup and mobile app builds -- [API Documentation](http://localhost:3001/api/docs) to explore all endpoints -- [Project Structure](/development/structure/) if you want to contribute +**Need more help?** +- šŸ“– [Full Installation Guide](/getting-started/installation/) - Detailed setup, troubleshooting, and client builds +- šŸ”§ [Managing Your Server](/getting-started/installation/#managing-your-server) - Start, stop, update commands +- šŸ› [Troubleshooting](/getting-started/installation/#troubleshooting) - Common issues and fixes +- šŸ’» [Contributing](/development/contributing/) - Want to help build DesterLib? diff --git a/apps/docs/src/content/docs/guides/backup-restore.md b/apps/docs/src/content/docs/guides/backup-restore.md new file mode 100644 index 0000000..d1a9473 --- /dev/null +++ b/apps/docs/src/content/docs/guides/backup-restore.md @@ -0,0 +1,306 @@ +--- +title: Backup & Restore +description: How to backup and restore your DesterLib database and configuration +--- + +Protect your media library data with regular backups. + +## What to Backup + +### Database (Critical) + +Your PostgreSQL database contains: + +- Media library catalog +- Scan job history +- Settings and preferences +- Future: Watch progress, user data + +**Size:** Usually < 100MB for large libraries + +### Configuration Files (Important) + +Your configuration includes: + +- `docker-compose.yml` - Service definitions +- `.env` - Environment variables + +**Size:** < 1KB + +### Not Needed + +You don't need to backup: + +- Docker images (can be re-downloaded) +- Source code (if using CLI) +- Actual media files (just metadata is in database) + +## Database Backup + +### Manual Backup + +Create a SQL dump of your database: + +```bash +docker exec -t desterlib-postgres pg_dump -U desterlib desterlib > desterlib-backup-$(date +%Y%m%d).sql +``` + +This creates a file like: `desterlib-backup-20241113.sql` + +### Automated Backups + +**Create a backup script** (`backup-desterlib.sh`): + +```bash +#!/bin/bash +BACKUP_DIR=~/desterlib-backups +mkdir -p $BACKUP_DIR + +# Database backup +docker exec -t desterlib-postgres pg_dump -U desterlib desterlib > \ + $BACKUP_DIR/db-$(date +%Y%m%d-%H%M%S).sql + +# Keep only last 7 days +find $BACKUP_DIR -name "db-*.sql" -mtime +7 -delete + +echo "Backup completed: $BACKUP_DIR" +``` + +**Make executable and run:** + +```bash +chmod +x backup-desterlib.sh +./backup-desterlib.sh +``` + +**Schedule with cron** (Linux/Mac): + +```bash +# Run daily at 2 AM +crontab -e + +# Add this line: +0 2 * * * /path/to/backup-desterlib.sh +``` + +## Configuration Backup + +### CLI Installation + +```bash +# Backup entire config directory +cp -r ~/.desterlib ~/desterlib-config-backup-$(date +%Y%m%d) +``` + +### Git Installation + +```bash +# Configuration is in your git repo +git stash # Save any local changes +tar -czf desterlib-config-$(date +%Y%m%d).tar.gz \ + desterlib/apps/api/.env \ + desterlib/docker-compose.yml +``` + +## Restore Database + +### From SQL Dump + +```bash +# Stop the server first +cd ~/.desterlib +docker-compose down + +# Start database only +docker-compose up -d postgres + +# Wait for database to be ready (10 seconds) +sleep 10 + +# Restore from backup +cat desterlib-backup-20241113.sql | \ + docker exec -i desterlib-postgres psql -U desterlib desterlib + +# Start all services +docker-compose up -d +``` + +### Verify Restore + +```bash +# Check media count +docker exec -it desterlib-postgres psql -U desterlib -d desterlib \ + -c "SELECT COUNT(*) FROM \"Media\";" + +# Check last updated +docker exec -it desterlib-postgres psql -U desterlib -d desterlib \ + -c "SELECT title, \"updatedAt\" FROM \"Media\" ORDER BY \"updatedAt\" DESC LIMIT 5;" +``` + +## Restore Configuration + +### CLI Installation + +```bash +# Stop server +cd ~/.desterlib && docker-compose down + +# Restore from backup +rm -rf ~/.desterlib +cp -r ~/desterlib-config-backup-20241113 ~/.desterlib + +# Start server +cd ~/.desterlib && docker-compose up -d +``` + +### Git Installation + +```bash +# Extract backup +tar -xzf desterlib-config-20241113.tar.gz + +# Or restore from git +git checkout apps/api/.env +git checkout docker-compose.yml +``` + +## Migrating to New Server + +### Export from Old Server + +```bash +# 1. Backup database +docker exec -t desterlib-postgres pg_dump -U desterlib desterlib > migration.sql + +# 2. Backup configuration +cp ~/.desterlib/.env desterlib-env-backup +cp ~/.desterlib/docker-compose.yml desterlib-compose-backup + +# 3. Transfer files to new server +scp migration.sql user@new-server:/tmp/ +scp desterlib-*-backup user@new-server:/tmp/ +``` + +### Import on New Server + +```bash +# 1. Install DesterLib on new server +npx @desterlib/cli + +# 2. Stop the new server +cd ~/.desterlib && docker-compose down + +# 3. Restore database +docker-compose up -d postgres +sleep 10 +cat /tmp/migration.sql | docker exec -i desterlib-postgres psql -U desterlib desterlib + +# 4. Restore config if needed +cp /tmp/desterlib-env-backup ~/.desterlib/.env + +# 5. Start all services +docker-compose up -d +``` + +## Disaster Recovery + +### Complete Loss Scenario + +If you lose everything but have backups: + +```bash +# 1. Install DesterLib fresh +npx @desterlib/cli + +# 2. Stop and restore database +cd ~/.desterlib +docker-compose down +docker-compose up -d postgres +sleep 10 +cat backup.sql | docker exec -i desterlib-postgres psql -U desterlib desterlib +docker-compose up -d + +# 3. Your media library is restored! +``` + +### Partial Data Loss + +If only some data is corrupted: + +```bash +# Rescan your media library +# Settings → Library Management → Scan Library +# TMDB metadata will be re-fetched +``` + +## Backup Storage + +### Where to Store Backups + +**Local (Quick Access):** + +- Same machine: `~/desterlib-backups/` +- External drive: `/Volumes/Backup/desterlib/` + +**Remote (Safer):** + +- Cloud storage (Dropbox, Google Drive, iCloud) +- NAS (Synology, QNAP) +- Another server (rsync, scp) + +### Rotation Strategy + +**Keep:** + +- āœ… Daily backups for last 7 days +- āœ… Weekly backups for last month +- āœ… Monthly backups for last year + +**Example cleanup:** + +```bash +# Keep daily (last 7 days) +find ~/desterlib-backups -name "db-*.sql" -mtime +7 -delete + +# Archive weekly (older than 7 days, keep if Sunday) +# Archive monthly (first of month) +``` + +## Testing Backups + +### Regular Testing + +Test your backups monthly: + +```bash +# 1. Create test environment +mkdir ~/desterlib-test +cd ~/desterlib-test + +# 2. Copy config +cp -r ~/.desterlib/* . + +# 3. Edit docker-compose.yml +# Change container names (desterlib-test-*) +# Change ports (3002, 5433) + +# 4. Restore database +docker-compose up -d postgres +sleep 10 +cat ~/desterlib-backups/latest.sql | docker exec -i desterlib-test-postgres psql -U desterlib desterlib + +# 5. Start and test +docker-compose up -d +curl http://localhost:3002/health + +# 6. Cleanup +docker-compose down -v +cd ~ && rm -rf desterlib-test +``` + +## Related Documentation + +- [Managing Server](/guides/managing-server/) - Server management +- [Updating](/guides/updating/) - Update procedures +- [Troubleshooting](/getting-started/installation/#troubleshooting) - Common issues +- [Installation Guide](/getting-started/installation/) - Initial setup diff --git a/apps/docs/src/content/docs/guides/example.md b/apps/docs/src/content/docs/guides/example.md deleted file mode 100644 index ebd0f3b..0000000 --- a/apps/docs/src/content/docs/guides/example.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -title: Example Guide -description: A guide in my new Starlight docs site. ---- - -Guides lead a user through a specific task they want to accomplish, often with a sequence of steps. -Writing a good guide requires thinking about what your users are trying to do. - -## Further reading - -- Read [about how-to guides](https://diataxis.fr/how-to-guides/) in the DiĆ”taxis framework diff --git a/apps/docs/src/content/docs/guides/managing-server.md b/apps/docs/src/content/docs/guides/managing-server.md new file mode 100644 index 0000000..fd085f9 --- /dev/null +++ b/apps/docs/src/content/docs/guides/managing-server.md @@ -0,0 +1,332 @@ +--- +title: Managing Your Server +description: How to start, stop, update, and maintain your DesterLib server +--- + +This guide covers common server management tasks for DesterLib. + +## Installation Location + +Commands vary based on how you installed DesterLib: + +| Installation Method | Location | +| -------------------------- | -------------------------------- | +| CLI (`npx @desterlib/cli`) | `~/.desterlib` | +| Git clone | `./desterlib` (where you cloned) | + +:::tip +This guide assumes **CLI installation**. If you used git clone, replace `~/.desterlib` with your repo directory. +::: + +## Starting the Server + +### First Time Start + +During CLI setup, you're asked if you want to start the server. If you chose "No": + +```bash +cd ~/.desterlib +docker-compose up -d +``` + +### After Stopping + +```bash +cd ~/.desterlib +docker-compose up -d +``` + +The `-d` flag runs containers in the background (detached mode). + +## Stopping the Server + +### Temporary Stop (Keeps Data) + +```bash +cd ~/.desterlib +docker-compose down +``` + +This stops containers but preserves: + +- āœ… Database data +- āœ… Configuration files +- āœ… Downloaded images + +### Full Stop (Removes Everything) + +```bash +cd ~/.desterlib +docker-compose down -v +``` + +:::danger[Data Loss Warning] +The `-v` flag removes Docker volumes, **deleting your database**! Only use if you want to completely reset. +::: + +## Viewing Status + +### Check Running Containers + +```bash +cd ~/.desterlib +docker-compose ps +``` + +Expected output: + +``` +NAME STATUS PORTS +desterlib-postgres Up 5 minutes 0.0.0.0:5432->5432/tcp +desterlib-api Up 5 minutes 0.0.0.0:3001->3001/tcp +``` + +### View Logs + +**All services:** + +```bash +docker-compose logs -f +``` + +**API only:** + +```bash +docker-compose logs -f api +``` + +**Database only:** + +```bash +docker-compose logs -f postgres +``` + +**Last 100 lines:** + +```bash +docker-compose logs --tail=100 api +``` + +Press `Ctrl+C` to stop following logs. + +## Restarting the Server + +### Restart All Services + +```bash +cd ~/.desterlib +docker-compose restart +``` + +### Restart Specific Service + +```bash +docker-compose restart api # API only +docker-compose restart postgres # Database only +``` + +## Updating DesterLib + +### CLI Installation + +```bash +cd ~/.desterlib + +# Pull latest Docker images +docker-compose pull + +# Restart with new images +docker-compose up -d +``` + +### Git Installation + +```bash +cd desterlib + +# Pull latest code +git pull origin main + +# Rebuild and restart +docker-compose up -d --build +``` + +## Configuration Changes + +### Edit Configuration + +**CLI installation:** + +```bash +cd ~/.desterlib +nano .env # or use your preferred editor +``` + +**After editing**, restart to apply: + +```bash +docker-compose restart +``` + +### Reconfigure via CLI + +Run the CLI again to regenerate config: + +```bash +npx @desterlib/cli +``` + +Choose "Reconfigure" when prompted. + +## Backup & Restore + +### Backup Database + +```bash +docker exec -t desterlib-postgres pg_dump -U desterlib desterlib > desterlib-backup.sql +``` + +This creates a SQL dump file with all your data. + +### Restore Database + +```bash +cat desterlib-backup.sql | docker exec -i desterlib-postgres psql -U desterlib desterlib +``` + +### Backup Configuration + +```bash +# CLI installation +cp -r ~/.desterlib ~/desterlib-config-backup + +# Git installation +tar -czf desterlib-config.tar.gz desterlib/ +``` + +## Resource Usage + +### View Resource Consumption + +```bash +docker stats desterlib-api desterlib-postgres +``` + +Shows CPU, memory, and network usage in real-time. + +### Limit Resources + +Edit `docker-compose.yml` to add resource limits: + +```yaml +api: + image: desterlib/api:latest + deploy: + resources: + limits: + cpus: "2.0" + memory: 2G +``` + +Then restart: `docker-compose up -d` + +## Network Configuration + +### Change Port + +**Edit** `.env`: + +```env +PORT=3002 # Change from 3001 +``` + +**Restart:** + +```bash +docker-compose down +docker-compose up -d +``` + +### Access from Network + +The API is already configured to accept connections from your local network (LAN). Just use your server's IP: + +``` +http://192.168.1.100:3001 +``` + +Find your IP: + +- **macOS/Linux:** `ifconfig | grep "inet "` +- **Windows:** `ipconfig` + +## Monitoring + +### Health Check + +```bash +curl http://localhost:3001/health +``` + +Expected response: + +```json +{ + "status": "OK", + "timestamp": "2024-01-01T00:00:00.000Z", + "uptime": 12345 +} +``` + +### View Database Status + +```bash +docker exec -it desterlib-postgres psql -U desterlib -d desterlib -c "SELECT COUNT(*) FROM \"Media\";" +``` + +## Troubleshooting + +### Container Won't Start + +**Check what failed:** + +```bash +docker-compose logs api +docker-compose logs postgres +``` + +**Common issues:** + +- Port already in use → Change port in `.env` +- Database connection failed → Restart postgres first +- Permission denied → Check media path permissions + +### Database Connection Issues + +**Test database connection:** + +```bash +docker exec -it desterlib-postgres psql -U desterlib -d desterlib +``` + +Should open PostgreSQL prompt. Type `\q` to exit. + +### High CPU/Memory Usage + +**During scan:** + +- This is normal - scanning is resource-intensive +- CPU usage is high when processing metadata +- Will return to normal after scan completes + +**Constantly high:** + +- Check logs for errors or infinite loops +- Restart the API: `docker-compose restart api` + +## Related Documentation + +- [CLI Tool](/cli/overview/) - CLI usage and options +- [Updating Guide](/guides/updating/) - Update procedures +- [Backup Guide](/guides/backup-restore/) - Comprehensive backup strategies +- [Troubleshooting](/getting-started/installation/#troubleshooting) - Common issues diff --git a/apps/docs/src/content/docs/guides/remote-access.md b/apps/docs/src/content/docs/guides/remote-access.md new file mode 100644 index 0000000..3758625 --- /dev/null +++ b/apps/docs/src/content/docs/guides/remote-access.md @@ -0,0 +1,331 @@ +--- +title: Remote Access +description: Access your DesterLib server from outside your home network +--- + +Access your media library from anywhere! This guide covers secure remote access options. + +## Quick Options + +| Method | Difficulty | Security | Best For | +| ---------------------- | ------------- | ---------------------- | -------------- | +| Tailscale | ⭐ Easy | šŸ”’ Excellent | Most users | +| Cloudflare Tunnel | ⭐⭐ Moderate | šŸ”’ Excellent | Advanced users | +| Port Forwarding + DDNS | ⭐⭐⭐ Hard | āš ļø Manual setup needed | Self-hosters | +| ngrok | ⭐ Easy | āš ļø Temporary | Testing only | + +## Option 1: Tailscale (Recommended) + +**Best for:** Everyone - it's free, secure, and easy! + +### What is Tailscale? + +Tailscale creates a secure VPN between your devices. No port forwarding needed! + +### Setup Steps + +1. **Install Tailscale:** + - [Download for your OS](https://tailscale.com/download) + - Install on server and all client devices + +2. **Sign up and connect:** + - Create a free account + - Log in on each device + - They're now on the same network! + +3. **Find your server IP:** + + ```bash + tailscale ip -4 + ``` + + Example: `100.64.123.45` + +4. **Connect from client:** + - Use the Tailscale IP in your DesterLib app + - Example: `http://100.64.123.45:3001` + +**That's it!** Access from anywhere, completely secure. + +### Benefits + +- āœ… No port forwarding +- āœ… End-to-end encrypted +- āœ… Works behind NAT/CGNAT +- āœ… Free for personal use +- āœ… Multi-platform support + +## Option 2: Cloudflare Tunnel + +**Best for:** Users who want a public URL without exposing their home IP. + +### Setup Steps + +1. **Install cloudflared:** + + ```bash + # macOS + brew install cloudflare/cloudflare/cloudflared + + # Linux + wget -q https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64.deb + sudo dpkg -i cloudflared-linux-amd64.deb + + # Windows - download from Cloudflare + ``` + +2. **Authenticate:** + + ```bash + cloudflared tunnel login + ``` + +3. **Create tunnel:** + + ```bash + cloudflared tunnel create desterlib + ``` + +4. **Configure tunnel** (`~/.cloudflared/config.yml`): + + ```yaml + tunnel: + credentials-file: /path/to/credentials.json + + ingress: + - hostname: desterlib.yourdomain.com + service: http://localhost:3001 + - service: http_status:404 + ``` + +5. **Run tunnel:** + + ```bash + cloudflared tunnel run desterlib + ``` + +6. **Access via:** + `https://desterlib.yourdomain.com` + +### Benefits + +- āœ… No port forwarding +- āœ… Automatic HTTPS +- āœ… Cloudflare protection +- āœ… Custom domain +- āœ… Free tier available + +## Option 3: Port Forwarding + DDNS + +**Best for:** Self-hosters comfortable with networking. + +### Requirements + +- Router with port forwarding capability +- Dynamic DNS service (or static IP) + +### Steps + +1. **Configure port forwarding on router:** + - Forward external port 3001 → internal port 3001 + - Target your server's local IP (e.g., 192.168.1.100) + +2. **Set up Dynamic DNS:** + - Use [DuckDNS](https://www.duckdns.org/) (free) + - Or [No-IP](https://www.noip.com/) + - Create hostname like: `mydesterlib.duckdns.org` + +3. **Update DDNS automatically:** + + ```bash + # Example DuckDNS update script + curl "https://www.duckdns.org/update?domains=mydesterlib&token=YOUR_TOKEN" + ``` + +4. **Access via:** + `http://mydesterlib.duckdns.org:3001` + +### Security Considerations + +:::danger[Security Warning] +Port forwarding exposes your server to the internet. You MUST: + +- Use strong database passwords +- Enable authentication (when available) +- Consider using HTTPS (see below) +- Keep DesterLib updated + ::: + +### Add HTTPS (Recommended) + +Use a reverse proxy with Let's Encrypt: + +```bash +# Install Caddy (automatic HTTPS) +sudo apt install caddy + +# Configure Caddy +sudo nano /etc/caddy/Caddyfile +``` + +``` +mydesterlib.duckdns.org { + reverse_proxy localhost:3001 +} +``` + +```bash +# Start Caddy +sudo systemctl start caddy +``` + +Now access via: `https://mydesterlib.duckdns.org` + +## Option 4: ngrok (Testing Only) + +**Best for:** Quick testing, not permanent use. + +### Quick Setup + +```bash +# Install ngrok +brew install ngrok # macOS + +# Authenticate (free account) +ngrok authtoken YOUR_TOKEN + +# Create tunnel +ngrok http 3001 +``` + +You'll get a URL like: `https://abc123.ngrok.io` + +:::caution[Temporary] + +- URL changes every time you restart +- Free tier has session limits +- Not suitable for permanent use + ::: + +## Comparing Options + +### Speed + +| Method | Latency | Throughput | +| ----------------- | --------------- | ------------------------------- | +| Tailscale | ~20-50ms added | Excellent (direct peer-to-peer) | +| Cloudflare Tunnel | ~50-100ms added | Good (proxied through CF) | +| Port Forward | ~0ms added | Excellent (direct) | +| ngrok | ~50-150ms added | Moderate (free tier) | + +### Security + +**Most Secure to Least:** + +1. Tailscale (encrypted VPN) +2. Cloudflare Tunnel (proxied + HTTPS) +3. Port Forward + HTTPS + Auth +4. Port Forward (HTTP only) āš ļø +5. ngrok (temporary URLs) + +## Firewall Configuration + +### Allow DesterLib Through Firewall + +**macOS:** + +```bash +# Docker Desktop usually handles this +# If issues, add rule in System Preferences → Security & Privacy → Firewall +``` + +**Linux (ufw):** + +```bash +sudo ufw allow 3001/tcp +sudo ufw reload +``` + +**Windows Firewall:** + +1. Windows Security → Firewall & network protection +2. Advanced settings → Inbound Rules +3. New Rule → Port → TCP 3001 → Allow + +## Testing Remote Access + +### From Client Device + +```bash +# Test from outside your network +curl http://YOUR_PUBLIC_URL:3001/health +``` + +Expected response: + +```json +{ "status": "OK", "timestamp": "...", "uptime": 12345 } +``` + +### From Browser + +Visit: `http://YOUR_PUBLIC_URL:3001/api/docs` + +Should show Swagger documentation. + +## Mobile Data Usage + +When streaming remotely: + +- **HD movie (2 hours)**: ~2-4GB +- **SD movie (2 hours)**: ~0.5-1GB +- **4K movie (2 hours)**: ~10-20GB + +Consider your mobile data plan when streaming! + +## Troubleshooting + +### Can't Connect Remotely + +**Checklist:** + +1. āœ… Server running? → `docker ps` +2. āœ… Port forwarded correctly? → Check router settings +3. āœ… Firewall allows port? → Test with `telnet YOUR_IP 3001` +4. āœ… DDNS updated? → Check your DDNS provider +5. āœ… Using correct IP/domain? → Test from inside network first + +### Works Locally, Not Remotely + +**Likely causes:** + +- Router not forwarding port → Check port forwarding rules +- ISP blocks port 3001 → Try different port (8080, 8443) +- CGNAT (Carrier-Grade NAT) → Use Tailscale or Cloudflare Tunnel +- Firewall blocking → Temporarily disable to test + +### Slow Streaming + +**Fixes:** + +- Check upload speed on server → `speedtest-cli` +- Reduce video quality in client app +- Consider transcoding (future feature) +- Use wired connection on server + +## Security Best Practices + +When exposing to internet: + +1. āœ… **Use HTTPS** - Encrypt traffic +2. āœ… **Enable authentication** - When available in DesterLib +3. āœ… **Strong passwords** - For database and future auth +4. āœ… **Keep updated** - Install updates regularly +5. āœ… **Monitor logs** - Watch for suspicious activity +6. āœ… **Use VPN** - Tailscale is recommended over public exposure + +## Related Documentation + +- [Managing Server](/guides/managing-server/) - Server management +- [Installation Guide](/getting-started/installation/) - Initial setup +- [Troubleshooting](/getting-started/installation/#troubleshooting) - Common issues diff --git a/apps/docs/src/content/docs/guides/tmdb-setup.md b/apps/docs/src/content/docs/guides/tmdb-setup.md new file mode 100644 index 0000000..d7da49f --- /dev/null +++ b/apps/docs/src/content/docs/guides/tmdb-setup.md @@ -0,0 +1,140 @@ +--- +title: TMDB Setup +description: How to get and configure your TMDB API key +--- + +TMDB (The Movie Database) provides metadata and artwork for your media library. A TMDB API key is required for DesterLib to fetch movie and TV show information. + +## Getting Your TMDB API Key + +### 1. Create a TMDB Account + +1. Go to [themoviedb.org](https://www.themoviedb.org/) +2. Click **"Join TMDB"** in the top right +3. Fill out the registration form +4. Verify your email address + +### 2. Request an API Key + +1. Log in to your TMDB account +2. Go to **Settings** → **API** +3. Click **"Request an API Key"** +4. Choose **"Developer"** (not commercial) +5. Accept the terms of use +6. Fill out the application form: + - **Application URL**: Can use `http://localhost` or your personal site + - **Application Summary**: "Personal media server for organizing my collection" +7. Submit the request + +Your API key will be generated instantly! + +### 3. Copy Your API Key + +You'll see two keys: + +- **API Key (v3 auth)** - This is what you need āœ… +- **API Read Access Token** - Not needed + +Copy the **API Key (v3 auth)** - it looks like: `a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6` + +## Configuring in DesterLib + +### During CLI Setup + +The CLI doesn't ask for TMDB during initial setup. You'll configure it in the app after installation. + +### In the Mobile/Desktop App + +1. Open the DesterLib app +2. Go to **Settings** +3. Navigate to **TMDB Integration** or **Settings** +4. Enter your API key +5. Save + +The app will now fetch metadata for your media! + +### Via API (Advanced) + +You can also set it via the settings API: + +```bash +curl -X PUT http://localhost:3001/api/v1/settings \ + -H "Content-Type: application/json" \ + -d '{ + "key": "core.tmdb.apiKey", + "value": "your_api_key_here" + }' +``` + +## What TMDB Is Used For + +Once configured, DesterLib uses TMDB to fetch: + +- šŸŽ¬ **Movie/Show Titles** - Proper names and translations +- šŸ“ **Descriptions** - Plot summaries and overviews +- šŸŽØ **Poster Images** - Cover art for your media +- šŸ–¼ļø **Backdrop Images** - Background images +- ⭐ **Ratings** - TMDB community ratings +- šŸ“… **Release Dates** - When movies/shows were released +- šŸŽ­ **Genres** - Categories and classifications +- šŸ‘„ **Cast & Crew** - Actor and director information (coming soon) + +## Scanning Without TMDB + +If you haven't configured TMDB yet: + +- āœ… DesterLib will still scan your files +- āœ… Files will be added to the database +- āŒ Metadata won't be fetched +- āŒ No posters or artwork + +You can configure TMDB later and rescan to fetch metadata. + +## TMDB API Limits + +TMDB's free API has rate limits: + +- **40 requests per 10 seconds** +- DesterLib automatically handles rate limiting +- Large libraries may take longer to scan due to this + +## Privacy + +- TMDB API key is stored securely in your database +- Only your server communicates with TMDB +- No data is sent to DesterLib developers +- Your API key never leaves your server + +## Troubleshooting + +### Invalid API Key Error + +If you see "Invalid TMDB API key": + +1. Verify you copied the **v3 API Key** (not the Read Access Token) +2. Check for extra spaces or characters +3. Make sure the key is active in your TMDB account + +### Rate Limit Errors + +If scans are slow or failing: + +- This is normal for large libraries +- TMDB limits: 40 requests/10 seconds +- DesterLib automatically retries failed requests +- Just wait - scans will complete eventually + +### No Metadata Fetched + +If scans complete but metadata is missing: + +1. Verify TMDB key is configured +2. Check API logs for errors: `docker-compose logs -f api` +3. Try manually fetching for one item +4. Ensure file names are recognizable (e.g., `The Matrix (1999).mkv`) + +## Related Documentation + +- [Installation Guide](/getting-started/installation/) - Set up DesterLib +- [Library Scanning](/guides/scanning/) - How scanning works +- [Settings API](/api/overview/#api-endpoints) - Configure via API diff --git a/apps/docs/src/content/docs/guides/updating.md b/apps/docs/src/content/docs/guides/updating.md new file mode 100644 index 0000000..827e0f0 --- /dev/null +++ b/apps/docs/src/content/docs/guides/updating.md @@ -0,0 +1,222 @@ +--- +title: Updating DesterLib +description: How to update DesterLib to the latest version +--- + +Keep your DesterLib installation up-to-date with the latest features and bug fixes. + +## Updating the Server + +### CLI Installation (Recommended Method) + +If you installed via `npx @desterlib/cli`: + +```bash +cd ~/.desterlib + +# Pull latest Docker images +docker-compose pull + +# Restart with new version +docker-compose up -d +``` + +**That's it!** The update is complete. + +### Git Installation (Manual Method) + +If you cloned the repository: + +```bash +cd desterlib + +# Pull latest code +git pull origin main + +# Rebuild and restart +docker-compose down +docker-compose up -d --build +``` + +## Updating Client Apps + +### Mobile & Desktop Apps + +**Download new version:** + +1. Go to [Releases](https://github.com/DesterLib/Dester-Flutter/releases/latest) +2. Download for your platform +3. Install (overwrites old version) + +**On mobile:** + +- Android: Install new APK (overwrites automatically) +- iOS: Reinstall from source or TestFlight (when available) + +**On desktop:** + +- Replace the old app with the new one +- Your settings and server connection persist + +## Checking Versions + +### Server Version + +```bash +# Check Docker image version +docker images | grep desterlib + +# Check running container image +docker inspect desterlib-api | grep Image +``` + +### Client Version + +In the app: + +- Go to **Settings → About** +- Check version number + +:::note[Version Mismatch] +The app version display may not match release tags during alpha. This will be fixed in stable releases. +::: + +## Update Frequency + +### Recommended Schedule + +- **Server**: Check weekly during alpha +- **Client**: Check weekly during alpha +- **Stable releases**: Monthly or when needed + +### How to Stay Updated + +Follow updates: + +- ⭐ Star the [GitHub repo](https://github.com/DesterLib/desterlib) +- šŸ‘€ Watch releases on GitHub +- šŸ“¢ Join [GitHub Discussions](https://github.com/DesterLib/desterlib/discussions) + +## Database Migrations + +DesterLib handles database migrations automatically: + +- Schema changes apply on container start +- Uses Prisma's `db push` during startup +- No manual migration needed + +:::tip +Database migrations happen automatically when you update. Your data is preserved! +::: + +## Rollback + +If an update causes issues: + +### Server Rollback + +**CLI installation:** + +```bash +cd ~/.desterlib + +# Pull specific version (if tagged) +docker pull desterlib/api:v1.0.0 + +# Edit docker-compose.yml to use that tag +# Then restart +docker-compose up -d +``` + +**Git installation:** + +```bash +cd desterlib + +# Go back to previous commit/tag +git checkout v1.0.0 # or specific commit + +# Rebuild +docker-compose up -d --build +``` + +### Client Rollback + +Download and install a previous release from the [Releases page](https://github.com/DesterLib/Dester-Flutter/releases). + +## Breaking Changes + +During alpha development, breaking changes may occur. We'll document them in: + +- Release notes on GitHub +- Package-specific changelogs (auto-generated by Changesets): + - [API Changelog](https://github.com/DesterLib/desterlib/blob/main/apps/api/CHANGELOG.md) + - [CLI Changelog](https://github.com/DesterLib/desterlib/blob/main/packages/cli/CHANGELOG.md) + - [Docs Changelog](https://github.com/DesterLib/desterlib/blob/main/apps/docs/CHANGELOG.md) +- [Aggregated Changelog](https://desterlib.github.io/desterlib/changelog) on docs site +- Migration guides when needed + +## Troubleshooting Updates + +### Update Failed + +**Error pulling images:** + +```bash +# Check Docker is running +docker ps + +# Try pulling manually +docker pull desterlib/api:latest +docker pull postgres:15-alpine +``` + +**Database migration failed:** + +```bash +# Check logs +docker-compose logs api + +# If corrupted, restore from backup +cat backup.sql | docker exec -i desterlib-postgres psql -U desterlib desterlib +``` + +### Version Not Changing + +**Verify update:** + +```bash +docker images desterlib/api +# Check if "latest" tag is recent +``` + +**Force update:** + +```bash +docker-compose down +docker-compose pull +docker-compose up -d --force-recreate +``` + +## Best Practices + +### Before Updating + +1. āœ… **Backup your database** - See [Backup Guide](/guides/backup-restore/) +2. āœ… **Read release notes** - Check for breaking changes +3. āœ… **Stop running scans** - Avoid interrupting operations +4. āœ… **Test on non-production first** - If possible + +### After Updating + +1. āœ… **Check logs** - `docker-compose logs -f` +2. āœ… **Test health** - `curl http://localhost:3001/health` +3. āœ… **Verify API docs** - Visit `http://localhost:3001/api/docs` +4. āœ… **Test streaming** - Play a video + +## Related Documentation + +- [Managing Server](/guides/managing-server/) - Server management commands +- [Backup & Restore](/guides/backup-restore/) - Data backup strategies +- [Installation Guide](/getting-started/installation/) - Initial setup +- [Troubleshooting](/getting-started/installation/#troubleshooting) - Common issues diff --git a/apps/docs/src/content/docs/index.mdx b/apps/docs/src/content/docs/index.mdx index 063151c..5b347c9 100644 --- a/apps/docs/src/content/docs/index.mdx +++ b/apps/docs/src/content/docs/index.mdx @@ -4,89 +4,19 @@ description: Your Personal Media Server - Documentation and Guides template: splash hero: title: | - DesterLib - Your Personal Media Server - tagline: Watch your movies and TV shows from anywhere. It's like Netflix, but for YOUR personal collection! + Own Your Stream. + tagline: The simplest way to enjoy what's yours. actions: - text: Get Started link: /getting-started/quick-start/ icon: right-arrow variant: primary - - text: View on GitHub - link: https://github.com/DesterLib/desterlib - icon: external - variant: minimal --- -import { Card, CardGrid } from '@astrojs/starlight/components'; +import FeatureCards from "../../components/FeatureCards.astro"; +import Mockups from "../../components/react/Mockups"; -## What is DesterLib? - -DesterLib is a modern, self-hosted media server system that lets you organize and stream your personal media collection from any device. - - - - Automatically scan, organize, and catalog your movies and TV shows - - - Watch your media with smooth playback on any device - - - Quick installation with Docker or development mode with pnpm - - - Works on Android, iOS, macOS, Linux, and Windows - - - -## Main Features - -- āœ… **Scan & Index** - Automatically finds and organizes your media -- āœ… **Browse Library** - Find movies and shows easily -- āœ… **Smooth Streaming** - Watch without buffering -- āœ… **Multi-Device** - Watch on phone, tablet, or desktop -- āœ… **Track Progress** - Remember where you stopped (coming soon) -- āœ… **Customizable** - Personalize your experience - -## Quick Links - -- [Installation Guide](/getting-started/installation/) - Get up and running -- [Quick Start](/getting-started/quick-start/) - Start using DesterLib -- [API Overview](/api/overview/) - REST API documentation -- [Contributing Guide](/development/contributing/) - Help build DesterLib -- [Project Structure](/development/structure/) - Understand the codebase - -## Project Components - -### Backend API -The engine that does all the work: -- Organizes your media files -- Streams videos to your devices -- Remembers what you're watching -- Manages the database - -### Mobile & Desktop App -The way you watch on your devices: -- Browse your media collection -- Play videos smoothly -- Manage settings -- Cross-platform support - -## Alpha Status - -āš ļø **DesterLib is currently in alpha development**. This means: -- Features are still being developed -- Bugs and issues are expected -- APIs may change -- Documentation may be incomplete -- Frequent updates - -We appreciate your patience and feedback as we build DesterLib! - -## Get Help - -Need assistance? Check out these resources: -- [GitHub Issues](https://github.com/DesterLib/desterlib/issues) - Report bugs or request features -- [GitHub Discussions](https://github.com/DesterLib/desterlib/discussions) - Ask questions and share ideas -- [Contributing Guide](/development/contributing/) - Help us build DesterLib -- [Quick Reference](/development/quick-reference/) - Quick commands and tips +
+ + +
diff --git a/apps/docs/src/content/docs/reference/example.md b/apps/docs/src/content/docs/reference/example.md deleted file mode 100644 index 0224f09..0000000 --- a/apps/docs/src/content/docs/reference/example.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -title: Example Reference -description: A reference page in my new Starlight docs site. ---- - -Reference pages are ideal for outlining how things work in terse and clear terms. -Less concerned with telling a story or addressing a specific use case, they should give a comprehensive outline of what you're documenting. - -## Further reading - -- Read [about reference](https://diataxis.fr/reference/) in the DiĆ”taxis framework diff --git a/apps/docs/src/styles/global.css b/apps/docs/src/styles/global.css new file mode 100644 index 0000000..dbcc69c --- /dev/null +++ b/apps/docs/src/styles/global.css @@ -0,0 +1,9 @@ +@layer base, starlight, theme, components, utilities; + +@import "@astrojs/starlight-tailwind"; +@import "tailwindcss/theme.css" layer(theme); +@import "tailwindcss/utilities.css" layer(utilities); + +.tagline-italic { + @apply italic font-extralight; +} diff --git a/apps/docs/tsconfig.json b/apps/docs/tsconfig.json index 8bf91d3..69c1600 100644 --- a/apps/docs/tsconfig.json +++ b/apps/docs/tsconfig.json @@ -1,5 +1,14 @@ { "extends": "astro/tsconfigs/strict", - "include": [".astro/types.d.ts", "**/*"], - "exclude": ["dist"] -} + "include": [ + ".astro/types.d.ts", + "**/*" + ], + "exclude": [ + "dist" + ], + "compilerOptions": { + "jsx": "react-jsx", + "jsxImportSource": "react" + } +} \ No newline at end of file diff --git a/package.json b/package.json index bb8e637..e5e1e06 100644 --- a/package.json +++ b/package.json @@ -1,22 +1,28 @@ { "name": "desterlib", + "version": "0.1.0", "private": true, "scripts": { "build": "turbo run build", "dev": "turbo run dev", "lint": "turbo run lint", "format": "prettier --write \"**/*.{ts,tsx,md}\"", + "format:check": "prettier --check \"**/*.{ts,tsx,md}\" --ignore-path .gitignore", "check-types": "turbo run check-types", "commit": "TERM=dumb git-cz", "commit:retry": "TERM=dumb git-cz --retry", "prepare": "husky", "changeset": "changeset", - "changeset:add": "changeset add", "changeset:status": "changeset status", - "version": "changeset version && pnpm install --lockfile-only", + "version": "changeset version && pnpm install --lockfile-only && pnpm changelog:sync", + "version:sync": "node scripts/sync-version.js", + "changelog:sync": "node scripts/sync-changelog.js", + "verify:versioning": "node scripts/verify-versioning.js", + "pre-pr": "node scripts/pre-pr-check.js", "release": "pnpm build && changeset publish", "pr:create": "bash scripts/create-pr.sh", - "pr:create:main": "bash scripts/create-pr.sh main" + "pr:create:main": "bash scripts/create-pr.sh main", + "setup:docker": "tsx packages/cli/src/index.ts" }, "devDependencies": { "@changesets/changelog-github": "^0.5.1", @@ -28,6 +34,7 @@ "cz-customizable": "^7.5.1", "husky": "^9.1.7", "prettier": "^3.6.2", + "tsx": "^4.20.6", "turbo": "^2.5.8", "typescript": "5.9.2" }, @@ -39,5 +46,10 @@ "packageManager": "pnpm@9.0.0", "engines": { "node": ">=18" + }, + "pnpm": { + "overrides": { + "lightningcss": "^1.30.2" + } } } diff --git a/packages/cli/.gitignore b/packages/cli/.gitignore new file mode 100644 index 0000000..431b515 --- /dev/null +++ b/packages/cli/.gitignore @@ -0,0 +1,5 @@ +node_modules +dist +*.log +.DS_Store + diff --git a/packages/cli/README.md b/packages/cli/README.md new file mode 100644 index 0000000..a2ef206 --- /dev/null +++ b/packages/cli/README.md @@ -0,0 +1,69 @@ +# @desterlib/cli + +Command-line tool for setting up DesterLib with Docker. + +## Installation + +### Option 1: Quick Install (No Node.js Required) + +**macOS/Linux:** + +```bash +curl -fsSL https://raw.githubusercontent.com/DesterLib/desterlib/main/packages/cli/install.sh | bash +``` + +**Windows (PowerShell):** + +```powershell +iwr -useb https://raw.githubusercontent.com/DesterLib/desterlib/main/packages/cli/install.ps1 | iex +``` + +The installer will automatically: + +- Check for Node.js 18+ +- Install Node.js if needed (via nvm, package manager, or Chocolatey/winget) +- Install the DesterLib CLI globally + +### Option 2: Using npx (No Installation) + +```bash +npx @desterlib/cli +``` + +### Option 3: Install via npm (Requires Node.js) + +```bash +npm install -g @desterlib/cli +``` + +### Option 4: Standalone Binary + +Download pre-built binaries from [GitHub Releases](https://github.com/DesterLib/desterlib/releases) (coming soon). + +Or build from source: + +```bash +cd packages/cli +pnpm install +pnpm build:binary:all +# Binaries will be in dist/bin/ +``` + +## Usage + +```bash +# Run the setup wizard +desterlib + +# Or explicitly +desterlib setup + +# Check for updates +desterlib update-check +``` + +## Documentation + +šŸ“– **[Full CLI Documentation](https://desterlib.github.io/desterlib/cli/overview)** + +For detailed usage, configuration, and more, visit the [documentation site](https://desterlib.github.io/desterlib/cli/overview). diff --git a/packages/cli/install.ps1 b/packages/cli/install.ps1 new file mode 100644 index 0000000..329252f --- /dev/null +++ b/packages/cli/install.ps1 @@ -0,0 +1,139 @@ +# DesterLib CLI Installer for Windows +# This script installs the DesterLib CLI without requiring Node.js to be pre-installed + +$ErrorActionPreference = "Stop" + +# Colors for output +function Write-ColorOutput($ForegroundColor) { + $fc = $host.UI.RawUI.ForegroundColor + $host.UI.RawUI.ForegroundColor = $ForegroundColor + if ($args) { + Write-Output $args + } + $host.UI.RawUI.ForegroundColor = $fc +} + +# Banner +Write-ColorOutput Cyan @" +╔═══════════════════════════════════════════════════════════╗ +ā•‘ ā•‘ +ā•‘ šŸŽ¬ DesterLib CLI Installer ā•‘ +ā•‘ ā•‘ +ā•šā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā• +"@ + +# Function to check if command exists +function Test-CommandExists { + param($Command) + $null -ne (Get-Command $Command -ErrorAction SilentlyContinue) +} + +# Function to check Node.js version +function Test-NodeVersion { + if (Test-CommandExists "node") { + $nodeVersion = (node -v) -replace 'v', '' -split '\.' | Select-Object -First 1 + if ([int]$nodeVersion -ge 18) { + return $true + } + } + return $false +} + +# Function to install Node.js via Chocolatey +function Install-NodeViaChocolatey { + Write-ColorOutput Yellow "šŸ“¦ Installing Node.js via Chocolatey..." + + if (-not (Test-CommandExists "choco")) { + Write-ColorOutput Yellow "Installing Chocolatey..." + Set-ExecutionPolicy Bypass -Scope Process -Force + [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072 + iex ((New-Object System.Net.WebClient).DownloadString('https://community.chocolatey.org/install.ps1')) + } + + choco install nodejs-lts -y + Write-ColorOutput Green "āœ… Node.js installed successfully" + + # Refresh environment variables + $env:Path = [System.Environment]::GetEnvironmentVariable("Path", "Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path", "User") +} + +# Function to install Node.js via winget +function Install-NodeViaWinget { + Write-ColorOutput Yellow "šŸ“¦ Installing Node.js via winget..." + + if (-not (Test-CommandExists "winget")) { + Write-ColorOutput Red "āŒ winget is not available. Please install Node.js manually." + Write-ColorOutput Yellow "Download from: https://nodejs.org/" + exit 1 + } + + winget install OpenJS.NodeJS.LTS + Write-ColorOutput Green "āœ… Node.js installed successfully" + + # Refresh environment variables + $env:Path = [System.Environment]::GetEnvironmentVariable("Path", "Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path", "User") +} + +# Check if Node.js is installed +if (-not (Test-NodeVersion)) { + Write-ColorOutput Yellow "āš ļø Node.js 18+ is required but not found." + Write-Output "" + Write-ColorOutput Cyan "Choose installation method:" + Write-Output " 1) Install via Chocolatey (requires admin)" + Write-Output " 2) Install via winget (Windows 10/11)" + Write-Output " 3) Install manually (exit and install from https://nodejs.org/)" + Write-Output "" + $choice = Read-Host "Enter choice [1-3]" + + switch ($choice) { + "1" { + Install-NodeViaChocolatey + } + "2" { + Install-NodeViaWinget + } + "3" { + Write-ColorOutput Yellow "Please install Node.js 18+ and run this script again." + exit 0 + } + default { + Write-ColorOutput Red "Invalid choice. Exiting." + exit 1 + } + } +} + +# Verify Node.js installation +if (-not (Test-NodeVersion)) { + Write-ColorOutput Red "āŒ Node.js 18+ is still not available." + Write-ColorOutput Yellow "Please restart your terminal and try again, or install Node.js manually." + exit 1 +} + +$nodeVersion = node -v +$npmVersion = npm -v +Write-ColorOutput Green "āœ… Node.js $nodeVersion and npm $npmVersion detected" + +# Install CLI globally +Write-Output "" +Write-ColorOutput Cyan "šŸ“¦ Installing DesterLib CLI..." +npm install -g @desterlib/cli@latest + +# Verify installation +if (Test-CommandExists "desterlib") { + Write-Output "" + Write-ColorOutput Green "āœ… DesterLib CLI installed successfully!" + Write-Output "" + Write-ColorOutput Cyan "You can now run:" + Write-ColorOutput Green " desterlib - Run the setup wizard" + Write-ColorOutput Green " desterlib setup - Run the setup wizard" + Write-Output "" + Write-ColorOutput Cyan "Or use npx (no installation needed):" + Write-ColorOutput Green " npx @desterlib/cli" + Write-Output "" +} else { + Write-ColorOutput Red "āŒ Installation completed but 'desterlib' command not found." + Write-ColorOutput Yellow "Please check your PATH or restart your terminal." + exit 1 +} + diff --git a/packages/cli/install.sh b/packages/cli/install.sh new file mode 100755 index 0000000..cc3ea86 --- /dev/null +++ b/packages/cli/install.sh @@ -0,0 +1,170 @@ +#!/bin/bash + +# DesterLib CLI Installer +# This script installs the DesterLib CLI without requiring Node.js to be pre-installed + +set -e + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +CYAN='\033[0;36m' +NC='\033[0m' # No Color + +# Banner +echo -e "${CYAN}" +echo "╔═══════════════════════════════════════════════════════════╗" +echo "ā•‘ ā•‘" +echo "ā•‘ šŸŽ¬ DesterLib CLI Installer ā•‘" +echo "ā•‘ ā•‘" +echo "ā•šā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•" +echo -e "${NC}" + +# Function to check if command exists +command_exists() { + command -v "$1" >/dev/null 2>&1 +} + +# Function to check Node.js version +check_node_version() { + if command_exists node; then + NODE_VERSION=$(node -v | cut -d'v' -f2 | cut -d'.' -f1) + if [ "$NODE_VERSION" -ge 18 ]; then + return 0 + fi + fi + return 1 +} + +# Function to install Node.js via nvm (recommended) +install_node_via_nvm() { + echo -e "${YELLOW}šŸ“¦ Installing Node.js via nvm...${NC}" + + if [ ! -d "$HOME/.nvm" ]; then + echo -e "${CYAN}Installing nvm...${NC}" + curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.0/install.sh | bash + + # Source nvm + export NVM_DIR="$HOME/.nvm" + [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" + else + # Source nvm if it exists + export NVM_DIR="$HOME/.nvm" + [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" + fi + + # Install latest LTS Node.js + nvm install --lts + nvm use --lts + nvm alias default node + + echo -e "${GREEN}āœ… Node.js installed successfully${NC}" +} + +# Function to install Node.js via package manager +install_node_via_package_manager() { + OS="$(uname -s)" + + case "$OS" in + Linux*) + if command_exists apt-get; then + echo -e "${CYAN}Installing Node.js via apt-get...${NC}" + curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash - + sudo apt-get install -y nodejs + elif command_exists yum; then + echo -e "${CYAN}Installing Node.js via yum...${NC}" + curl -fsSL https://rpm.nodesource.com/setup_20.x | sudo bash - + sudo yum install -y nodejs + elif command_exists dnf; then + echo -e "${CYAN}Installing Node.js via dnf...${NC}" + curl -fsSL https://rpm.nodesource.com/setup_20.x | sudo bash - + sudo dnf install -y nodejs + else + echo -e "${RED}āŒ Could not detect package manager. Please install Node.js manually.${NC}" + exit 1 + fi + ;; + Darwin*) + if command_exists brew; then + echo -e "${CYAN}Installing Node.js via Homebrew...${NC}" + brew install node + else + echo -e "${YELLOW}āš ļø Homebrew not found. Please install Node.js from:${NC}" + echo -e "${CYAN} https://nodejs.org/${NC}" + exit 1 + fi + ;; + *) + echo -e "${RED}āŒ Unsupported operating system: $OS${NC}" + echo -e "${YELLOW}Please install Node.js manually from: https://nodejs.org/${NC}" + exit 1 + ;; + esac + + echo -e "${GREEN}āœ… Node.js installed successfully${NC}" +} + +# Check if Node.js is installed +if ! check_node_version; then + echo -e "${YELLOW}āš ļø Node.js 18+ is required but not found.${NC}" + echo "" + echo -e "${CYAN}Choose installation method:${NC}" + echo -e " 1) Install via nvm (recommended, user-level)" + echo -e " 2) Install via system package manager (requires sudo)" + echo -e " 3) Install manually (exit and install from https://nodejs.org/)" + echo "" + read -p "Enter choice [1-3]: " choice + + case $choice in + 1) + install_node_via_nvm + ;; + 2) + install_node_via_package_manager + ;; + 3) + echo -e "${YELLOW}Please install Node.js 18+ and run this script again.${NC}" + exit 0 + ;; + *) + echo -e "${RED}Invalid choice. Exiting.${NC}" + exit 1 + ;; + esac +fi + +# Verify Node.js installation +if ! check_node_version; then + echo -e "${RED}āŒ Node.js 18+ is still not available.${NC}" + echo -e "${YELLOW}Please restart your terminal and try again, or install Node.js manually.${NC}" + exit 1 +fi + +NODE_VERSION=$(node -v) +NPM_VERSION=$(npm -v) +echo -e "${GREEN}āœ… Node.js $NODE_VERSION and npm $NPM_VERSION detected${NC}" + +# Install CLI globally +echo "" +echo -e "${CYAN}šŸ“¦ Installing DesterLib CLI...${NC}" +npm install -g @desterlib/cli@latest + +# Verify installation +if command_exists desterlib; then + echo "" + echo -e "${GREEN}āœ… DesterLib CLI installed successfully!${NC}" + echo "" + echo -e "${CYAN}You can now run:${NC}" + echo -e " ${GREEN}desterlib${NC} - Run the setup wizard" + echo -e " ${GREEN}desterlib setup${NC} - Run the setup wizard" + echo "" + echo -e "${CYAN}Or use npx (no installation needed):${NC}" + echo -e " ${GREEN}npx @desterlib/cli${NC}" + echo "" +else + echo -e "${RED}āŒ Installation completed but 'desterlib' command not found.${NC}" + echo -e "${YELLOW}Please check your PATH or restart your terminal.${NC}" + exit 1 +fi + diff --git a/packages/cli/package.json b/packages/cli/package.json new file mode 100644 index 0000000..c008c7c --- /dev/null +++ b/packages/cli/package.json @@ -0,0 +1,72 @@ +{ + "name": "@desterlib/cli", + "version": "0.0.1", + "description": "DesterLib setup CLI for easy Docker configuration", + "type": "module", + "bin": { + "desterlib": "./dist/index.js" + }, + "scripts": { + "dev": "tsx src/index.ts", + "build": "tsc", + "setup": "node dist/index.js", + "build:binary": "pnpm build && pkg dist/index.js --out-path dist/bin", + "build:binary:all": "pnpm build && pkg dist/index.js --targets node18-linux-x64,node18-macos-x64,node18-win-x64 --out-path dist/bin" + }, + "pkg": { + "scripts": [ + "dist/**/*.js" + ], + "assets": [ + "dist/**/*" + ], + "targets": [ + "node18-linux-x64", + "node18-macos-x64", + "node18-win-x64" + ] + }, + "keywords": [ + "desterlib", + "cli", + "setup", + "docker" + ], + "author": "DesterLib", + "license": "AGPL-3.0", + "repository": { + "type": "git", + "url": "https://github.com/DesterLib/desterlib.git", + "directory": "packages/cli" + }, + "homepage": "https://desterlib.github.io/desterlib/cli/overview", + "bugs": { + "url": "https://github.com/DesterLib/desterlib/issues" + }, + "publishConfig": { + "access": "public" + }, + "files": [ + "dist", + "README.md", + "install.sh", + "install.ps1" + ], + "engines": { + "node": ">=18" + }, + "dependencies": { + "chalk": "^4.1.2", + "commander": "^11.1.0", + "inquirer": "^8.2.6", + "ora": "^5.4.1", + "execa": "^5.1.1" + }, + "devDependencies": { + "@types/inquirer": "^8.2.10", + "@types/node": "^20.14.10", + "pkg": "^5.8.1", + "tsx": "^4.15.7", + "typescript": "^5.5.3" + } +} diff --git a/packages/cli/src/commands/setup.ts b/packages/cli/src/commands/setup.ts new file mode 100644 index 0000000..8cb52d0 --- /dev/null +++ b/packages/cli/src/commands/setup.ts @@ -0,0 +1,319 @@ +import inquirer from "inquirer"; +import chalk from "chalk"; +import path from "path"; +import os from "os"; +import { createConfigFiles, type EnvConfig } from "../utils/env.js"; +import { + startDockerContainers, + checkContainersStatus, + checkDockerCompose, +} from "../utils/docker.js"; +import { + getInstallationDir, + isInstalled, + validatePath, + removeDirectory, + ensureDirectory, +} from "../utils/paths.js"; + +/** + * Main setup wizard + */ +export async function setupWizard(): Promise { + try { + const installDir = getInstallationDir(); + console.log(chalk.gray(`šŸ“ Installation directory: ${installDir}\n`)); + + // Check if Docker Compose is available + const hasCompose = await checkDockerCompose(); + if (!hasCompose) { + console.log(chalk.red("āŒ Docker Compose is not available")); + console.log(chalk.yellow("\nPlease install Docker Compose:")); + console.log(chalk.cyan(" https://docs.docker.com/compose/install/")); + process.exit(1); + } + + // Check if already installed + const alreadyInstalled = isInstalled(); + if (alreadyInstalled) { + const { action } = await inquirer.prompt([ + { + type: "list", + name: "action", + message: chalk.yellow( + "āš ļø DesterLib configuration already exists. What would you like to do?", + ), + choices: [ + { name: "Reconfigure (update settings)", value: "reconfigure" }, + { name: "Remove and start fresh", value: "reinstall" }, + { name: "Cancel", value: "cancel" }, + ], + }, + ]); + + if (action === "cancel") { + console.log(chalk.yellow("\nšŸ‘‹ Setup cancelled.")); + return; + } + + if (action === "reinstall") { + console.log(chalk.yellow("\nšŸ—‘ļø Removing existing configuration...")); + const removed = await removeDirectory(installDir); + if (!removed) { + console.log(chalk.red("āŒ Failed to remove existing configuration")); + process.exit(1); + } + } + // For 'reconfigure', we just overwrite the files + } + + // Ensure installation directory exists + await ensureDirectory(installDir); + + console.log(chalk.cyan.bold("šŸ“‹ Configuration Setup\n")); + console.log(chalk.gray("Please provide the following information:\n")); + + // Gather configuration + const answers = await inquirer.prompt([ + { + type: "input", + name: "mediaPath", + message: "šŸ“š Path to your media library:", + default: path.join(os.homedir(), "Media"), + validate: (input: string) => { + const validation = validatePath(input); + if (!validation.valid) { + return validation.message || "Invalid path"; + } + return true; + }, + }, + { + type: "input", + name: "port", + message: "šŸ”Œ API server port:", + default: "3001", + validate: (input: string) => { + const port = parseInt(input, 10); + if (isNaN(port) || port < 1024 || port > 65535) { + return "Please enter a valid port number (1024-65535)"; + } + return true; + }, + }, + ]); + + console.log(chalk.cyan.bold("\nšŸ”’ Database Configuration\n")); + console.log(chalk.gray("Configure PostgreSQL database credentials:\n")); + + const dbAnswers = await inquirer.prompt([ + { + type: "input", + name: "postgresUser", + message: "šŸ‘¤ Database username:", + default: "desterlib", + }, + { + type: "password", + name: "postgresPassword", + message: "šŸ” Database password:", + mask: "*", + validate: (input: string) => { + if (input.length < 8) { + return "Password must be at least 8 characters"; + } + return true; + }, + }, + { + type: "input", + name: "postgresDb", + message: "šŸ—„ļø Database name:", + default: "desterlib", + }, + ]); + + // Build configuration + const config: EnvConfig = { + mediaPath: answers.mediaPath, + port: parseInt(answers.port, 10), + postgresUser: dbAnswers.postgresUser, + postgresPassword: dbAnswers.postgresPassword, + postgresDb: dbAnswers.postgresDb, + databaseUrl: `postgresql://${dbAnswers.postgresUser}:${dbAnswers.postgresPassword}@postgres:5432/${dbAnswers.postgresDb}?schema=public`, + }; + + // Display configuration review + console.log(chalk.cyan.bold("\nšŸ“‹ Configuration Review\n")); + console.log(chalk.gray("Please review your configuration:\n")); + console.log( + chalk.white(" Media Path: ") + chalk.cyan(config.mediaPath), + ); + console.log( + chalk.white(" API Port: ") + chalk.cyan(config.port.toString()), + ); + console.log( + chalk.white(" Database User: ") + chalk.cyan(config.postgresUser), + ); + console.log( + chalk.white(" Database Name: ") + chalk.cyan(config.postgresDb), + ); + console.log( + chalk.white(" Database Password:") + chalk.cyan("***hidden***"), + ); + console.log(""); + console.log( + chalk.gray( + "Note: TMDB API key and JWT secret are configured in-app, not here.", + ), + ); + console.log(""); + + // Confirm before proceeding + const { confirmConfig } = await inquirer.prompt([ + { + type: "confirm", + name: "confirmConfig", + message: chalk.yellow("šŸ“ Proceed with this configuration?"), + default: true, + }, + ]); + + if (!confirmConfig) { + console.log(chalk.yellow("\nšŸ‘‹ Setup cancelled. No changes were made.")); + return; + } + + console.log(chalk.cyan.bold("\nāš™ļø Applying Configuration\n")); + + // Create all configuration files + const configCreated = await createConfigFiles(config, installDir); + if (!configCreated) { + console.log( + chalk.red("\nāŒ Failed to create configuration. Setup aborted."), + ); + process.exit(1); + } + + // Ask if user wants to start containers now + const { startNow } = await inquirer.prompt([ + { + type: "confirm", + name: "startNow", + message: chalk.cyan.bold("\nšŸš€ Start Docker containers now?"), + default: true, + }, + ]); + + if (startNow) { + console.log(""); + const started = await startDockerContainers(installDir); + + if (started) { + // Wait a moment for containers to initialize + await new Promise((resolve) => setTimeout(resolve, 3000)); + + // Check status + await checkContainersStatus(installDir); + + // Display success message + displaySuccessMessage(config.port, installDir); + } else { + console.log(chalk.red("\nāŒ Failed to start containers.")); + console.log(chalk.yellow("\nYou can start them manually with:")); + console.log(chalk.cyan(` cd ${installDir}`)); + console.log(chalk.cyan(" docker-compose up -d")); + } + } else { + console.log(chalk.yellow("\nāøļø Containers not started.")); + console.log(chalk.gray("\nTo start them later, run:")); + console.log(chalk.cyan(` cd ${installDir}`)); + console.log(chalk.cyan(" docker-compose up -d")); + console.log(""); + } + } catch (error: any) { + if (error.isTtyError) { + console.error( + chalk.red("\nāŒ Interactive prompts not supported in this environment"), + ); + } else if (error.message === "User force closed the prompt") { + console.log(chalk.yellow("\n\nšŸ‘‹ Setup cancelled by user.")); + } else { + console.error( + chalk.red("\nāŒ An error occurred during setup:"), + error.message, + ); + } + process.exit(1); + } +} + +/** + * Display success message with next steps + */ +function displaySuccessMessage(port: number, installDir: string): void { + console.log(chalk.green.bold("\nāœ… Setup Complete!\n")); + console.log(chalk.cyan("šŸ“š Your DesterLib server is now running!\n")); + + console.log(chalk.bold("šŸ”— Quick Links:")); + console.log( + chalk.gray(" ā”œā”€") + + chalk.cyan(` API Server: http://localhost:${port}`), + ); + console.log( + chalk.gray(" ā”œā”€") + + chalk.cyan(` API Docs: http://localhost:${port}/api/docs`), + ); + console.log( + chalk.gray(" ā”œā”€") + + chalk.cyan(` Health Check: http://localhost:${port}/health`), + ); + console.log( + chalk.gray(" └─") + + chalk.cyan(` WebSocket: ws://localhost:${port}/ws`), + ); + + console.log(chalk.bold("\nšŸ“± Next Steps:")); + console.log( + chalk.gray(" 1.") + + chalk.white(" Open the API docs and configure your media library"), + ); + console.log( + chalk.gray(" 2.") + + chalk.white(" Download the DesterLib mobile/desktop app"), + ); + console.log( + chalk.gray(" 3.") + chalk.white(` Connect to: http://localhost:${port}`), + ); + + console.log(chalk.bold("\nšŸ“‚ Installation Location:")); + console.log(chalk.gray(" ") + chalk.white(installDir)); + + console.log(chalk.bold("\nšŸ› ļø Useful Commands:")); + console.log( + chalk.gray(" ā”œā”€") + + chalk.white(" Stop: ") + + chalk.cyan(`cd ${installDir} && docker-compose down`), + ); + console.log( + chalk.gray(" ā”œā”€") + + chalk.white(" Restart: ") + + chalk.cyan(`cd ${installDir} && docker-compose restart`), + ); + console.log( + chalk.gray(" ā”œā”€") + + chalk.white(" Logs: ") + + chalk.cyan(`cd ${installDir} && docker-compose logs -f`), + ); + console.log( + chalk.gray(" └─") + + chalk.white(" Status: ") + + chalk.cyan(`cd ${installDir} && docker-compose ps`), + ); + + console.log( + chalk.gray("\nšŸ“– Documentation: ") + + chalk.cyan("https://desterlib.github.io/desterlib"), + ); + console.log(""); +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts new file mode 100644 index 0000000..af31906 --- /dev/null +++ b/packages/cli/src/index.ts @@ -0,0 +1,97 @@ +#!/usr/bin/env node + +import { Command } from "commander"; +import chalk from "chalk"; +import { setupWizard } from "./commands/setup.js"; +import { checkDocker } from "./utils/docker.js"; +import { displayBanner } from "./utils/banner.js"; +import { + getCurrentVersion, + checkForUpdates, + displayUpdateNotification, +} from "./utils/update-checker.js"; + +const program = new Command(); + +program + .name("desterlib") + .description("šŸŽ¬ DesterLib CLI - Configure your personal media server") + .version(getCurrentVersion()); + +/** + * Shared logic for setup command + */ +async function runSetup(options: { + skipDockerCheck?: boolean; + skipUpdateCheck?: boolean; +}): Promise { + displayBanner(); + + // Check for updates in the background (non-blocking) + if (!options.skipUpdateCheck) { + checkForUpdates() + .then((updateInfo) => { + displayUpdateNotification(updateInfo); + }) + .catch(() => { + // Silently fail - don't interrupt the user experience + }); + } + + // Check Docker installation unless skipped + if (!options.skipDockerCheck) { + const dockerInstalled = await checkDocker(); + if (!dockerInstalled) { + console.log(chalk.red("\nāŒ Docker is not installed or not running.")); + console.log(chalk.yellow("\nšŸ“¦ Please install Docker Desktop:")); + console.log( + chalk.cyan( + " • macOS/Windows: https://www.docker.com/products/docker-desktop", + ), + ); + console.log( + chalk.cyan(" • Linux: https://docs.docker.com/engine/install/"), + ); + console.log( + chalk.yellow("\nAfter installing Docker, run this setup again."), + ); + process.exit(1); + } + } + + await setupWizard(); +} + +program + .command("setup") + .description("Run the interactive setup wizard") + .option("--skip-docker-check", "Skip Docker installation check") + .option("--skip-update-check", "Skip checking for CLI updates") + .action(async (options) => { + await runSetup(options); + }); + +program + .command("update-check") + .description("Check for CLI updates") + .action(async () => { + console.log(chalk.cyan("Checking for updates...\n")); + const updateInfo = await checkForUpdates(); + + if (updateInfo.isOutdated && updateInfo.latestVersion) { + displayUpdateNotification(updateInfo); + } else { + console.log( + chalk.green( + `āœ… You're using the latest version (${updateInfo.currentVersion})`, + ), + ); + } + }); + +// Default command is setup +program.action(async () => { + await runSetup({}); +}); + +program.parse(); diff --git a/packages/cli/src/utils/banner.ts b/packages/cli/src/utils/banner.ts new file mode 100644 index 0000000..e4f93cc --- /dev/null +++ b/packages/cli/src/utils/banner.ts @@ -0,0 +1,17 @@ +import chalk from "chalk"; + +export function displayBanner(): void { + console.clear(); + console.log( + chalk.cyan.bold(` +╔══════════════════════════════════════════════════════════════╗ +ā•‘ ā•‘ +ā•‘ šŸŽ¬ DesterLib Setup ā•‘ +ā•‘ ā•‘ +ā•‘ Your Personal Media Server ā•‘ +ā•‘ ā•‘ +ā•šā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā• + `), + ); + console.log(chalk.gray(" Welcome! Let's set up your DesterLib server.\n")); +} diff --git a/packages/cli/src/utils/docker.ts b/packages/cli/src/utils/docker.ts new file mode 100644 index 0000000..95b4cc0 --- /dev/null +++ b/packages/cli/src/utils/docker.ts @@ -0,0 +1,148 @@ +import execa from "execa"; +import chalk from "chalk"; +import ora from "ora"; + +/** + * Check if Docker is installed and running + */ +export async function checkDocker(): Promise { + const spinner = ora("Checking Docker installation...").start(); + + try { + // Check if docker command exists + await execa("docker", ["--version"]); + + // Check if Docker daemon is running + await execa("docker", ["ps"]); + + spinner.succeed(chalk.green("Docker is installed and running")); + return true; + } catch (error) { + spinner.fail(chalk.red("Docker check failed")); + return false; + } +} + +/** + * Check if Docker Compose is available + */ +export async function checkDockerCompose(): Promise { + try { + // Try docker compose (v2) + await execa("docker", ["compose", "version"]); + return true; + } catch { + try { + // Try docker-compose (v1) + await execa("docker-compose", ["--version"]); + return true; + } catch { + return false; + } + } +} + +/** + * Get the appropriate docker compose command + */ +export async function getDockerComposeCommand(): Promise { + try { + await execa("docker", ["compose", "version"]); + return ["docker", "compose"]; + } catch { + return ["docker-compose"]; + } +} + +/** + * Start Docker containers + */ +export async function startDockerContainers( + installDir: string, +): Promise { + const spinner = ora("Starting Docker containers...").start(); + + try { + const composeCmd = await getDockerComposeCommand(); + const command = composeCmd[0]; + const baseArgs = composeCmd.slice(1); + + if (!command) { + throw new Error("No docker compose command found"); + } + + spinner.text = + "Building and starting containers (this may take a few minutes)..."; + + await execa(command, [...baseArgs, "up", "-d", "--build"], { + cwd: installDir, + stdio: "pipe", + }); + + spinner.succeed(chalk.green("Docker containers started successfully")); + return true; + } catch (error: any) { + spinner.fail(chalk.red("Failed to start Docker containers")); + console.error(chalk.red("\nError details:"), error.message); + return false; + } +} + +/** + * Stop Docker containers + * Note: Currently unused, reserved for future stop/restart commands + */ +export async function stopDockerContainers( + installDir: string, +): Promise { + const spinner = ora("Stopping Docker containers...").start(); + + try { + const composeCmd = await getDockerComposeCommand(); + const command = composeCmd[0]; + const baseArgs = composeCmd.slice(1); + + if (!command) { + throw new Error("No docker compose command found"); + } + + await execa(command, [...baseArgs, "down"], { + cwd: installDir, + stdio: "pipe", + }); + + spinner.succeed(chalk.green("Docker containers stopped")); + return true; + } catch (error: any) { + spinner.fail(chalk.red("Failed to stop Docker containers")); + console.error(chalk.red("\nError details:"), error.message); + return false; + } +} + +/** + * Check Docker containers status + */ +export async function checkContainersStatus(installDir: string): Promise { + const spinner = ora("Checking container status...").start(); + + try { + const composeCmd = await getDockerComposeCommand(); + const command = composeCmd[0]; + const baseArgs = composeCmd.slice(1); + + if (!command) { + throw new Error("No docker compose command found"); + } + + const { stdout } = await execa(command, [...baseArgs, "ps"], { + cwd: installDir, + }); + + spinner.stop(); + console.log(chalk.cyan("\nšŸ“Š Container Status:")); + console.log(stdout); + } catch (error) { + spinner.fail(chalk.red("Failed to check container status")); + } +} diff --git a/packages/cli/src/utils/env.ts b/packages/cli/src/utils/env.ts new file mode 100644 index 0000000..c11e495 --- /dev/null +++ b/packages/cli/src/utils/env.ts @@ -0,0 +1,52 @@ +import fs from "fs"; +import path from "path"; +import chalk from "chalk"; +import ora from "ora"; +import { + generateDockerCompose, + generateEnvFile, + generateReadme, +} from "./templates.js"; + +export interface EnvConfig { + mediaPath: string; + port: number; + databaseUrl: string; + postgresUser: string; + postgresPassword: string; + postgresDb: string; +} + +/** + * Create configuration files in the installation directory + */ +export async function createConfigFiles( + config: EnvConfig, + installDir: string, +): Promise { + const spinner = ora("Creating configuration files...").start(); + + try { + // Create docker-compose.yml + const composeContent = generateDockerCompose(config); + const composePath = path.join(installDir, "docker-compose.yml"); + await fs.promises.writeFile(composePath, composeContent, "utf-8"); + + // Create .env file + const envContent = generateEnvFile(config); + const envPath = path.join(installDir, ".env"); + await fs.promises.writeFile(envPath, envContent, "utf-8"); + + // Create README + const readmeContent = generateReadme(config.port, installDir); + const readmePath = path.join(installDir, "README.md"); + await fs.promises.writeFile(readmePath, readmeContent, "utf-8"); + + spinner.succeed(chalk.green("Configuration files created successfully")); + return true; + } catch (error: any) { + spinner.fail(chalk.red("Failed to create configuration files")); + console.error(chalk.red("\nError details:"), error.message); + return false; + } +} diff --git a/packages/cli/src/utils/paths.ts b/packages/cli/src/utils/paths.ts new file mode 100644 index 0000000..d90e6c8 --- /dev/null +++ b/packages/cli/src/utils/paths.ts @@ -0,0 +1,87 @@ +import path from "path"; +import fs from "fs"; +import os from "os"; + +/** + * Get the installation directory where DesterLib will be installed + * Defaults to ~/.desterlib + */ +export function getInstallationDir(): string { + return path.join(os.homedir(), ".desterlib"); +} + +/** + * Check if DesterLib configuration exists + */ +export function isInstalled(): boolean { + const installDir = getInstallationDir(); + const dockerComposePath = path.join(installDir, "docker-compose.yml"); + const envPath = path.join(installDir, ".env"); + return fs.existsSync(dockerComposePath) && fs.existsSync(envPath); +} + +/** + * Validate if a path exists and is accessible + */ +export function validatePath(inputPath: string): { + valid: boolean; + message?: string; +} { + if (!inputPath || inputPath.trim() === "") { + return { valid: false, message: "Path cannot be empty" }; + } + + const expandedPath = inputPath.replace(/^~/, os.homedir()); + + try { + if (!fs.existsSync(expandedPath)) { + return { + valid: false, + message: `Path does not exist: ${expandedPath}. Please create it first or choose an existing directory.`, + }; + } + + const stats = fs.statSync(expandedPath); + if (!stats.isDirectory()) { + return { valid: false, message: "Path must be a directory, not a file" }; + } + + // Check if readable + fs.accessSync(expandedPath, fs.constants.R_OK); + + return { valid: true }; + } catch (error: any) { + return { + valid: false, + message: `Cannot access path: ${error.message}`, + }; + } +} + +/** + * Ensure directory exists, create if it doesn't + */ +export async function ensureDirectory(dirPath: string): Promise { + try { + if (!fs.existsSync(dirPath)) { + await fs.promises.mkdir(dirPath, { recursive: true }); + } + return true; + } catch (error) { + return false; + } +} + +/** + * Remove directory and all its contents + */ +export async function removeDirectory(dirPath: string): Promise { + try { + if (fs.existsSync(dirPath)) { + await fs.promises.rm(dirPath, { recursive: true, force: true }); + } + return true; + } catch (error) { + return false; + } +} diff --git a/packages/cli/src/utils/templates.ts b/packages/cli/src/utils/templates.ts new file mode 100644 index 0000000..f46341e --- /dev/null +++ b/packages/cli/src/utils/templates.ts @@ -0,0 +1,177 @@ +import { EnvConfig } from "./env.js"; + +const DOCKER_IMAGE = "desterlib/api:latest"; // TODO: Update with your actual Docker Hub image + +/** + * Generate docker-compose.yml content + */ +export function generateDockerCompose(config: EnvConfig): string { + return `# DesterLib Docker Compose Configuration +# Generated by DesterLib CLI + +services: + postgres: + image: postgres:15-alpine + container_name: desterlib-postgres + restart: unless-stopped + environment: + POSTGRES_USER: ${config.postgresUser} + POSTGRES_PASSWORD: ${config.postgresPassword} + POSTGRES_DB: ${config.postgresDb} + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${config.postgresUser}"] + interval: 10s + timeout: 5s + retries: 5 + + api: + image: ${DOCKER_IMAGE} + container_name: desterlib-api + restart: unless-stopped + depends_on: + postgres: + condition: service_healthy + environment: + # Required environment variables + DATABASE_URL: ${config.databaseUrl} + NODE_ENV: production + PORT: ${config.port} + + # Rate limiting (optional - defaults shown) + RATE_LIMIT_WINDOW_MS: 900000 # 15 minutes + RATE_LIMIT_MAX: 100 # 100 requests per window + ports: + # Bind to all interfaces to allow mobile app connections + - "0.0.0.0:${config.port}:${config.port}" + volumes: + # Mount your media library (read-only for security) + - ${config.mediaPath}:/media:ro + healthcheck: + test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:${config.port}/health || exit 1"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + +volumes: + postgres_data: + driver: local +`; +} + +/** + * Generate .env content + */ +export function generateEnvFile(config: EnvConfig): string { + return `# DesterLib Configuration +# Generated by DesterLib CLI + +# Database Configuration +DATABASE_URL=${config.databaseUrl} + +# Server Configuration +NODE_ENV=production +PORT=${config.port} + +# Rate Limiting (optional) +RATE_LIMIT_WINDOW_MS=900000 # 15 minutes in milliseconds +RATE_LIMIT_MAX=100 # Maximum requests per window + +# Notes: +# - TMDB API key is configured in the app settings (not here) +# - JWT secret is stored in the database (not here) +# - Media path is configured in docker-compose.yml +`; +} + +/** + * Generate README for the installation directory + */ +export function generateReadme(port: number, installDir: string): string { + return `# DesterLib Installation + +This directory contains your DesterLib configuration. + +## Quick Start + +\`\`\`bash +# Start DesterLib +docker-compose up -d + +# View logs +docker-compose logs -f + +# Stop DesterLib +docker-compose down +\`\`\` + +## Access Points + +- **API Server**: http://localhost:${port} +- **API Documentation**: http://localhost:${port}/api/docs +- **Health Check**: http://localhost:${port}/health +- **WebSocket**: ws://localhost:${port}/ws + +## Configuration Files + +- \`docker-compose.yml\` - Docker services configuration +- \`.env\` - Environment variables and secrets + +## Updating + +To update to the latest version: + +\`\`\`bash +# Pull latest image +docker-compose pull + +# Restart with new image +docker-compose up -d +\`\`\` + +## Reconfiguring + +To change your configuration, run: + +\`\`\`bash +npx @desterlib/cli +\`\`\` + +## Useful Commands + +\`\`\`bash +# View running containers +docker-compose ps + +# Restart services +docker-compose restart + +# View API logs +docker-compose logs -f api + +# View database logs +docker-compose logs -f postgres + +# Stop and remove everything (keeps data) +docker-compose down + +# Stop and remove everything including data +docker-compose down -v +\`\`\` + +## Troubleshooting + +If you encounter issues: + +1. Check logs: \`docker-compose logs -f\` +2. Verify containers are running: \`docker-compose ps\` +3. Restart services: \`docker-compose restart\` +4. Check port availability: \`lsof -i :${port}\` + +For more help, visit: https://desterlib.github.io/desterlib +`; +} diff --git a/packages/cli/src/utils/update-checker.ts b/packages/cli/src/utils/update-checker.ts new file mode 100644 index 0000000..195f70e --- /dev/null +++ b/packages/cli/src/utils/update-checker.ts @@ -0,0 +1,111 @@ +import { readFileSync } from "fs"; +import { fileURLToPath } from "url"; +import { dirname, join } from "path"; +import chalk from "chalk"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +interface UpdateInfo { + currentVersion: string; + latestVersion: string | null; + isOutdated: boolean; +} + +/** + * Get the current version from package.json + */ +export function getCurrentVersion(): string { + try { + const packagePath = join(__dirname, "../../package.json"); + const packageJson = JSON.parse(readFileSync(packagePath, "utf-8")); + return packageJson.version || "0.0.0"; + } catch (error) { + return "0.0.0"; + } +} + +/** + * Check for updates from npm registry + */ +export async function checkForUpdates(): Promise { + const currentVersion = getCurrentVersion(); + let latestVersion: string | null = null; + + try { + const response = await fetch( + "https://registry.npmjs.org/@desterlib/cli/latest", + { + headers: { + Accept: "application/vnd.npm.install-v1+json", + }, + }, + ); + + if (response.ok) { + const data = (await response.json()) as { version?: string }; + latestVersion = data.version || null; + } + } catch (error) { + // Silently fail - network issues shouldn't block the CLI + // Only log in development/debug mode + } + + const isOutdated = latestVersion !== null && latestVersion !== currentVersion; + + return { + currentVersion, + latestVersion, + isOutdated, + }; +} + +/** + * Display update notification if a new version is available + */ +export function displayUpdateNotification(updateInfo: UpdateInfo): void { + if (!updateInfo.isOutdated || !updateInfo.latestVersion) { + return; + } + + console.log( + chalk.yellow( + "\nā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”", + ), + ); + console.log( + chalk.yellow("│") + + chalk.bold(" āš ļø Update Available!") + + " ".repeat(35) + + chalk.yellow("│"), + ); + console.log( + chalk.yellow("ā”œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¤"), + ); + console.log( + chalk.yellow("│") + + ` Current version: ${chalk.gray(updateInfo.currentVersion)}` + + " ".repeat(25 - updateInfo.currentVersion.length) + + chalk.yellow("│"), + ); + console.log( + chalk.yellow("│") + + ` Latest version: ${chalk.green(updateInfo.latestVersion)}` + + " ".repeat(25 - updateInfo.latestVersion.length) + + chalk.yellow("│"), + ); + console.log( + chalk.yellow("ā”œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¤"), + ); + console.log( + chalk.yellow("│") + + ` Run: ${chalk.cyan("npm install -g @desterlib/cli@latest")}` + + " ".repeat(12) + + chalk.yellow("│"), + ); + console.log( + chalk.yellow( + "ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜\n", + ), + ); +} diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json new file mode 100644 index 0000000..ff740f5 --- /dev/null +++ b/packages/cli/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../typescript-config/base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "module": "ES2022", + "moduleResolution": "node", + "target": "ES2022", + "lib": ["ES2022"], + "types": ["node"] + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} + diff --git a/packages/eslint-config/README.md b/packages/eslint-config/README.md index 8b42d90..d362011 100644 --- a/packages/eslint-config/README.md +++ b/packages/eslint-config/README.md @@ -1,3 +1,9 @@ -# `@turbo/eslint-config` +# @desterlib/eslint-config -Collection of internal eslint configurations. +ESLint configurations for DesterLib projects. + +## Documentation + +šŸ“– **[Full Documentation](https://desterlib.github.io/desterlib)** + +For usage and configuration, visit the [documentation site](https://desterlib.github.io/desterlib). diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cc02405..65ce3ff 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,6 +4,9 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +overrides: + lightningcss: ^1.30.2 + importers: .: @@ -35,6 +38,9 @@ importers: prettier: specifier: ^3.6.2 version: 3.6.2 + tsx: + specifier: ^4.20.6 + version: 4.20.6 turbo: specifier: ^2.5.8 version: 2.5.8 @@ -74,6 +80,9 @@ importers: jsonwebtoken: specifier: ^9.0.2 version: 9.0.2 + node-vibrant: + specifier: ^4.0.2 + version: 4.0.3 pg: specifier: ^8.16.3 version: 8.16.3 @@ -86,6 +95,9 @@ importers: winston: specifier: ^3.18.3 version: 3.18.3 + winston-transport: + specifier: ^4.9.0 + version: 4.9.0 ws: specifier: ^8.18.3 version: 8.18.3(bufferutil@4.0.9) @@ -139,22 +151,92 @@ importers: apps/docs: dependencies: + '@astrojs/react': + specifier: ^4.4.2 + version: 4.4.2(@types/node@20.19.22)(@types/react-dom@19.2.3(@types/react@19.2.4))(@types/react@19.2.4)(jiti@2.6.1)(lightningcss@1.30.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(tsx@4.20.6)(yaml@2.8.1) '@astrojs/starlight': specifier: ^0.36.1 - version: 0.36.1(astro@5.15.1(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.1)(rollup@4.52.5)(tsx@4.20.6)(typescript@5.9.2)(yaml@2.8.1)) + version: 0.36.1(astro@5.15.1(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.52.5)(tsx@4.20.6)(typescript@5.9.2)(yaml@2.8.1)) + '@astrojs/starlight-tailwind': + specifier: ^4.0.2 + version: 4.0.2(@astrojs/starlight@0.36.1(astro@5.15.1(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.52.5)(tsx@4.20.6)(typescript@5.9.2)(yaml@2.8.1)))(tailwindcss@4.1.17) + '@tailwindcss/vite': + specifier: ^4.1.17 + version: 4.1.17(vite@6.4.1(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.1)) + '@types/react': + specifier: ^19.2.4 + version: 19.2.4 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.4) astro: specifier: ^5.6.1 - version: 5.15.1(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.1)(rollup@4.52.5)(tsx@4.20.6)(typescript@5.9.2)(yaml@2.8.1) + version: 5.15.1(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.52.5)(tsx@4.20.6)(typescript@5.9.2)(yaml@2.8.1) + motion: + specifier: ^12.23.24 + version: 12.23.24(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + react: + specifier: ^19.2.0 + version: 19.2.0 + react-dom: + specifier: ^19.2.0 + version: 19.2.0(react@19.2.0) sharp: specifier: ^0.34.2 version: 0.34.4 + tailwindcss: + specifier: ^4.1.17 + version: 4.1.17 devDependencies: '@astrojs/check': specifier: ^0.9.0 - version: 0.9.5(prettier@3.6.2)(typescript@5.9.2) + version: 0.9.5(prettier-plugin-astro@0.14.1)(prettier@3.6.2)(typescript@5.9.2) + prettier: + specifier: ^3.6.2 + version: 3.6.2 + prettier-plugin-astro: + specifier: 0.14.1 + version: 0.14.1 typescript: specifier: ^5.9.2 version: 5.9.2 + vite: + specifier: ^6.4.1 + version: 6.4.1(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.1) + + packages/cli: + dependencies: + chalk: + specifier: ^4.1.2 + version: 4.1.2 + commander: + specifier: ^11.1.0 + version: 11.1.0 + execa: + specifier: ^5.1.1 + version: 5.1.1 + inquirer: + specifier: ^8.2.6 + version: 8.2.7(@types/node@20.19.22) + ora: + specifier: ^5.4.1 + version: 5.4.1 + devDependencies: + '@types/inquirer': + specifier: ^8.2.10 + version: 8.2.12 + '@types/node': + specifier: ^20.14.10 + version: 20.19.22 + pkg: + specifier: ^5.8.1 + version: 5.8.1 + tsx: + specifier: ^4.15.7 + version: 4.20.6 + typescript: + specifier: ^5.5.3 + version: 5.9.2 packages/eslint-config: devDependencies: @@ -248,9 +330,24 @@ packages: resolution: {integrity: sha512-q8VwfU/fDZNoDOf+r7jUnMC2//H2l0TuQ6FkGJL8vD8nw/q5KiL3DS1KKBI3QhI9UQhpJ5dc7AtqfbXWuOgLCQ==} engines: {node: 18.20.8 || ^20.3.0 || >=22.0.0} + '@astrojs/react@4.4.2': + resolution: {integrity: sha512-1tl95bpGfuaDMDn8O3x/5Dxii1HPvzjvpL2YTuqOOrQehs60I2DKiDgh1jrKc7G8lv+LQT5H15V6QONQ+9waeQ==} + engines: {node: 18.20.8 || ^20.3.0 || >=22.0.0} + peerDependencies: + '@types/react': ^17.0.50 || ^18.0.21 || ^19.0.0 + '@types/react-dom': ^17.0.17 || ^18.0.6 || ^19.0.0 + react: ^17.0.2 || ^18.0.0 || ^19.0.0 + react-dom: ^17.0.2 || ^18.0.0 || ^19.0.0 + '@astrojs/sitemap@3.6.0': resolution: {integrity: sha512-4aHkvcOZBWJigRmMIAJwRQXBS+ayoP5z40OklTXYXhUDhwusz+DyDl+nSshY6y9DvkVEavwNcFO8FD81iGhXjg==} + '@astrojs/starlight-tailwind@4.0.2': + resolution: {integrity: sha512-SYN/6zq6hJO5tWqbQ2tWT9/jd8ubUkzkBCcF94vByC/ZJ20Mi5GPjFvAh89Yky/aIM+jXxT6W5q4p6l58GKHiQ==} + peerDependencies: + '@astrojs/starlight': '>=0.34.0' + tailwindcss: ^4.0.0 + '@astrojs/starlight@0.36.1': resolution: {integrity: sha512-Fmt8mIsAIZN18Y4YQDI6p521GsYGe4hYxh9jWmz0pHBXnS5J7Na3TSXNya4eyIymCcKkuiKFbs7b/knsdGVYPg==} peerDependencies: @@ -267,6 +364,44 @@ packages: resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} engines: {node: '>=6.9.0'} + '@babel/compat-data@7.28.5': + resolution: {integrity: sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.28.5': + resolution: {integrity: sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.18.2': + resolution: {integrity: sha512-W1lG5vUwFvfMd8HVXqdfbuG7RuaSrTCCD8cl8fP8wOivdbtbIg2Db3IWUcgvfxKbbn6ZBGYRW/Zk1MIwK49mgw==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.28.5': + resolution: {integrity: sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.27.2': + resolution: {integrity: sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.28.0': + resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.27.1': + resolution: {integrity: sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.28.3': + resolution: {integrity: sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-plugin-utils@7.27.1': + resolution: {integrity: sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==} + engines: {node: '>=6.9.0'} + '@babel/helper-string-parser@7.27.1': resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} engines: {node: '>=6.9.0'} @@ -275,19 +410,69 @@ packages: resolution: {integrity: sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==} engines: {node: '>=6.9.0'} + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.27.1': + resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.28.4': + resolution: {integrity: sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.18.4': + resolution: {integrity: sha512-FDge0dFazETFcxGw/EXzOkN8uJp0PC7Qbm+Pe9T+av2zlBpOgunFHkQPPn+eRuClU73JF+98D531UgayY89tow==} + engines: {node: '>=6.0.0'} + hasBin: true + '@babel/parser@7.28.4': resolution: {integrity: sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg==} engines: {node: '>=6.0.0'} hasBin: true + '@babel/parser@7.28.5': + resolution: {integrity: sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-transform-react-jsx-self@7.27.1': + resolution: {integrity: sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx-source@7.27.1': + resolution: {integrity: sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + '@babel/runtime@7.28.4': resolution: {integrity: sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==} engines: {node: '>=6.9.0'} + '@babel/template@7.27.2': + resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.28.5': + resolution: {integrity: sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.19.0': + resolution: {integrity: sha512-YuGopBq3ke25BVSiS6fgF49Ul9gH1x70Bcr6bqRLjWCkcX8Hre1/5+z+IiWOIerRMSSEfGZVB9z9kyq7wVs9YA==} + engines: {node: '>=6.9.0'} + '@babel/types@7.28.4': resolution: {integrity: sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==} engines: {node: '>=6.9.0'} + '@babel/types@7.28.5': + resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==} + engines: {node: '>=6.9.0'} + '@capsizecss/unpack@3.0.0': resolution: {integrity: sha512-+ntATQe1AlL7nTOYjwjj6w3299CgRot48wL761TUGYpYgAou3AaONZazp0PKZyCyWhudWsjhq1nvRHOvbMzhTA==} engines: {node: '>=18'} @@ -839,9 +1024,66 @@ packages: '@types/node': optional: true + '@jimp/bmp@0.22.12': + resolution: {integrity: sha512-aeI64HD0npropd+AR76MCcvvRaa+Qck6loCOS03CkkxGHN5/r336qTM5HPUdHKMDOGzqknuVPA8+kK1t03z12g==} + peerDependencies: + '@jimp/custom': '>=0.3.5' + + '@jimp/core@0.22.12': + resolution: {integrity: sha512-l0RR0dOPyzMKfjUW1uebzueFEDtCOj9fN6pyTYWWOM/VS4BciXQ1VVrJs8pO3kycGYZxncRKhCoygbNr8eEZQA==} + + '@jimp/custom@0.22.12': + resolution: {integrity: sha512-xcmww1O/JFP2MrlGUMd3Q78S3Qu6W3mYTXYuIqFq33EorgYHV/HqymHfXy9GjiCJ7OI+7lWx6nYFOzU7M4rd1Q==} + + '@jimp/gif@0.22.12': + resolution: {integrity: sha512-y6BFTJgch9mbor2H234VSjd9iwAhaNf/t3US5qpYIs0TSbAvM02Fbc28IaDETj9+4YB4676sz4RcN/zwhfu1pg==} + peerDependencies: + '@jimp/custom': '>=0.3.5' + + '@jimp/jpeg@0.22.12': + resolution: {integrity: sha512-Rq26XC/uQWaQKyb/5lksCTCxXhtY01NJeBN+dQv5yNYedN0i7iYu+fXEoRsfaJ8xZzjoANH8sns7rVP4GE7d/Q==} + peerDependencies: + '@jimp/custom': '>=0.3.5' + + '@jimp/plugin-resize@0.22.12': + resolution: {integrity: sha512-3NyTPlPbTnGKDIbaBgQ3HbE6wXbAlFfxHVERmrbqAi8R3r6fQPxpCauA8UVDnieg5eo04D0T8nnnNIX//i/sXg==} + peerDependencies: + '@jimp/custom': '>=0.3.5' + + '@jimp/png@0.22.12': + resolution: {integrity: sha512-Mrp6dr3UTn+aLK8ty/dSKELz+Otdz1v4aAXzV5q53UDD2rbB5joKVJ/ChY310B+eRzNxIovbUF1KVrUsYdE8Hg==} + peerDependencies: + '@jimp/custom': '>=0.3.5' + + '@jimp/tiff@0.22.12': + resolution: {integrity: sha512-E1LtMh4RyJsoCAfAkBRVSYyZDTtLq9p9LUiiYP0vPtXyxX4BiYBUYihTLSBlCQg5nF2e4OpQg7SPrLdJ66u7jg==} + peerDependencies: + '@jimp/custom': '>=0.3.5' + + '@jimp/types@0.22.12': + resolution: {integrity: sha512-wwKYzRdElE1MBXFREvCto5s699izFHNVvALUv79GXNbsOVqlwlOxlWJ8DuyOGIXoLP4JW/m30YyuTtfUJgMRMA==} + peerDependencies: + '@jimp/custom': '>=0.3.5' + + '@jimp/utils@0.22.12': + resolution: {integrity: sha512-yJ5cWUknGnilBq97ZXOyOS0HhsHOyAyjHwYfHxGbSyMTohgQI6sVyE8KPgDwH8HHW/nMKXk8TrSwAE71zt716Q==} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + '@jridgewell/sourcemap-codec@1.5.5': resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@jsdevtools/ono@7.1.3': resolution: {integrity: sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==} @@ -935,6 +1177,9 @@ packages: '@prisma/get-platform@6.17.1': resolution: {integrity: sha512-AKEn6fsfz0r482S5KRDFlIGEaq9wLNcgalD1adL+fPcFFblIKs1sD81kY/utrHdqKuVC6E1XSRpegDK3ZLL4Qg==} + '@rolldown/pluginutils@1.0.0-beta.27': + resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} + '@rollup/pluginutils@5.3.0': resolution: {integrity: sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==} engines: {node: '>=14.0.0'} @@ -1087,6 +1332,111 @@ packages: '@swc/helpers@0.5.17': resolution: {integrity: sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A==} + '@tailwindcss/node@4.1.17': + resolution: {integrity: sha512-csIkHIgLb3JisEFQ0vxr2Y57GUNYh447C8xzwj89U/8fdW8LhProdxvnVH6U8M2Y73QKiTIH+LWbK3V2BBZsAg==} + + '@tailwindcss/oxide-android-arm64@4.1.17': + resolution: {integrity: sha512-BMqpkJHgOZ5z78qqiGE6ZIRExyaHyuxjgrJ6eBO5+hfrfGkuya0lYfw8fRHG77gdTjWkNWEEm+qeG2cDMxArLQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [android] + + '@tailwindcss/oxide-darwin-arm64@4.1.17': + resolution: {integrity: sha512-EquyumkQweUBNk1zGEU/wfZo2qkp/nQKRZM8bUYO0J+Lums5+wl2CcG1f9BgAjn/u9pJzdYddHWBiFXJTcxmOg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@tailwindcss/oxide-darwin-x64@4.1.17': + resolution: {integrity: sha512-gdhEPLzke2Pog8s12oADwYu0IAw04Y2tlmgVzIN0+046ytcgx8uZmCzEg4VcQh+AHKiS7xaL8kGo/QTiNEGRog==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@tailwindcss/oxide-freebsd-x64@4.1.17': + resolution: {integrity: sha512-hxGS81KskMxML9DXsaXT1H0DyA+ZBIbyG/sSAjWNe2EDl7TkPOBI42GBV3u38itzGUOmFfCzk1iAjDXds8Oh0g==} + engines: {node: '>= 10'} + cpu: [x64] + os: [freebsd] + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.1.17': + resolution: {integrity: sha512-k7jWk5E3ldAdw0cNglhjSgv501u7yrMf8oeZ0cElhxU6Y2o7f8yqelOp3fhf7evjIS6ujTI3U8pKUXV2I4iXHQ==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-gnu@4.1.17': + resolution: {integrity: sha512-HVDOm/mxK6+TbARwdW17WrgDYEGzmoYayrCgmLEw7FxTPLcp/glBisuyWkFz/jb7ZfiAXAXUACfyItn+nTgsdQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-musl@4.1.17': + resolution: {integrity: sha512-HvZLfGr42i5anKtIeQzxdkw/wPqIbpeZqe7vd3V9vI3RQxe3xU1fLjss0TjyhxWcBaipk7NYwSrwTwK1hJARMg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@tailwindcss/oxide-linux-x64-gnu@4.1.17': + resolution: {integrity: sha512-M3XZuORCGB7VPOEDH+nzpJ21XPvK5PyjlkSFkFziNHGLc5d6g3di2McAAblmaSUNl8IOmzYwLx9NsE7bplNkwQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@tailwindcss/oxide-linux-x64-musl@4.1.17': + resolution: {integrity: sha512-k7f+pf9eXLEey4pBlw+8dgfJHY4PZ5qOUFDyNf7SI6lHjQ9Zt7+NcscjpwdCEbYi6FI5c2KDTDWyf2iHcCSyyQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@tailwindcss/oxide-wasm32-wasi@4.1.17': + resolution: {integrity: sha512-cEytGqSSoy7zK4JRWiTCx43FsKP/zGr0CsuMawhH67ONlH+T79VteQeJQRO/X7L0juEUA8ZyuYikcRBf0vsxhg==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + bundledDependencies: + - '@napi-rs/wasm-runtime' + - '@emnapi/core' + - '@emnapi/runtime' + - '@tybys/wasm-util' + - '@emnapi/wasi-threads' + - tslib + + '@tailwindcss/oxide-win32-arm64-msvc@4.1.17': + resolution: {integrity: sha512-JU5AHr7gKbZlOGvMdb4722/0aYbU+tN6lv1kONx0JK2cGsh7g148zVWLM0IKR3NeKLv+L90chBVYcJ8uJWbC9A==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@tailwindcss/oxide-win32-x64-msvc@4.1.17': + resolution: {integrity: sha512-SKWM4waLuqx0IH+FMDUw6R66Hu4OuTALFgnleKbqhgGU30DY20NORZMZUKgLRjQXNN2TLzKvh48QXTig4h4bGw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@tailwindcss/oxide@4.1.17': + resolution: {integrity: sha512-F0F7d01fmkQhsTjXezGBLdrl1KresJTcI3DB8EkScCldyKp3Msz4hub4uyYaVnk88BAS1g5DQjjF6F5qczheLA==} + engines: {node: '>= 10'} + + '@tailwindcss/vite@4.1.17': + resolution: {integrity: sha512-4+9w8ZHOiGnpcGI6z1TVVfWaX/koK7fKeSYF3qlYg2xpBtbteP2ddBxiarL+HVgfSJGeK5RIxRQmKm4rTJJAwA==} + peerDependencies: + vite: ^5.2.0 || ^6 || ^7 + + '@tokenizer/token@0.3.0': + resolution: {integrity: sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==} + + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.28.0': + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + '@types/body-parser@1.19.6': resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} @@ -1126,6 +1476,9 @@ packages: '@types/http-errors@2.0.5': resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} + '@types/inquirer@8.2.12': + resolution: {integrity: sha512-YxURZF2ZsSjU5TAe06tW0M3sL4UI9AMPA6dd8I72uOtppzNafcY38xkYgCZ/vsVOAyNdzHmvtTpLWilOrbP0dQ==} + '@types/js-yaml@4.0.9': resolution: {integrity: sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==} @@ -1153,15 +1506,18 @@ packages: '@types/node@12.20.55': resolution: {integrity: sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==} + '@types/node@16.9.1': + resolution: {integrity: sha512-QpLcX9ZSsq3YYUUnD3nFDY8H7wctAhQj/TFKL8Ya8v5fMm3CFXxo8zStsLAl780ltoYoo1WvKUVGBQK+1ifr7g==} + '@types/node@17.0.45': resolution: {integrity: sha512-w+tIMs3rq2afQdsPJlODhoUEKzFP1ayaoyl1CcnwtIlsVe7K7bA1NGm4s3PraqTLlXnbIN84zuBlxBWo1u9BLw==} + '@types/node@18.19.130': + resolution: {integrity: sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==} + '@types/node@20.19.22': resolution: {integrity: sha512-hRnu+5qggKDSyWHlnmThnUqg62l29Aj/6vcYgUaSFL9oc7DVjeWEQN3PRgdSc6F8d9QRMWkf36CLMch1Do/+RQ==} - '@types/node@22.15.3': - resolution: {integrity: sha512-lX7HFZeHf4QG/J7tBZqrCAXwz9J5RD56Y6MpP0eJkka8p+K0RY/yBTW7CYFJ4VGCclxqOLKmiGP5juQc6MKgcw==} - '@types/node@24.8.1': resolution: {integrity: sha512-alv65KGRadQVfVcG69MuB4IzdYVpRwMG/mq8KWOaoOdyY617P5ivaDiMCGOFDWD2sAn5Q0mR3mRtUOgm99hL9Q==} @@ -1174,6 +1530,14 @@ packages: '@types/range-parser@1.2.7': resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} + '@types/react-dom@19.2.3': + resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} + peerDependencies: + '@types/react': ^19.2.0 + + '@types/react@19.2.4': + resolution: {integrity: sha512-tBFxBp9Nfyy5rsmefN+WXc1JeW/j2BpBHFdLZbEVfs9wn3E3NRFxwV0pJg8M1qQAexFpvz73hJXFofV0ZAu92A==} + '@types/sax@1.2.7': resolution: {integrity: sha512-rO73L89PJxeYM3s3pPPjiPgVVcymqU490g0YO5n5By0k2Erzj6tay/4lr1CHAAU4JyOWd1rpQ8bCf6cZfHU96A==} @@ -1192,6 +1556,9 @@ packages: '@types/swagger-ui-express@4.1.8': resolution: {integrity: sha512-AhZV8/EIreHFmBV5wAs0gzJUNq9JbbSXgJLQubCC0jtIo6prnI9MIRRxnU4MZX9RB9yXxF1V4R7jtLl/Wcj31g==} + '@types/through@0.0.33': + resolution: {integrity: sha512-HsJ+z3QuETzP3cswwtzt2vEIiHBk/dCcHGhbmG5X3ecnwFD/lPrMpliGXxSCg03L9AhrdwA4Oz/qfspkDW+xGQ==} + '@types/triple-beam@1.3.5': resolution: {integrity: sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==} @@ -1266,6 +1633,45 @@ packages: '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + '@vibrant/color@4.0.0': + resolution: {integrity: sha512-S9ItdqS1135wTXoIIqAJu8df9dqlOo6Boc5Y4MGsBTu9UmUOvOwfj5b4Ga6S5yrLAKmKYIactkz7zYJdMddkig==} + + '@vibrant/core@4.0.0': + resolution: {integrity: sha512-fqlVRUTDjEws9VNKvI3cDXM4wUT7fMFS+cVqEjJk3im+R5EvjJzPF6OAbNhfPzW04NvHNE555eY9FfhYuX3PRw==} + + '@vibrant/generator-default@4.0.3': + resolution: {integrity: sha512-HZlfp19sDokODEkZF4p70QceARHgjP3a1Dmxg+dlblYMJM98jPq+azA0fzqKNR7R17JJNHxexpJEepEsNlG0gw==} + + '@vibrant/generator@4.0.0': + resolution: {integrity: sha512-CqKAjmgHVDXJVo3Q5+9pUJOvksR7cN3bzx/6MbURYh7lA4rhsIewkUK155M6q0vfcUN3ETi/eTneCi0tLuM2Sg==} + + '@vibrant/image-browser@4.0.0': + resolution: {integrity: sha512-mXckzvJWiP575Y/wNtP87W/TPgyJoGlPBjW4E9YmNS6n4Jb6RqyHQA0ZVulqDslOxjSsihDzY7gpAORRclaoLg==} + + '@vibrant/image-node@4.0.0': + resolution: {integrity: sha512-m7yfnQtmo2y8z+tOjRFBx6q/qGnhl/ax2uCaj4TBkm4TtXfR4Dsn90wT6OWXmCFFzxIKHXKKEBShkxR+4RHseA==} + + '@vibrant/image@4.0.0': + resolution: {integrity: sha512-Asv/7R/L701norosgvbjOVkodFiwcFihkXixA/gbAd6C+5GCts1Wm1NPk14FNKnM7eKkfAN+0wwPkdOH+PY/YA==} + + '@vibrant/quantizer-mmcq@4.0.0': + resolution: {integrity: sha512-TZqNiRoGGyCP8fH1XE6rvhFwLNv9D8MP1Xhz3K8tsuUweC6buWax3qLfrfEnkhtQnPJHaqvTfTOlIIXVMfRpow==} + + '@vibrant/quantizer@4.0.0': + resolution: {integrity: sha512-YDGxmCv/RvHFtZghDlVRwH5GMxdGGozWS1JpUOUt73/F5zAKGiiier8F31K1npIXARn6/Gspvg/Rbg7qqyEr2A==} + + '@vibrant/types@4.0.0': + resolution: {integrity: sha512-tA5TAbuROXcPkt+PWjmGfoaiEXyySVaNnCZovf6vXhCbMdrTTCQXvNCde2geiVl6YwtuU/Qrj9iZxS5jZ6yVIw==} + + '@vibrant/worker@4.0.0': + resolution: {integrity: sha512-nSaZZwWQKOgN/nPYUAIRF0/uoa7KpK91A+gjLmZZDgfN1enqxaiihmn+75ayNadW0c6cxAEpEFEHTONR5u9tMw==} + + '@vitejs/plugin-react@4.7.0': + resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + '@volar/kit@2.4.23': resolution: {integrity: sha512-YuUIzo9zwC2IkN7FStIcVl1YS9w5vkSFEZfPvnu0IbIMaR9WHhc9ZxvlT+91vrcSoRY469H2jwbrGqpG7m1KaQ==} peerDependencies: @@ -1296,6 +1702,10 @@ packages: resolution: {integrity: sha512-E+iruNOY8VV9s4JEbe1aNEm6MiszPRr/UfcHMz0TQh1BXSxHK+ASV1R6W4HpjBhSeS+54PIsAMCBmwD06LLsqQ==} hasBin: true + abort-controller@3.0.0: + resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} + engines: {node: '>=6.5'} + accepts@1.3.8: resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} engines: {node: '>= 0.6'} @@ -1308,6 +1718,11 @@ packages: acorn@8.15.0: resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} engines: {node: '>=0.4.0'} + hasBin: true + + agent-base@6.0.2: + resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} + engines: {node: '>= 6.0.0'} ajv@6.12.6: resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} @@ -1358,6 +1773,9 @@ packages: resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} engines: {node: '>=12'} + any-base@1.1.0: + resolution: {integrity: sha512-uMgjozySS8adZZYePpaWs8cxB9/kdzmpX6SgJZ+wbz1K5eYk5QMYDVJaZKhxyIHUdnnJkfR7SVgStgH7LkGUyg==} + anymatch@3.1.3: resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} engines: {node: '>= 8'} @@ -1467,6 +1885,10 @@ packages: base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + baseline-browser-mapping@2.8.28: + resolution: {integrity: sha512-gYjt7OIqdM0PcttNYP2aVrr2G0bMALkBaoehD4BuRGjAOtipg0b6wHg1yNL+s5zSnLZZrGHOw4IrND8CD+3oIQ==} + hasBin: true + bcp-47-match@2.0.3: resolution: {integrity: sha512-JtTezzbAibu8G0R9op9zb3vcWZd9JF6M0xOYGPn0fNCd7wOpRB1mU2mH9T8gaBGbAAyIIVgB2G7xG0GP98zMAQ==} @@ -1484,6 +1906,9 @@ packages: bl@4.1.0: resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + bmp-js@0.1.0: + resolution: {integrity: sha512-vHdS19CnY3hwiNdkaqk93DvjVLfbEcI8mys4UjuWrlX1haDmroo8o4xCzh4wD6DGV6HxRCyauwhHRqMTfERtjw==} + body-parser@1.20.3: resolution: {integrity: sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==} engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} @@ -1508,12 +1933,20 @@ packages: brotli@1.3.3: resolution: {integrity: sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==} + browserslist@4.28.0: + resolution: {integrity: sha512-tbydkR/CxfMwelN0vwdP/pLkDwyAASZ+VfWm4EOwlB6SWhx1sYnWLqo8N5j0rAzPfzfRaxt0mM/4wPU/Su84RQ==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + buffer-equal-constant-time@1.0.1: resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} buffer@5.7.1: resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + buffer@6.0.3: + resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} + bufferutil@4.0.9: resolution: {integrity: sha512-WDtdLmJvAuNNPzByAYpRo2rF1Mmradw6gvWsQKf63476DDXmomT9zUiGypLcG4ibIM67vhAj8jJRdbmEws2Aqw==} engines: {node: '>=6.14.2'} @@ -1557,6 +1990,9 @@ packages: resolution: {integrity: sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA==} engines: {node: '>=16'} + caniuse-lite@1.0.30001754: + resolution: {integrity: sha512-x6OeBXueoAceOmotzx3PO4Zpt4rzpeIFsSr6AAePTZxSkXiYDUmpypEl7e2+8NCd9bD7bXjqyef8CJYPC1jfxg==} + ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} @@ -1598,6 +2034,9 @@ packages: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} + chownr@1.1.4: + resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} + ci-info@3.9.0: resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==} engines: {node: '>=8'} @@ -1632,6 +2071,9 @@ packages: resolution: {integrity: sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw==} engines: {node: '>= 10'} + cliui@7.0.4: + resolution: {integrity: sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==} + cliui@8.0.1: resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} engines: {node: '>=12'} @@ -1687,6 +2129,10 @@ packages: comma-separated-tokens@2.0.3: resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} + commander@11.1.0: + resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==} + engines: {node: '>=16'} + commander@6.2.0: resolution: {integrity: sha512-zP4jEKbe8SHzKJYQmq8Y9gYjtO/POJLgIdKgV7B9qNmABVFVc+ctqSX6iXh4mCpJfRBOabiZ2YKPg8ciDw6C+Q==} engines: {node: '>= 6'} @@ -1748,6 +2194,9 @@ packages: engines: {node: '>=16'} hasBin: true + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cookie-es@1.2.2: resolution: {integrity: sha512-+W7VmiVINB+ywl1HGXJXmrqkOhpKrIiVZV6tQuV54ZyQC7MMuBt81Vc336GMLoHBq5hV/F9eXgt5Mnx0Rha5Fg==} @@ -1762,6 +2211,9 @@ packages: resolution: {integrity: sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==} engines: {node: '>=18'} + core-util-is@1.0.3: + resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + cors@2.8.5: resolution: {integrity: sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==} engines: {node: '>= 0.10'} @@ -1802,6 +2254,9 @@ packages: engines: {node: '>=4'} hasBin: true + csstype@3.2.0: + resolution: {integrity: sha512-si++xzRAY9iPp60roQiFta7OFbhrgvcthrhlNAGeQptSY25uJjkfUV8OArC3KLocB8JT8ohz+qgxWCmz8RhjIg==} + cz-conventional-changelog@3.3.0: resolution: {integrity: sha512-U466fIzU5U22eES5lTNiNbZ+d8dfcHcssH4o7QsdWaCcRs/feIPCxKYSWkYBNs5mny7MvEfwpTLWjvbm94hecw==} engines: {node: '>= 10'} @@ -1849,9 +2304,17 @@ packages: decode-named-character-reference@1.2.0: resolution: {integrity: sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q==} + decompress-response@6.0.0: + resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} + engines: {node: '>=10'} + dedent@0.7.0: resolution: {integrity: sha512-Q6fKUPqnAHAyhiUgFU7BUzLiv0kd8saH9al7tnu5Q/okj6dnupxyTgFIBjVzJATdfIAm9NAsvXNzjaKa+bxVyA==} + deep-extend@0.6.0: + resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} + engines: {node: '>=4.0.0'} + deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} @@ -1988,6 +2451,9 @@ packages: effect@3.16.12: resolution: {integrity: sha512-N39iBk0K71F9nb442TLbTkjl24FLUzuvx2i1I2RsEAQsdAdUTuUoW0vlfUXgkMTUOnYqKnWcFfqw4hK4Pw27hg==} + electron-to-chromium@1.5.252: + resolution: {integrity: sha512-53uTpjtRgS7gjIxZ4qCgFdNO2q+wJt/Z8+xAvxbCqXPJrY6h7ighUkadQmNMXH96crtpa6gPFNP7BF4UBGDuaA==} + emmet@2.4.11: resolution: {integrity: sha512-23QPJB3moh/U9sT4rQzGgeyyGIrcM+GH5uVYg2C6wZIxAIJq7Ng3QLT79tl8FUwDXhyq9SusfknOrofAKqvgyQ==} @@ -2012,6 +2478,13 @@ packages: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} engines: {node: '>= 0.8'} + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + + enhanced-resolve@5.18.3: + resolution: {integrity: sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==} + engines: {node: '>=10.13.0'} + enquirer@2.4.1: resolution: {integrity: sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==} engines: {node: '>=8.6'} @@ -2195,9 +2668,28 @@ packages: resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} engines: {node: '>= 0.6'} + event-target-shim@5.0.1: + resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} + engines: {node: '>=6'} + eventemitter3@5.0.1: resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==} + events@3.3.0: + resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} + engines: {node: '>=0.8.x'} + + execa@5.1.1: + resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} + engines: {node: '>=10'} + + exif-parser@0.1.12: + resolution: {integrity: sha512-c2bQfLNbMzLPmzQuOr8fy0csy84WmwnER81W88DzTp9CYNPJ6yzOj2EZAh9pywYpqHnshVLHQJ8WzldAyfY+Iw==} + + expand-template@2.0.3: + resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} + engines: {node: '>=6'} + expand-tilde@2.0.2: resolution: {integrity: sha512-A5EmesHW6rfnZ9ysHQjPdJRni0SRar0tjtG5MNtm9n5TUvsYU8oozprtRD4AqHxcZWWlVuAmQo2nWKfN9oyjTw==} engines: {node: '>=0.10.0'} @@ -2279,6 +2771,10 @@ packages: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} + file-type@16.5.4: + resolution: {integrity: sha512-/yFHK0aGjFEgDJjEKP0pWCplsPFPhwyfwevf/pVxiN0tmE4L9LmwWxWukdJSHdoCli4VgQLehjJtwQBnqmsKcw==} + engines: {node: '>=10'} + fill-range@7.1.1: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} @@ -2354,10 +2850,30 @@ packages: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} + framer-motion@12.23.24: + resolution: {integrity: sha512-HMi5HRoRCTou+3fb3h9oTLyJGBxHfW+HnNE25tAXOvVx/IvwMHK0cx7IR4a2ZU6sh3IX1Z+4ts32PcYBOqka8w==} + peerDependencies: + '@emotion/is-prop-valid': '*' + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/is-prop-valid': + optional: true + react: + optional: true + react-dom: + optional: true + fresh@0.5.2: resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} engines: {node: '>= 0.6'} + from2@2.3.0: + resolution: {integrity: sha512-OMcX/4IC/uqEPVgGeyfN22LJk6AZrMkRZHxcHBMBvHScDGgwTm2GT2Wkgtocyd3JfZffjj2kYUDXXII0Fk9W0g==} + + fs-constants@1.0.0: + resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} + fs-extra@7.0.1: resolution: {integrity: sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==} engines: {node: '>=6 <7 || >=8'} @@ -2388,6 +2904,10 @@ packages: functions-have-names@1.2.3: resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + get-caller-file@2.0.5: resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} engines: {node: 6.* || 8.* || >= 10.*} @@ -2404,6 +2924,10 @@ packages: resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} engines: {node: '>= 0.4'} + get-stream@6.0.1: + resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} + engines: {node: '>=10'} + get-symbol-description@1.1.0: resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==} engines: {node: '>= 0.4'} @@ -2411,6 +2935,9 @@ packages: get-tsconfig@4.12.0: resolution: {integrity: sha512-LScr2aNr2FbjAjZh2C6X6BxRx1/x+aTDExct/xyq2XKbYOiG5c0aK7pMsSuyc0brz3ibr/lbQiHD9jzt4lccJw==} + gifwrap@0.10.1: + resolution: {integrity: sha512-2760b1vpJHNmLzZ/ubTtNnEx5WApN/PYWJvXvgS+tL1egTTthayFYIQQNi136FLEDcN/IyEY2EcGpIITD6eYUw==} + giget@2.0.0: resolution: {integrity: sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==} hasBin: true @@ -2420,6 +2947,9 @@ packages: engines: {node: '>=16'} hasBin: true + github-from-package@0.0.0: + resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} + github-slugger@2.0.0: resolution: {integrity: sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==} @@ -2507,6 +3037,10 @@ packages: resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} engines: {node: '>= 0.4'} + has@1.0.4: + resolution: {integrity: sha512-qdSAmqLF6209RFj4VVItywPMbm3vWylknmB3nvNiUIs72xAimcM8nVYxYr7ncvZq5qzk9MKIZR8ijqD/1QuYjQ==} + engines: {node: '>= 0.4.0'} + hasown@2.0.2: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} @@ -2595,10 +3129,18 @@ packages: resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==} engines: {node: '>= 0.8'} + https-proxy-agent@5.0.1: + resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} + engines: {node: '>= 6'} + human-id@4.1.2: resolution: {integrity: sha512-v/J+4Z/1eIJovEBdlV5TYj1IR+ZiohcYGRY+qN/oC9dAfKzVT023N/Bgw37hrKCoVRBvk3bqyzpr2PP5YeTMSg==} hasBin: true + human-signals@2.1.0: + resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} + engines: {node: '>=10.17.0'} + husky@9.1.7: resolution: {integrity: sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==} engines: {node: '>=18'} @@ -2626,6 +3168,9 @@ packages: resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} engines: {node: '>= 4'} + image-q@4.0.0: + resolution: {integrity: sha512-PfJGVgIfKQJuq3s0tTDOKtztksibuUEbJQIYT3by6wctQo+Rdlh7ef4evJ5NCdxY4CfMbvFkocEwbl4BF8RlJw==} + import-fresh@3.3.1: resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} engines: {node: '>=6'} @@ -2662,10 +3207,18 @@ packages: resolution: {integrity: sha512-QAgPDQMEgrDssk1XiwwHoOGYF9BAbUcc1+j+FhEvaOt8/cKRqyLn0U5qA6F74fGhTMGxf92pOvPBeh29jQJDTQ==} engines: {node: '>=12.0.0'} + inquirer@8.2.7: + resolution: {integrity: sha512-UjOaSel/iddGZJ5xP/Eixh6dY1XghiBw4XK13rCCIJcJfyhhoul/7KhLLUGtebEj6GDYM6Vnx/mVsjx2L/mFIA==} + engines: {node: '>=12.0.0'} + internal-slot@1.1.0: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} + into-stream@6.0.0: + resolution: {integrity: sha512-XHbaOAvP+uFKUFsOgoNPRjLkwB+I22JFPFe5OjTkQ0nwgj6+pSjb4NmB6VMxaPshLiOf+zcpOCBQuLwC1KHhZA==} + engines: {node: '>=10'} + ipaddr.js@1.9.1: resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} engines: {node: '>= 0.10'} @@ -2710,6 +3263,9 @@ packages: resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} engines: {node: '>= 0.4'} + is-core-module@2.9.0: + resolution: {integrity: sha512-+5FPy5PnwmO3lvfMb0AsoPaBG+5KHUI0wYFXOtYPnVVVspTFUuMZNfNaNVRt3FZadstu2c8x23vykRW/NBoU6A==} + is-data-view@1.0.2: resolution: {integrity: sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==} engines: {node: '>= 0.4'} @@ -2849,12 +3405,18 @@ packages: resolution: {integrity: sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==} engines: {node: '>=16'} + isarray@1.0.0: + resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} + isarray@2.0.5: resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + isomorphic-fetch@3.0.0: + resolution: {integrity: sha512-qvUtwJ3j6qwsF3jLxkZ72qCgjMysPzDfeV240JHiGZsANBYd+EEuu35v7dfrJ9Up0Ak07D7GGSkGhCHTqg/5wA==} + iterator.prototype@1.1.5: resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==} engines: {node: '>= 0.4'} @@ -2863,6 +3425,9 @@ packages: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true + jpeg-js@0.4.4: + resolution: {integrity: sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -2872,6 +3437,17 @@ packages: js-yaml@4.1.0: resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} + hasBin: true + + jsesc@2.5.2: + resolution: {integrity: sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==} + engines: {node: '>=4'} + hasBin: true + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true json-buffer@3.0.1: resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} @@ -2888,6 +3464,11 @@ packages: json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + jsonc-parser@2.3.1: resolution: {integrity: sha512-H8jvkz1O50L3dMZCsLqiuB2tA7muqbSg1AtGEkN0leAqGjsUzDJir3Zwr02BhqdcITPg3ei3mZ+HjMocAknhhg==} @@ -2940,68 +3521,74 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} - lightningcss-darwin-arm64@1.30.1: - resolution: {integrity: sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ==} + lightningcss-android-arm64@1.30.2: + resolution: {integrity: sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.30.2: + resolution: {integrity: sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [darwin] - lightningcss-darwin-x64@1.30.1: - resolution: {integrity: sha512-k1EvjakfumAQoTfcXUcHQZhSpLlkAuEkdMBsI/ivWw9hL+7FtilQc0Cy3hrx0AAQrVtQAbMI7YjCgYgvn37PzA==} + lightningcss-darwin-x64@1.30.2: + resolution: {integrity: sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [darwin] - lightningcss-freebsd-x64@1.30.1: - resolution: {integrity: sha512-kmW6UGCGg2PcyUE59K5r0kWfKPAVy4SltVeut+umLCFoJ53RdCUWxcRDzO1eTaxf/7Q2H7LTquFHPL5R+Gjyig==} + lightningcss-freebsd-x64@1.30.2: + resolution: {integrity: sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [freebsd] - lightningcss-linux-arm-gnueabihf@1.30.1: - resolution: {integrity: sha512-MjxUShl1v8pit+6D/zSPq9S9dQ2NPFSQwGvxBCYaBYLPlCWuPh9/t1MRS8iUaR8i+a6w7aps+B4N0S1TYP/R+Q==} + lightningcss-linux-arm-gnueabihf@1.30.2: + resolution: {integrity: sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==} engines: {node: '>= 12.0.0'} cpu: [arm] os: [linux] - lightningcss-linux-arm64-gnu@1.30.1: - resolution: {integrity: sha512-gB72maP8rmrKsnKYy8XUuXi/4OctJiuQjcuqWNlJQ6jZiWqtPvqFziskH3hnajfvKB27ynbVCucKSm2rkQp4Bw==} + lightningcss-linux-arm64-gnu@1.30.2: + resolution: {integrity: sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - lightningcss-linux-arm64-musl@1.30.1: - resolution: {integrity: sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ==} + lightningcss-linux-arm64-musl@1.30.2: + resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - lightningcss-linux-x64-gnu@1.30.1: - resolution: {integrity: sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw==} + lightningcss-linux-x64-gnu@1.30.2: + resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - lightningcss-linux-x64-musl@1.30.1: - resolution: {integrity: sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ==} + lightningcss-linux-x64-musl@1.30.2: + resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - lightningcss-win32-arm64-msvc@1.30.1: - resolution: {integrity: sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA==} + lightningcss-win32-arm64-msvc@1.30.2: + resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [win32] - lightningcss-win32-x64-msvc@1.30.1: - resolution: {integrity: sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg==} + lightningcss-win32-x64-msvc@1.30.2: + resolution: {integrity: sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [win32] - lightningcss@1.30.1: - resolution: {integrity: sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg==} + lightningcss@1.30.2: + resolution: {integrity: sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==} engines: {node: '>= 12.0.0'} lines-and-columns@1.2.4: @@ -3095,12 +3682,16 @@ packages: loose-envify@1.4.0: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} - magic-string@0.30.19: - resolution: {integrity: sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==} + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} magicast@0.3.5: resolution: {integrity: sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==} @@ -3184,6 +3775,9 @@ packages: merge-descriptors@1.0.3: resolution: {integrity: sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==} + merge-stream@2.0.0: + resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} + merge2@1.4.1: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} @@ -3332,6 +3926,10 @@ packages: resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} engines: {node: '>=6'} + mimic-response@3.1.0: + resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} + engines: {node: '>=10'} + minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} @@ -3345,15 +3943,38 @@ packages: minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + mkdirp-classic@0.5.3: + resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} + mkdirp@0.5.6: resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==} hasBin: true - mri@1.2.0: - resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} - engines: {node: '>=4'} + motion-dom@12.23.23: + resolution: {integrity: sha512-n5yolOs0TQQBRUFImrRfs/+6X4p3Q4n1dUEqt/H58Vx7OW6RF+foWEgmTVDhIWJIMXOuNNL0apKH2S16en9eiA==} - mrmime@2.0.1: + motion-utils@12.23.6: + resolution: {integrity: sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==} + + motion@12.23.24: + resolution: {integrity: sha512-Rc5E7oe2YZ72N//S3QXGzbnXgqNrTESv8KKxABR20q2FLch9gHLo0JLyYo2hZ238bZ9Gx6cWhj9VO0IgwbMjCw==} + peerDependencies: + '@emotion/is-prop-valid': '*' + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/is-prop-valid': + optional: true + react: + optional: true + react-dom: + optional: true + + mri@1.2.0: + resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} + engines: {node: '>=4'} + + mrmime@2.0.1: resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} engines: {node: '>=10'} @@ -3366,6 +3987,9 @@ packages: muggle-string@0.4.1: resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==} + multistream@4.1.0: + resolution: {integrity: sha512-J1XDiAmmNpRCBfIWJv+n0ymC4ABcf/Pl+5YvC5B/D2f/2+8PtHvCNxMPKiQcZyi922Hq69J2YOpb1pTywfifyw==} + mute-stream@0.0.7: resolution: {integrity: sha512-r65nCZhrbXXb6dXOACihYApHw2Q6pV0M3V0PSxd74N0+D8nzAdEAITq2oAjA1jVnKI+tGvEBUpqiMh0+rW6zDQ==} @@ -3381,6 +4005,9 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + napi-build-utils@1.0.2: + resolution: {integrity: sha512-ONmRUqK7zj7DWX0D9ADe03wbwOBZxNAfF20PlGfCWQcD3+/MakShIHrMqx9YwPTfxDdF1zLeL+RGZiR9kGMLdg==} + natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} @@ -3399,6 +4026,10 @@ packages: nlcst-to-string@4.0.0: resolution: {integrity: sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA==} + node-abi@3.85.0: + resolution: {integrity: sha512-zsFhmbkAzwhTft6nd3VxcG0cvJsT70rL+BIGHWVq5fi6MwGrHwzqKaxXE+Hl2GmnGItnDKPPkO5/LQqjVkIdFg==} + engines: {node: '>=10'} + node-fetch-native@1.6.7: resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==} @@ -3418,10 +4049,20 @@ packages: node-mock-http@1.0.3: resolution: {integrity: sha512-jN8dK25fsfnMrVsEhluUTPkBFY+6ybu7jSB1n+ri/vOGjJxU8J9CZhpSGkHXSkFjtUhbmoncG/YG9ta5Ludqog==} + node-releases@2.0.27: + resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} + + node-vibrant@4.0.3: + resolution: {integrity: sha512-kzoIuJK90BH/k65Avt077JCX4Nhqz1LNc8cIOm2rnYEvFdJIYd8b3SQwU1MTpzcHtr8z8jxkl1qdaCfbP3olFg==} + normalize-path@3.0.0: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} engines: {node: '>=0.10.0'} + npm-run-path@4.0.1: + resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} + engines: {node: '>=8'} + nth-check@2.1.1: resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} @@ -3464,6 +4105,9 @@ packages: ohash@2.0.11: resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==} + omggif@1.0.10: + resolution: {integrity: sha512-LMJTtvgc/nugXj0Vcrrs68Mn2D1r0zf630VNtqtpI1FEO7e+O9FP4gqs9AcnBaSEeoHIPm28u6qgPR0oyEpGSw==} + on-finished@2.4.1: resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} engines: {node: '>= 0.8'} @@ -3522,6 +4166,10 @@ packages: resolution: {integrity: sha512-ZBxxZ5sL2HghephhpGAQdoskxplTwr7ICaehZwLIlfL6acuVgZPm8yBNuRAFBGEqtD/hmUeq9eqLg2ys9Xr/yw==} engines: {node: '>=8'} + p-is-promise@3.0.0: + resolution: {integrity: sha512-Wo8VsW4IRQSKVXsJCn7TomUaVtyfjVDn3nUP7kE967BQk0CwFpdbZs0X0uk5sW9mkBa9eNM7hCMaG93WUAwxYQ==} + engines: {node: '>=8'} + p-limit@2.3.0: resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} engines: {node: '>=6'} @@ -3579,6 +4227,9 @@ packages: pako@0.2.9: resolution: {integrity: sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==} + pako@1.0.11: + resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} + parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -3636,6 +4287,10 @@ packages: pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + peek-readable@4.1.0: + resolution: {integrity: sha512-ZI3LnwUv5nOGbQzD9c2iDG6toheuXSZP5esSHBjopsXH4dg19soufvpUGA3uohi5anFtGb2lhAVdHzH6R/Evvg==} + engines: {node: '>=8'} + perfect-debounce@1.0.0: resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==} @@ -3688,13 +4343,38 @@ packages: resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==} engines: {node: '>=6'} + pixelmatch@4.0.2: + resolution: {integrity: sha512-J8B6xqiO37sU/gkcMglv6h5Jbd9xNER7aHzpfRdNmV4IbQBzBpe4l9XmbG+xPF/znacgu2jfEw+wHffaq/YkXA==} + hasBin: true + + pkg-fetch@3.4.2: + resolution: {integrity: sha512-0+uijmzYcnhC0hStDjm/cl2VYdrmVVBpe7Q8k9YBojxmR5tG8mvR9/nooQq3QSXiQqORDVOTY3XqMEqJVIzkHA==} + hasBin: true + pkg-types@2.3.0: resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==} + pkg@5.8.1: + resolution: {integrity: sha512-CjBWtFStCfIiT4Bde9QpJy0KeH19jCfwZRJqHFDFXfhUklCx8JoFmMj3wgnEYIwGmZVNkhsStPHEOnrtrQhEXA==} + hasBin: true + peerDependencies: + node-notifier: '>=9.0.1' + peerDependenciesMeta: + node-notifier: + optional: true + plimit-lit@1.6.1: resolution: {integrity: sha512-B7+VDyb8Tl6oMJT9oSO2CW8XC/T4UcJGrwOVoNGwOQsQYhlpfajmrMj5xeejqaASq3V/EqThyOeATEOMuSEXiA==} engines: {node: '>=12'} + pngjs@3.4.0: + resolution: {integrity: sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w==} + engines: {node: '>=4.0.0'} + + pngjs@6.0.0: + resolution: {integrity: sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg==} + engines: {node: '>=12.13.0'} + possible-typed-array-names@1.1.0: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} @@ -3729,10 +4409,19 @@ packages: resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} engines: {node: '>=0.10.0'} + prebuild-install@7.1.1: + resolution: {integrity: sha512-jAXscXWMcCK8GgCoHOfIr0ODh5ai8mj63L2nWrjuAgXE6tDyYGnx4/8o/rCgU+B4JSyZBKbeZqzhtwtC3ovxjw==} + engines: {node: '>=10'} + hasBin: true + prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} + prettier-plugin-astro@0.14.1: + resolution: {integrity: sha512-RiBETaaP9veVstE4vUwSIcdATj6dKmXljouXc/DDNwBSPTp8FRkLGDSGFClKsAFeeg+13SB0Z1JZvbD76bigJw==} + engines: {node: ^14.15.0 || >=16.0.0} + prettier@2.8.7: resolution: {integrity: sha512-yPngTo3aXUUmyuTjeTUT75txrf+aMh9FiD7q9ZE/i6r0bPb22g4FsE6Y338PQX1bmfy08i9QQCB7/rcUAVntfw==} engines: {node: '>=10.13.0'} @@ -3746,6 +4435,7 @@ packages: prettier@3.6.2: resolution: {integrity: sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==} engines: {node: '>=14'} + hasBin: true prisma@6.17.1: resolution: {integrity: sha512-ac6h0sM1Tg3zu8NInY+qhP/S9KhENVaw9n1BrGKQVFu05JT5yT5Qqqmb8tMRIE3ZXvVj4xcRA5yfrsy4X7Yy5g==} @@ -3761,6 +4451,17 @@ packages: resolution: {integrity: sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==} engines: {node: '>=6'} + process-nextick-args@2.0.1: + resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + + process@0.11.10: + resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==} + engines: {node: '>= 0.6.0'} + + progress@2.0.3: + resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==} + engines: {node: '>=0.4.0'} + prompts@2.4.2: resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} engines: {node: '>= 6'} @@ -3781,6 +4482,9 @@ packages: proxy-from-env@1.1.0: resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + pump@3.0.3: + resolution: {integrity: sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} @@ -3816,17 +4520,45 @@ packages: rc9@2.1.2: resolution: {integrity: sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==} + rc@1.2.8: + resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} + hasBin: true + + react-dom@19.2.0: + resolution: {integrity: sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==} + peerDependencies: + react: ^19.2.0 + react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + react-refresh@0.17.0: + resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} + engines: {node: '>=0.10.0'} + + react@19.2.0: + resolution: {integrity: sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==} + engines: {node: '>=0.10.0'} + read-yaml-file@1.1.0: resolution: {integrity: sha512-VIMnQi/Z4HT2Fxuwg5KrY174U1VdUIASQVWXXyqtNRtxSr9IYkn1rsI6Tb6HsrHCmB7gVpNwX6JxPTHcH6IoTA==} engines: {node: '>=6'} + readable-stream@2.3.8: + resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + readable-stream@3.6.2: resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} engines: {node: '>= 6'} + readable-stream@4.7.0: + resolution: {integrity: sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + readable-web-to-node-stream@3.0.4: + resolution: {integrity: sha512-9nX56alTf5bwXQ3ZDipHJhusu9NTQJ/CVPtb/XHAJCXihZeitfJvIRS4GqQ/mfIoOE3IelHMrpayVrosdHBuLw==} + engines: {node: '>=8'} + readdirp@3.6.0: resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} engines: {node: '>=8.10.0'} @@ -3853,6 +4585,9 @@ packages: resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} engines: {node: '>= 0.4'} + regenerator-runtime@0.13.11: + resolution: {integrity: sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==} + regex-recursion@6.0.2: resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==} @@ -3938,8 +4673,14 @@ packages: resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + resolve@1.22.11: + resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==} + engines: {node: '>= 0.4'} + hasBin: true + resolve@2.0.0-next.5: resolution: {integrity: sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==} + hasBin: true restore-cursor@2.0.0: resolution: {integrity: sha512-6IzJLuGi4+R14vwagDHX+JrXmPVtPpn4mffDJ1UdR7/Edm87fl6yi8mMBIVvFtJaNTUvjughmW4hwLhRG7gC1Q==} @@ -3992,10 +4733,16 @@ packages: rxjs@7.8.2: resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} + s.color@0.0.15: + resolution: {integrity: sha512-AUNrbEUHeKY8XsYr/DYpl+qk5+aM+DChopnWOPEzn8YKzOhv4l2zH6LzZms3tOZP3wwdOyc0RmTciyi46HLIuA==} + safe-array-concat@1.1.3: resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==} engines: {node: '>=0.4'} + safe-buffer@5.1.2: + resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} + safe-buffer@5.2.1: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} @@ -4014,15 +4761,23 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + sass-formatter@0.7.9: + resolution: {integrity: sha512-CWZ8XiSim+fJVG0cFLStwDvft1VI7uvXdCNJYXhDvowiv+DsbD1nXLiQ4zrE5UBvj5DWZJ93cwN0NX5PMsr1Pw==} + sax@1.4.1: resolution: {integrity: sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==} + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + semver@6.3.1: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true semver@7.7.2: resolution: {integrity: sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==} engines: {node: '>=10'} + hasBin: true send@0.19.0: resolution: {integrity: sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==} @@ -4085,6 +4840,12 @@ packages: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} + simple-concat@1.0.1: + resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==} + + simple-get@4.0.1: + resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} + sisteransi@1.0.5: resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} @@ -4133,6 +4894,9 @@ packages: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} engines: {node: '>= 0.4'} + stream-meter@1.0.4: + resolution: {integrity: sha512-4sOEtrbgFotXwnEuzzsQBYEV1elAeFSO8rSGeTwabuX1RRn/kEq9JVH7I0MRBhKVRR0sJkr0M0QCH7yOLf9fhQ==} + stream-replace-string@2.0.0: resolution: {integrity: sha512-TlnjJ1C0QrmxRNrON00JvaFFlNh5TTG00APw23j74ET7gkQpTASi6/L2fuiav8pzK715HXtUeClpBTw2NPSn6w==} @@ -4167,6 +4931,9 @@ packages: resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} engines: {node: '>= 0.4'} + string_decoder@1.1.1: + resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} + string_decoder@1.3.0: resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} @@ -4197,16 +4964,31 @@ packages: resolution: {integrity: sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==} engines: {node: '>=8'} + strip-final-newline@2.0.0: + resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} + engines: {node: '>=6'} + + strip-json-comments@2.0.1: + resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} + engines: {node: '>=0.10.0'} + strip-json-comments@3.1.1: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} + strtok3@6.3.0: + resolution: {integrity: sha512-fZtbhtvI9I48xDSywd/somNqgUHl2L2cstmXCCif0itOf96jeW18MBSyrLuNicYQVkvpOxkZtkzujiTJ9LW5Jw==} + engines: {node: '>=10'} + style-to-js@1.1.18: resolution: {integrity: sha512-JFPn62D4kJaPTnhFUI244MThx+FEGbi+9dw1b9yBBQ+1CZpV7QAT8kUtJ7b7EUNdHajjF/0x8fT+16oLJoojLg==} style-to-object@1.0.11: resolution: {integrity: sha512-5A560JmXr7wDyGLK12Nq/EYS38VkGlglVzkis1JEdbGWSnbQIEhZzTJhzURXN5/8WwwFCs/f/VVcmkTppbXLow==} + suf-log@2.5.3: + resolution: {integrity: sha512-KvC8OPjzdNOe+xQ4XWJV2whQA0aM1kGVczMQ8+dStAO6KfEB140JEVQ9dE76ONZ0/Ylf67ni4tILPJB41U0eow==} + supports-color@5.5.0: resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} engines: {node: '>=4'} @@ -4237,6 +5019,20 @@ packages: peerDependencies: express: '>=4.0.0 || >=5.0.0-beta' + tailwindcss@4.1.17: + resolution: {integrity: sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q==} + + tapable@2.3.0: + resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} + engines: {node: '>=6'} + + tar-fs@2.1.4: + resolution: {integrity: sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==} + + tar-stream@2.2.0: + resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} + engines: {node: '>=6'} + temp@0.9.4: resolution: {integrity: sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==} engines: {node: '>=6.0.0'} @@ -4255,9 +5051,15 @@ packages: through@2.3.8: resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==} + timm@1.7.1: + resolution: {integrity: sha512-IjZc9KIotudix8bMaBW6QvMuq64BrJWFs1+4V0lXwWGQZwH+LnX87doAYhem4caOEusRP9/g6jVDQmZ8XOk1nw==} + tiny-inflate@1.0.3: resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==} + tinycolor2@1.6.0: + resolution: {integrity: sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==} + tinyexec@1.0.1: resolution: {integrity: sha512-5uC6DDlmeqiOwCPmK9jMSdOuZTh8bU39Ys6yidB+UTt5hfZUPGAypSgFRiEp+jbi9qH40BLDvy85jIU88wKSqw==} @@ -4269,6 +5071,10 @@ packages: resolution: {integrity: sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==} engines: {node: '>=0.6.0'} + to-fast-properties@2.0.0: + resolution: {integrity: sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==} + engines: {node: '>=4'} + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} @@ -4277,6 +5083,10 @@ packages: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} + token-types@4.2.1: + resolution: {integrity: sha512-6udB24Q737UD/SDsKAHI9FCRP7Bqc9D/MQUV02ORQg5iskjtLJlZJNdN4kKtcdtwCeWIwIHDGaUsTsCCAa8sFQ==} + engines: {node: '>=10'} + tr46@0.0.3: resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} @@ -4322,6 +5132,9 @@ packages: engines: {node: '>=18.0.0'} hasBin: true + tunnel-agent@0.6.0: + resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} + turbo-darwin-64@2.5.8: resolution: {integrity: sha512-Dh5bCACiHO8rUXZLpKw+m3FiHtAp2CkanSyJre+SInEvEr5kIxjGvCK/8MFX8SFRjQuhjtvpIvYYZJB4AGCxNQ==} cpu: [x64] @@ -4404,6 +5217,7 @@ packages: typescript@5.9.2: resolution: {integrity: sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==} engines: {node: '>=14.17'} + hasBin: true ufo@1.6.1: resolution: {integrity: sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==} @@ -4418,6 +5232,9 @@ packages: uncrypto@0.1.3: resolution: {integrity: sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==} + undici-types@5.26.5: + resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} + undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} @@ -4544,6 +5361,12 @@ packages: uploadthing: optional: true + update-browserslist-db@1.1.4: + resolution: {integrity: sha512-q0SPT4xyU84saUX+tomz1WLkxUbuaJnR1xWt17M7fJtEJigJeWUNGUqrauFXsHnqev9y9JTRGwk13tFBuKby4A==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} @@ -4551,6 +5374,9 @@ packages: resolution: {integrity: sha512-KMWqdlOcjCYdtIJpicDSFBQ8nFwS2i9sslAd6f4+CBGcU4gist2REnr2fxj2YocvJFxSF3ZOHLYLVZnUxv4BZQ==} engines: {node: '>=0.10.0'} + utif2@4.1.0: + resolution: {integrity: sha512-+oknB9FHrJ7oW7A2WZYajOcv4FcDR4CfoGB0dPNfxbi4GO05RRnFmt5oa23+9w32EanrYcSJWspUiJkLMs+37w==} + util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} @@ -4583,7 +5409,7 @@ packages: '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 jiti: '>=1.21.0' less: '*' - lightningcss: ^1.21.0 + lightningcss: ^1.30.2 sass: '*' sass-embedded: '*' stylus: '*' @@ -4738,6 +5564,9 @@ packages: webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + whatwg-fetch@3.6.20: + resolution: {integrity: sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==} + whatwg-url@5.0.0: resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} @@ -4768,6 +5597,7 @@ packages: which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} + hasBin: true widest-line@5.0.0: resolution: {integrity: sha512-c9bZp7b5YtRj2wOe6dlj32MK+Bx/M/d+9VB2SHM1OtsUHR0aV0tdP6DWh/iMt0kWi1t5g1Iudu6hQRNd1A4PVA==} @@ -4785,6 +5615,10 @@ packages: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} + wrap-ansi@6.2.0: + resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} + engines: {node: '>=8'} + wrap-ansi@7.0.0: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} @@ -4819,6 +5653,9 @@ packages: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + yaml-language-server@1.15.0: resolution: {integrity: sha512-N47AqBDCMQmh6mBLmI6oqxryHRzi33aPFPsJhYy3VTUGCdLHYjGh4FZzpUjRlphaADBBkDmnkM/++KNIOHi5Rw==} hasBin: true @@ -4836,10 +5673,18 @@ packages: engines: {node: '>= 14.6'} hasBin: true + yargs-parser@20.2.9: + resolution: {integrity: sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==} + engines: {node: '>=10'} + yargs-parser@21.1.1: resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} engines: {node: '>=12'} + yargs@16.2.0: + resolution: {integrity: sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==} + engines: {node: '>=10'} + yargs@17.7.2: resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} engines: {node: '>=12'} @@ -4908,9 +5753,9 @@ snapshots: openapi-types: 12.1.3 z-schema: 5.0.5 - '@astrojs/check@0.9.5(prettier@3.6.2)(typescript@5.9.2)': + '@astrojs/check@0.9.5(prettier-plugin-astro@0.14.1)(prettier@3.6.2)(typescript@5.9.2)': dependencies: - '@astrojs/language-server': 2.15.5(prettier@3.6.2)(typescript@5.9.2) + '@astrojs/language-server': 2.15.5(prettier-plugin-astro@0.14.1)(prettier@3.6.2)(typescript@5.9.2) chokidar: 4.0.3 kleur: 4.1.5 typescript: 5.9.2 @@ -4923,7 +5768,7 @@ snapshots: '@astrojs/internal-helpers@0.7.4': {} - '@astrojs/language-server@2.15.5(prettier@3.6.2)(typescript@5.9.2)': + '@astrojs/language-server@2.15.5(prettier-plugin-astro@0.14.1)(prettier@3.6.2)(typescript@5.9.2)': dependencies: '@astrojs/compiler': 2.13.0 '@astrojs/yaml2ts': 0.2.2 @@ -4945,6 +5790,7 @@ snapshots: vscode-uri: 3.1.0 optionalDependencies: prettier: 3.6.2 + prettier-plugin-astro: 0.14.1 transitivePeerDependencies: - typescript @@ -4974,12 +5820,12 @@ snapshots: transitivePeerDependencies: - supports-color - '@astrojs/mdx@4.3.8(astro@5.15.1(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.1)(rollup@4.52.5)(tsx@4.20.6)(typescript@5.9.2)(yaml@2.8.1))': + '@astrojs/mdx@4.3.8(astro@5.15.1(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.52.5)(tsx@4.20.6)(typescript@5.9.2)(yaml@2.8.1))': dependencies: '@astrojs/markdown-remark': 6.3.8 '@mdx-js/mdx': 3.1.1 acorn: 8.15.0 - astro: 5.15.1(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.1)(rollup@4.52.5)(tsx@4.20.6)(typescript@5.9.2)(yaml@2.8.1) + astro: 5.15.1(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.52.5)(tsx@4.20.6)(typescript@5.9.2)(yaml@2.8.1) es-module-lexer: 1.7.0 estree-util-visit: 2.0.0 hast-util-to-html: 9.0.5 @@ -4997,23 +5843,51 @@ snapshots: dependencies: prismjs: 1.30.0 + '@astrojs/react@4.4.2(@types/node@20.19.22)(@types/react-dom@19.2.3(@types/react@19.2.4))(@types/react@19.2.4)(jiti@2.6.1)(lightningcss@1.30.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(tsx@4.20.6)(yaml@2.8.1)': + dependencies: + '@types/react': 19.2.4 + '@types/react-dom': 19.2.3(@types/react@19.2.4) + '@vitejs/plugin-react': 4.7.0(vite@6.4.1(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.1)) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + ultrahtml: 1.6.0 + vite: 6.4.1(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.1) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + '@astrojs/sitemap@3.6.0': dependencies: sitemap: 8.0.1 stream-replace-string: 2.0.0 zod: 3.25.76 - '@astrojs/starlight@0.36.1(astro@5.15.1(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.1)(rollup@4.52.5)(tsx@4.20.6)(typescript@5.9.2)(yaml@2.8.1))': + '@astrojs/starlight-tailwind@4.0.2(@astrojs/starlight@0.36.1(astro@5.15.1(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.52.5)(tsx@4.20.6)(typescript@5.9.2)(yaml@2.8.1)))(tailwindcss@4.1.17)': + dependencies: + '@astrojs/starlight': 0.36.1(astro@5.15.1(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.52.5)(tsx@4.20.6)(typescript@5.9.2)(yaml@2.8.1)) + tailwindcss: 4.1.17 + + '@astrojs/starlight@0.36.1(astro@5.15.1(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.52.5)(tsx@4.20.6)(typescript@5.9.2)(yaml@2.8.1))': dependencies: '@astrojs/markdown-remark': 6.3.8 - '@astrojs/mdx': 4.3.8(astro@5.15.1(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.1)(rollup@4.52.5)(tsx@4.20.6)(typescript@5.9.2)(yaml@2.8.1)) + '@astrojs/mdx': 4.3.8(astro@5.15.1(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.52.5)(tsx@4.20.6)(typescript@5.9.2)(yaml@2.8.1)) '@astrojs/sitemap': 3.6.0 '@pagefind/default-ui': 1.4.0 '@types/hast': 3.0.4 '@types/js-yaml': 4.0.9 '@types/mdast': 4.0.4 - astro: 5.15.1(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.1)(rollup@4.52.5)(tsx@4.20.6)(typescript@5.9.2)(yaml@2.8.1) - astro-expressive-code: 0.41.3(astro@5.15.1(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.1)(rollup@4.52.5)(tsx@4.20.6)(typescript@5.9.2)(yaml@2.8.1)) + astro: 5.15.1(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.52.5)(tsx@4.20.6)(typescript@5.9.2)(yaml@2.8.1) + astro-expressive-code: 0.41.3(astro@5.15.1(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.52.5)(tsx@4.20.6)(typescript@5.9.2)(yaml@2.8.1)) bcp-47: 2.1.0 hast-util-from-html: 2.0.3 hast-util-select: 6.0.4 @@ -5054,25 +5928,145 @@ snapshots: '@babel/code-frame@7.27.1': dependencies: - '@babel/helper-validator-identifier': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 js-tokens: 4.0.0 picocolors: 1.1.1 + '@babel/compat-data@7.28.5': {} + + '@babel/core@7.28.5': + dependencies: + '@babel/code-frame': 7.27.1 + '@babel/generator': 7.28.5 + '@babel/helper-compilation-targets': 7.27.2 + '@babel/helper-module-transforms': 7.28.3(@babel/core@7.28.5) + '@babel/helpers': 7.28.4 + '@babel/parser': 7.28.5 + '@babel/template': 7.27.2 + '@babel/traverse': 7.28.5 + '@babel/types': 7.28.5 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.1 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.18.2': + dependencies: + '@babel/types': 7.28.5 + '@jridgewell/gen-mapping': 0.3.13 + jsesc: 2.5.2 + + '@babel/generator@7.28.5': + dependencies: + '@babel/parser': 7.28.5 + '@babel/types': 7.28.5 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.27.2': + dependencies: + '@babel/compat-data': 7.28.5 + '@babel/helper-validator-option': 7.27.1 + browserslist: 4.28.0 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.28.0': {} + + '@babel/helper-module-imports@7.27.1': + dependencies: + '@babel/traverse': 7.28.5 + '@babel/types': 7.28.5 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.28.3(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-module-imports': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + '@babel/traverse': 7.28.5 + transitivePeerDependencies: + - supports-color + + '@babel/helper-plugin-utils@7.27.1': {} + '@babel/helper-string-parser@7.27.1': {} '@babel/helper-validator-identifier@7.27.1': {} + '@babel/helper-validator-identifier@7.28.5': {} + + '@babel/helper-validator-option@7.27.1': {} + + '@babel/helpers@7.28.4': + dependencies: + '@babel/template': 7.27.2 + '@babel/types': 7.28.5 + + '@babel/parser@7.18.4': + dependencies: + '@babel/types': 7.28.5 + '@babel/parser@7.28.4': dependencies: - '@babel/types': 7.28.4 + '@babel/types': 7.28.5 + + '@babel/parser@7.28.5': + dependencies: + '@babel/types': 7.28.5 + + '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-plugin-utils': 7.27.1 '@babel/runtime@7.28.4': {} + '@babel/template@7.27.2': + dependencies: + '@babel/code-frame': 7.27.1 + '@babel/parser': 7.28.5 + '@babel/types': 7.28.5 + + '@babel/traverse@7.28.5': + dependencies: + '@babel/code-frame': 7.27.1 + '@babel/generator': 7.28.5 + '@babel/helper-globals': 7.28.0 + '@babel/parser': 7.28.5 + '@babel/template': 7.27.2 + '@babel/types': 7.28.5 + debug: 4.4.1 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.19.0': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + to-fast-properties: 2.0.0 + '@babel/types@7.28.4': dependencies: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.27.1 + '@babel/types@7.28.5': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + '@capsizecss/unpack@3.0.0': dependencies: fontkit: 2.0.4 @@ -5674,6 +6668,13 @@ snapshots: '@img/sharp-win32-x64@0.34.4': optional: true + '@inquirer/external-editor@1.0.2(@types/node@20.19.22)': + dependencies: + chardet: 2.1.0 + iconv-lite: 0.7.0 + optionalDependencies: + '@types/node': 20.19.22 + '@inquirer/external-editor@1.0.2(@types/node@24.8.1)': dependencies: chardet: 2.1.0 @@ -5681,8 +6682,93 @@ snapshots: optionalDependencies: '@types/node': 24.8.1 + '@jimp/bmp@0.22.12(@jimp/custom@0.22.12)': + dependencies: + '@jimp/custom': 0.22.12 + '@jimp/utils': 0.22.12 + bmp-js: 0.1.0 + + '@jimp/core@0.22.12': + dependencies: + '@jimp/utils': 0.22.12 + any-base: 1.1.0 + buffer: 5.7.1 + exif-parser: 0.1.12 + file-type: 16.5.4 + isomorphic-fetch: 3.0.0 + pixelmatch: 4.0.2 + tinycolor2: 1.6.0 + transitivePeerDependencies: + - encoding + + '@jimp/custom@0.22.12': + dependencies: + '@jimp/core': 0.22.12 + transitivePeerDependencies: + - encoding + + '@jimp/gif@0.22.12(@jimp/custom@0.22.12)': + dependencies: + '@jimp/custom': 0.22.12 + '@jimp/utils': 0.22.12 + gifwrap: 0.10.1 + omggif: 1.0.10 + + '@jimp/jpeg@0.22.12(@jimp/custom@0.22.12)': + dependencies: + '@jimp/custom': 0.22.12 + '@jimp/utils': 0.22.12 + jpeg-js: 0.4.4 + + '@jimp/plugin-resize@0.22.12(@jimp/custom@0.22.12)': + dependencies: + '@jimp/custom': 0.22.12 + '@jimp/utils': 0.22.12 + + '@jimp/png@0.22.12(@jimp/custom@0.22.12)': + dependencies: + '@jimp/custom': 0.22.12 + '@jimp/utils': 0.22.12 + pngjs: 6.0.0 + + '@jimp/tiff@0.22.12(@jimp/custom@0.22.12)': + dependencies: + '@jimp/custom': 0.22.12 + utif2: 4.1.0 + + '@jimp/types@0.22.12(@jimp/custom@0.22.12)': + dependencies: + '@jimp/bmp': 0.22.12(@jimp/custom@0.22.12) + '@jimp/custom': 0.22.12 + '@jimp/gif': 0.22.12(@jimp/custom@0.22.12) + '@jimp/jpeg': 0.22.12(@jimp/custom@0.22.12) + '@jimp/png': 0.22.12(@jimp/custom@0.22.12) + '@jimp/tiff': 0.22.12(@jimp/custom@0.22.12) + timm: 1.7.1 + + '@jimp/utils@0.22.12': + dependencies: + regenerator-runtime: 0.13.11 + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + '@jridgewell/sourcemap-codec@1.5.5': {} + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + '@jsdevtools/ono@7.1.3': {} '@manypkg/find-root@1.1.0': @@ -5804,6 +6890,8 @@ snapshots: dependencies: '@prisma/debug': 6.17.1 + '@rolldown/pluginutils@1.0.0-beta.27': {} + '@rollup/pluginutils@5.3.0(rollup@4.52.5)': dependencies: '@types/estree': 1.0.8 @@ -5924,10 +7012,101 @@ snapshots: dependencies: tslib: 2.8.1 + '@tailwindcss/node@4.1.17': + dependencies: + '@jridgewell/remapping': 2.3.5 + enhanced-resolve: 5.18.3 + jiti: 2.6.1 + lightningcss: 1.30.2 + magic-string: 0.30.21 + source-map-js: 1.2.1 + tailwindcss: 4.1.17 + + '@tailwindcss/oxide-android-arm64@4.1.17': + optional: true + + '@tailwindcss/oxide-darwin-arm64@4.1.17': + optional: true + + '@tailwindcss/oxide-darwin-x64@4.1.17': + optional: true + + '@tailwindcss/oxide-freebsd-x64@4.1.17': + optional: true + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.1.17': + optional: true + + '@tailwindcss/oxide-linux-arm64-gnu@4.1.17': + optional: true + + '@tailwindcss/oxide-linux-arm64-musl@4.1.17': + optional: true + + '@tailwindcss/oxide-linux-x64-gnu@4.1.17': + optional: true + + '@tailwindcss/oxide-linux-x64-musl@4.1.17': + optional: true + + '@tailwindcss/oxide-wasm32-wasi@4.1.17': + optional: true + + '@tailwindcss/oxide-win32-arm64-msvc@4.1.17': + optional: true + + '@tailwindcss/oxide-win32-x64-msvc@4.1.17': + optional: true + + '@tailwindcss/oxide@4.1.17': + optionalDependencies: + '@tailwindcss/oxide-android-arm64': 4.1.17 + '@tailwindcss/oxide-darwin-arm64': 4.1.17 + '@tailwindcss/oxide-darwin-x64': 4.1.17 + '@tailwindcss/oxide-freebsd-x64': 4.1.17 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.1.17 + '@tailwindcss/oxide-linux-arm64-gnu': 4.1.17 + '@tailwindcss/oxide-linux-arm64-musl': 4.1.17 + '@tailwindcss/oxide-linux-x64-gnu': 4.1.17 + '@tailwindcss/oxide-linux-x64-musl': 4.1.17 + '@tailwindcss/oxide-wasm32-wasi': 4.1.17 + '@tailwindcss/oxide-win32-arm64-msvc': 4.1.17 + '@tailwindcss/oxide-win32-x64-msvc': 4.1.17 + + '@tailwindcss/vite@4.1.17(vite@6.4.1(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.1))': + dependencies: + '@tailwindcss/node': 4.1.17 + '@tailwindcss/oxide': 4.1.17 + tailwindcss: 4.1.17 + vite: 6.4.1(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.1) + + '@tokenizer/token@0.3.0': {} + + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.28.5 + '@babel/types': 7.28.5 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.28.0 + + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.28.5 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.28.5 + '@babel/types': 7.28.5 + + '@types/babel__traverse@7.28.0': + dependencies: + '@babel/types': 7.28.5 + '@types/body-parser@1.19.6': dependencies: '@types/connect': 3.4.38 - '@types/node': 22.15.3 + '@types/node': 20.19.22 '@types/compression@1.8.1': dependencies: @@ -5936,11 +7115,11 @@ snapshots: '@types/connect@3.4.38': dependencies: - '@types/node': 22.15.3 + '@types/node': 20.19.22 '@types/conventional-commits-parser@5.0.1': dependencies: - '@types/node': 22.15.3 + '@types/node': 20.19.22 '@types/cors@2.8.19': dependencies: @@ -5958,7 +7137,7 @@ snapshots: '@types/express-serve-static-core@4.19.7': dependencies: - '@types/node': 22.15.3 + '@types/node': 20.19.22 '@types/qs': 6.14.0 '@types/range-parser': 1.2.7 '@types/send': 1.2.0 @@ -5980,6 +7159,11 @@ snapshots: '@types/http-errors@2.0.5': {} + '@types/inquirer@8.2.12': + dependencies: + '@types/through': 0.0.33 + rxjs: 7.8.2 + '@types/js-yaml@4.0.9': {} '@types/json-schema@7.0.15': {} @@ -5987,7 +7171,7 @@ snapshots: '@types/jsonwebtoken@9.0.10': dependencies: '@types/ms': 2.1.0 - '@types/node': 22.15.3 + '@types/node': 20.19.22 '@types/mdast@4.0.4': dependencies: @@ -6005,13 +7189,15 @@ snapshots: '@types/node@12.20.55': {} + '@types/node@16.9.1': {} + '@types/node@17.0.45': {} - '@types/node@20.19.22': + '@types/node@18.19.130': dependencies: - undici-types: 6.21.0 + undici-types: 5.26.5 - '@types/node@22.15.3': + '@types/node@20.19.22': dependencies: undici-types: 6.21.0 @@ -6021,7 +7207,7 @@ snapshots: '@types/pg@8.15.5': dependencies: - '@types/node': 24.8.1 + '@types/node': 20.19.22 pg-protocol: 1.10.3 pg-types: 2.2.0 @@ -6029,6 +7215,14 @@ snapshots: '@types/range-parser@1.2.7': {} + '@types/react-dom@19.2.3(@types/react@19.2.4)': + dependencies: + '@types/react': 19.2.4 + + '@types/react@19.2.4': + dependencies: + csstype: 3.2.0 + '@types/sax@1.2.7': dependencies: '@types/node': 20.19.22 @@ -6036,16 +7230,16 @@ snapshots: '@types/send@0.17.5': dependencies: '@types/mime': 1.3.5 - '@types/node': 22.15.3 + '@types/node': 20.19.22 '@types/send@1.2.0': dependencies: - '@types/node': 22.15.3 + '@types/node': 20.19.22 '@types/serve-static@1.15.9': dependencies: '@types/http-errors': 2.0.5 - '@types/node': 22.15.3 + '@types/node': 20.19.22 '@types/send': 0.17.5 '@types/swagger-jsdoc@6.0.4': {} @@ -6055,6 +7249,10 @@ snapshots: '@types/express': 4.17.23 '@types/serve-static': 1.15.9 + '@types/through@0.0.33': + dependencies: + '@types/node': 20.19.22 + '@types/triple-beam@1.3.5': {} '@types/unist@2.0.11': {} @@ -6063,7 +7261,7 @@ snapshots: '@types/ws@8.18.1': dependencies: - '@types/node': 22.15.3 + '@types/node': 20.19.22 '@typescript-eslint/eslint-plugin@8.40.0(@typescript-eslint/parser@8.40.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.2))(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.2)': dependencies: @@ -6160,6 +7358,73 @@ snapshots: '@ungap/structured-clone@1.3.0': {} + '@vibrant/color@4.0.0': {} + + '@vibrant/core@4.0.0': + dependencies: + '@vibrant/color': 4.0.0 + '@vibrant/generator': 4.0.0 + '@vibrant/image': 4.0.0 + '@vibrant/quantizer': 4.0.0 + '@vibrant/worker': 4.0.0 + + '@vibrant/generator-default@4.0.3': + dependencies: + '@vibrant/color': 4.0.0 + '@vibrant/generator': 4.0.0 + + '@vibrant/generator@4.0.0': + dependencies: + '@vibrant/color': 4.0.0 + '@vibrant/types': 4.0.0 + + '@vibrant/image-browser@4.0.0': + dependencies: + '@vibrant/image': 4.0.0 + + '@vibrant/image-node@4.0.0': + dependencies: + '@jimp/custom': 0.22.12 + '@jimp/plugin-resize': 0.22.12(@jimp/custom@0.22.12) + '@jimp/types': 0.22.12(@jimp/custom@0.22.12) + '@vibrant/image': 4.0.0 + transitivePeerDependencies: + - encoding + + '@vibrant/image@4.0.0': + dependencies: + '@vibrant/color': 4.0.0 + + '@vibrant/quantizer-mmcq@4.0.0': + dependencies: + '@vibrant/color': 4.0.0 + '@vibrant/image': 4.0.0 + '@vibrant/quantizer': 4.0.0 + + '@vibrant/quantizer@4.0.0': + dependencies: + '@vibrant/color': 4.0.0 + '@vibrant/image': 4.0.0 + '@vibrant/types': 4.0.0 + + '@vibrant/types@4.0.0': {} + + '@vibrant/worker@4.0.0': + dependencies: + '@vibrant/types': 4.0.0 + + '@vitejs/plugin-react@4.7.0(vite@6.4.1(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.1))': + dependencies: + '@babel/core': 7.28.5 + '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.5) + '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.28.5) + '@rolldown/pluginutils': 1.0.0-beta.27 + '@types/babel__core': 7.20.5 + react-refresh: 0.17.0 + vite: 6.4.1(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.1) + transitivePeerDependencies: + - supports-color + '@volar/kit@2.4.23(typescript@5.9.2)': dependencies: '@volar/language-service': 2.4.23 @@ -6215,6 +7480,10 @@ snapshots: jsonparse: 1.3.1 through: 2.3.8 + abort-controller@3.0.0: + dependencies: + event-target-shim: 5.0.1 + accepts@1.3.8: dependencies: mime-types: 2.1.35 @@ -6226,6 +7495,12 @@ snapshots: acorn@8.15.0: {} + agent-base@6.0.2: + dependencies: + debug: 4.4.1 + transitivePeerDependencies: + - supports-color + ajv@6.12.6: dependencies: fast-deep-equal: 3.1.3 @@ -6270,6 +7545,8 @@ snapshots: ansi-styles@6.2.3: {} + any-base@1.1.0: {} + anymatch@3.1.3: dependencies: normalize-path: 3.0.0 @@ -6352,12 +7629,12 @@ snapshots: astring@1.9.0: {} - astro-expressive-code@0.41.3(astro@5.15.1(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.1)(rollup@4.52.5)(tsx@4.20.6)(typescript@5.9.2)(yaml@2.8.1)): + astro-expressive-code@0.41.3(astro@5.15.1(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.52.5)(tsx@4.20.6)(typescript@5.9.2)(yaml@2.8.1)): dependencies: - astro: 5.15.1(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.1)(rollup@4.52.5)(tsx@4.20.6)(typescript@5.9.2)(yaml@2.8.1) + astro: 5.15.1(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.52.5)(tsx@4.20.6)(typescript@5.9.2)(yaml@2.8.1) rehype-expressive-code: 0.41.3 - astro@5.15.1(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.1)(rollup@4.52.5)(tsx@4.20.6)(typescript@5.9.2)(yaml@2.8.1): + astro@5.15.1(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.52.5)(tsx@4.20.6)(typescript@5.9.2)(yaml@2.8.1): dependencies: '@astrojs/compiler': 2.13.0 '@astrojs/internal-helpers': 0.7.4 @@ -6391,7 +7668,7 @@ snapshots: http-cache-semantics: 4.2.0 import-meta-resolve: 4.2.0 js-yaml: 4.1.0 - magic-string: 0.30.19 + magic-string: 0.30.21 magicast: 0.3.5 mrmime: 2.0.1 neotraverse: 0.6.18 @@ -6413,8 +7690,8 @@ snapshots: unist-util-visit: 5.0.0 unstorage: 1.17.1 vfile: 6.0.3 - vite: 6.4.1(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.1)(tsx@4.20.6)(yaml@2.8.1) - vitefu: 1.1.1(vite@6.4.1(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.1)(tsx@4.20.6)(yaml@2.8.1)) + vite: 6.4.1(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.1) + vitefu: 1.1.1(vite@6.4.1(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.1)) xxhash-wasm: 1.1.0 yargs-parser: 21.1.1 yocto-spinner: 0.2.3 @@ -6488,6 +7765,8 @@ snapshots: base64-js@1.5.1: {} + baseline-browser-mapping@2.8.28: {} + bcp-47-match@2.0.3: {} bcp-47@2.1.0: @@ -6508,6 +7787,8 @@ snapshots: inherits: 2.0.4 readable-stream: 3.6.2 + bmp-js@0.1.0: {} + body-parser@1.20.3: dependencies: bytes: 3.1.2 @@ -6555,6 +7836,14 @@ snapshots: dependencies: base64-js: 1.5.1 + browserslist@4.28.0: + dependencies: + baseline-browser-mapping: 2.8.28 + caniuse-lite: 1.0.30001754 + electron-to-chromium: 1.5.252 + node-releases: 2.0.27 + update-browserslist-db: 1.1.4(browserslist@4.28.0) + buffer-equal-constant-time@1.0.1: {} buffer@5.7.1: @@ -6562,6 +7851,11 @@ snapshots: base64-js: 1.5.1 ieee754: 1.2.1 + buffer@6.0.3: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + bufferutil@4.0.9: dependencies: node-gyp-build: 4.8.4 @@ -6611,6 +7905,8 @@ snapshots: camelcase@8.0.0: {} + caniuse-lite@1.0.30001754: {} + ccount@2.0.1: {} chalk@2.4.2: @@ -6654,6 +7950,8 @@ snapshots: dependencies: readdirp: 4.1.2 + chownr@1.1.4: {} + ci-info@3.9.0: {} ci-info@4.3.1: {} @@ -6678,6 +7976,12 @@ snapshots: cli-width@3.0.0: {} + cliui@7.0.4: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + cliui@8.0.1: dependencies: string-width: 4.2.3 @@ -6725,6 +8029,8 @@ snapshots: comma-separated-tokens@2.0.3: {} + commander@11.1.0: {} + commander@6.2.0: {} commander@9.5.0: {} @@ -6801,6 +8107,8 @@ snapshots: meow: 12.1.1 split2: 4.2.0 + convert-source-map@2.0.0: {} + cookie-es@1.2.2: {} cookie-signature@1.0.6: {} @@ -6809,6 +8117,8 @@ snapshots: cookie@1.0.2: {} + core-util-is@1.0.3: {} + cors@2.8.5: dependencies: object-assign: 4.1.1 @@ -6849,6 +8159,8 @@ snapshots: cssesc@3.0.0: {} + csstype@3.2.0: {} + cz-conventional-changelog@3.3.0(@types/node@24.8.1)(typescript@5.9.2): dependencies: chalk: 2.4.2 @@ -6906,8 +8218,14 @@ snapshots: dependencies: character-entities: 2.0.2 + decompress-response@6.0.0: + dependencies: + mimic-response: 3.1.0 + dedent@0.7.0: {} + deep-extend@0.6.0: {} + deep-is@0.1.4: {} deepmerge-ts@7.1.5: {} @@ -7020,6 +8338,8 @@ snapshots: '@standard-schema/spec': 1.0.0 fast-check: 3.23.2 + electron-to-chromium@1.5.252: {} + emmet@2.4.11: dependencies: '@emmetio/abbreviation': 2.3.3 @@ -7037,6 +8357,15 @@ snapshots: encodeurl@2.0.0: {} + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + + enhanced-resolve@5.18.3: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.0 + enquirer@2.4.1: dependencies: ansi-colors: 4.1.3 @@ -7352,8 +8681,28 @@ snapshots: etag@1.8.1: {} + event-target-shim@5.0.1: {} + eventemitter3@5.0.1: {} + events@3.3.0: {} + + execa@5.1.1: + dependencies: + cross-spawn: 7.0.6 + get-stream: 6.0.1 + human-signals: 2.1.0 + is-stream: 2.0.1 + merge-stream: 2.0.0 + npm-run-path: 4.0.1 + onetime: 5.1.2 + signal-exit: 3.0.7 + strip-final-newline: 2.0.0 + + exif-parser@0.1.12: {} + + expand-template@2.0.3: {} + expand-tilde@2.0.2: dependencies: homedir-polyfill: 1.0.3 @@ -7467,6 +8816,12 @@ snapshots: dependencies: flat-cache: 4.0.1 + file-type@16.5.4: + dependencies: + readable-web-to-node-stream: 3.0.4 + strtok3: 6.3.0 + token-types: 4.2.1 + fill-range@7.1.1: dependencies: to-regex-range: 5.0.1 @@ -7561,8 +8916,24 @@ snapshots: forwarded@0.2.0: {} + framer-motion@12.23.24(react-dom@19.2.0(react@19.2.0))(react@19.2.0): + dependencies: + motion-dom: 12.23.23 + motion-utils: 12.23.6 + tslib: 2.8.1 + optionalDependencies: + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + fresh@0.5.2: {} + from2@2.3.0: + dependencies: + inherits: 2.0.4 + readable-stream: 2.3.8 + + fs-constants@1.0.0: {} + fs-extra@7.0.1: dependencies: graceful-fs: 4.2.11 @@ -7600,6 +8971,8 @@ snapshots: functions-have-names@1.2.3: {} + gensync@1.0.0-beta.2: {} + get-caller-file@2.0.5: {} get-east-asian-width@1.4.0: {} @@ -7622,6 +8995,8 @@ snapshots: dunder-proto: 1.0.1 es-object-atoms: 1.1.1 + get-stream@6.0.1: {} + get-symbol-description@1.1.0: dependencies: call-bound: 1.0.4 @@ -7632,6 +9007,11 @@ snapshots: dependencies: resolve-pkg-maps: 1.0.0 + gifwrap@0.10.1: + dependencies: + image-q: 4.0.0 + omggif: 1.0.10 + giget@2.0.0: dependencies: citty: 0.1.6 @@ -7647,6 +9027,8 @@ snapshots: meow: 12.1.1 split2: 4.2.0 + github-from-package@0.0.0: {} + github-slugger@2.0.0: {} glob-parent@5.1.2: @@ -7749,6 +9131,8 @@ snapshots: dependencies: has-symbols: 1.1.0 + has@1.0.4: {} + hasown@2.0.2: dependencies: function-bind: 1.1.2 @@ -7964,8 +9348,17 @@ snapshots: statuses: 2.0.1 toidentifier: 1.0.1 + https-proxy-agent@5.0.1: + dependencies: + agent-base: 6.0.2 + debug: 4.4.1 + transitivePeerDependencies: + - supports-color + human-id@4.1.2: {} + human-signals@2.1.0: {} + husky@9.1.7: {} i18next@23.16.8: @@ -7986,6 +9379,10 @@ snapshots: ignore@7.0.5: {} + image-q@4.0.0: + dependencies: + '@types/node': 16.9.1 + import-fresh@3.3.1: dependencies: parent-module: 1.0.1 @@ -8042,12 +9439,37 @@ snapshots: through: 2.3.8 wrap-ansi: 7.0.0 + inquirer@8.2.7(@types/node@20.19.22): + dependencies: + '@inquirer/external-editor': 1.0.2(@types/node@20.19.22) + ansi-escapes: 4.3.2 + chalk: 4.1.2 + cli-cursor: 3.1.0 + cli-width: 3.0.0 + figures: 3.2.0 + lodash: 4.17.21 + mute-stream: 0.0.8 + ora: 5.4.1 + run-async: 2.4.1 + rxjs: 7.8.2 + string-width: 4.2.3 + strip-ansi: 6.0.1 + through: 2.3.8 + wrap-ansi: 6.2.0 + transitivePeerDependencies: + - '@types/node' + internal-slot@1.1.0: dependencies: es-errors: 1.3.0 hasown: 2.0.2 side-channel: 1.1.0 + into-stream@6.0.0: + dependencies: + from2: 2.3.0 + p-is-promise: 3.0.0 + ipaddr.js@1.9.1: {} iron-webcrypto@1.2.1: {} @@ -8094,6 +9516,10 @@ snapshots: dependencies: hasown: 2.0.2 + is-core-module@2.9.0: + dependencies: + has: 1.0.4 + is-data-view@1.0.2: dependencies: call-bound: 1.0.4 @@ -8212,10 +9638,19 @@ snapshots: dependencies: is-inside-container: 1.0.0 + isarray@1.0.0: {} + isarray@2.0.5: {} isexe@2.0.0: {} + isomorphic-fetch@3.0.0: + dependencies: + node-fetch: 2.7.0 + whatwg-fetch: 3.6.20 + transitivePeerDependencies: + - encoding + iterator.prototype@1.1.5: dependencies: define-data-property: 1.1.4 @@ -8227,6 +9662,8 @@ snapshots: jiti@2.6.1: {} + jpeg-js@0.4.4: {} + js-tokens@4.0.0: {} js-yaml@3.14.1: @@ -8238,6 +9675,10 @@ snapshots: dependencies: argparse: 2.0.1 + jsesc@2.5.2: {} + + jsesc@3.1.0: {} + json-buffer@3.0.1: {} json-parse-even-better-errors@2.3.1: {} @@ -8248,6 +9689,8 @@ snapshots: json-stable-stringify-without-jsonify@1.0.1: {} + json5@2.2.3: {} + jsonc-parser@2.3.1: {} jsonc-parser@3.3.1: {} @@ -8312,51 +9755,54 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 - lightningcss-darwin-arm64@1.30.1: + lightningcss-android-arm64@1.30.2: + optional: true + + lightningcss-darwin-arm64@1.30.2: optional: true - lightningcss-darwin-x64@1.30.1: + lightningcss-darwin-x64@1.30.2: optional: true - lightningcss-freebsd-x64@1.30.1: + lightningcss-freebsd-x64@1.30.2: optional: true - lightningcss-linux-arm-gnueabihf@1.30.1: + lightningcss-linux-arm-gnueabihf@1.30.2: optional: true - lightningcss-linux-arm64-gnu@1.30.1: + lightningcss-linux-arm64-gnu@1.30.2: optional: true - lightningcss-linux-arm64-musl@1.30.1: + lightningcss-linux-arm64-musl@1.30.2: optional: true - lightningcss-linux-x64-gnu@1.30.1: + lightningcss-linux-x64-gnu@1.30.2: optional: true - lightningcss-linux-x64-musl@1.30.1: + lightningcss-linux-x64-musl@1.30.2: optional: true - lightningcss-win32-arm64-msvc@1.30.1: + lightningcss-win32-arm64-msvc@1.30.2: optional: true - lightningcss-win32-x64-msvc@1.30.1: + lightningcss-win32-x64-msvc@1.30.2: optional: true - lightningcss@1.30.1: + lightningcss@1.30.2: dependencies: detect-libc: 2.1.2 optionalDependencies: - lightningcss-darwin-arm64: 1.30.1 - lightningcss-darwin-x64: 1.30.1 - lightningcss-freebsd-x64: 1.30.1 - lightningcss-linux-arm-gnueabihf: 1.30.1 - lightningcss-linux-arm64-gnu: 1.30.1 - lightningcss-linux-arm64-musl: 1.30.1 - lightningcss-linux-x64-gnu: 1.30.1 - lightningcss-linux-x64-musl: 1.30.1 - lightningcss-win32-arm64-msvc: 1.30.1 - lightningcss-win32-x64-msvc: 1.30.1 - optional: true + lightningcss-android-arm64: 1.30.2 + lightningcss-darwin-arm64: 1.30.2 + lightningcss-darwin-x64: 1.30.2 + lightningcss-freebsd-x64: 1.30.2 + lightningcss-linux-arm-gnueabihf: 1.30.2 + lightningcss-linux-arm64-gnu: 1.30.2 + lightningcss-linux-arm64-musl: 1.30.2 + lightningcss-linux-x64-gnu: 1.30.2 + lightningcss-linux-x64-musl: 1.30.2 + lightningcss-win32-arm64-msvc: 1.30.2 + lightningcss-win32-x64-msvc: 1.30.2 lines-and-columns@1.2.4: {} @@ -8434,7 +9880,11 @@ snapshots: lru-cache@10.4.3: {} - magic-string@0.30.19: + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -8641,6 +10091,8 @@ snapshots: merge-descriptors@1.0.3: {} + merge-stream@2.0.0: {} + merge2@1.4.1: {} merge@2.1.1: {} @@ -8940,6 +10392,8 @@ snapshots: mimic-fn@2.1.0: {} + mimic-response@3.1.0: {} + minimatch@3.1.2: dependencies: brace-expansion: 1.1.12 @@ -8952,10 +10406,26 @@ snapshots: minimist@1.2.8: {} + mkdirp-classic@0.5.3: {} + mkdirp@0.5.6: dependencies: minimist: 1.2.8 + motion-dom@12.23.23: + dependencies: + motion-utils: 12.23.6 + + motion-utils@12.23.6: {} + + motion@12.23.24(react-dom@19.2.0(react@19.2.0))(react@19.2.0): + dependencies: + framer-motion: 12.23.24(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + tslib: 2.8.1 + optionalDependencies: + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + mri@1.2.0: {} mrmime@2.0.1: {} @@ -8966,6 +10436,11 @@ snapshots: muggle-string@0.4.1: {} + multistream@4.1.0: + dependencies: + once: 1.4.0 + readable-stream: 3.6.2 + mute-stream@0.0.7: {} mute-stream@0.0.8: {} @@ -8974,6 +10449,8 @@ snapshots: nanoid@3.3.11: {} + napi-build-utils@1.0.2: {} + natural-compare@1.4.0: {} negotiator@0.6.3: {} @@ -8986,6 +10463,10 @@ snapshots: dependencies: '@types/nlcst': 2.0.3 + node-abi@3.85.0: + dependencies: + semver: 7.7.2 + node-fetch-native@1.6.7: {} node-fetch@2.7.0: @@ -8997,8 +10478,25 @@ snapshots: node-mock-http@1.0.3: {} + node-releases@2.0.27: {} + + node-vibrant@4.0.3: + dependencies: + '@types/node': 18.19.130 + '@vibrant/core': 4.0.0 + '@vibrant/generator-default': 4.0.3 + '@vibrant/image-browser': 4.0.0 + '@vibrant/image-node': 4.0.0 + '@vibrant/quantizer-mmcq': 4.0.0 + transitivePeerDependencies: + - encoding + normalize-path@3.0.0: {} + npm-run-path@4.0.1: + dependencies: + path-key: 3.1.1 + nth-check@2.1.1: dependencies: boolbase: 1.0.0 @@ -9055,6 +10553,8 @@ snapshots: ohash@2.0.11: {} + omggif@1.0.10: {} + on-finished@2.4.1: dependencies: ee-first: 1.1.1 @@ -9124,6 +10624,8 @@ snapshots: dependencies: p-map: 2.1.0 + p-is-promise@3.0.0: {} + p-limit@2.3.0: dependencies: p-try: 2.2.0 @@ -9180,6 +10682,8 @@ snapshots: pako@0.2.9: {} + pako@1.0.11: {} + parent-module@1.0.1: dependencies: callsites: 3.1.0 @@ -9236,6 +10740,8 @@ snapshots: pathe@2.0.3: {} + peek-readable@4.1.0: {} + perfect-debounce@1.0.0: {} pg-cloudflare@1.2.7: @@ -9281,16 +10787,58 @@ snapshots: pify@4.0.1: {} + pixelmatch@4.0.2: + dependencies: + pngjs: 3.4.0 + + pkg-fetch@3.4.2: + dependencies: + chalk: 4.1.2 + fs-extra: 9.1.0 + https-proxy-agent: 5.0.1 + node-fetch: 2.7.0 + progress: 2.0.3 + semver: 7.7.2 + tar-fs: 2.1.4 + yargs: 16.2.0 + transitivePeerDependencies: + - encoding + - supports-color + pkg-types@2.3.0: dependencies: confbox: 0.2.2 exsolve: 1.0.7 pathe: 2.0.3 + pkg@5.8.1: + dependencies: + '@babel/generator': 7.18.2 + '@babel/parser': 7.18.4 + '@babel/types': 7.19.0 + chalk: 4.1.2 + fs-extra: 9.1.0 + globby: 11.1.0 + into-stream: 6.0.0 + is-core-module: 2.9.0 + minimist: 1.2.8 + multistream: 4.1.0 + pkg-fetch: 3.4.2 + prebuild-install: 7.1.1 + resolve: 1.22.11 + stream-meter: 1.0.4 + transitivePeerDependencies: + - encoding + - supports-color + plimit-lit@1.6.1: dependencies: queue-lit: 1.5.2 + pngjs@3.4.0: {} + + pngjs@6.0.0: {} + possible-typed-array-names@1.1.0: {} postcss-nested@6.2.0(postcss@8.5.6): @@ -9319,8 +10867,29 @@ snapshots: dependencies: xtend: 4.0.2 + prebuild-install@7.1.1: + dependencies: + detect-libc: 2.1.2 + expand-template: 2.0.3 + github-from-package: 0.0.0 + minimist: 1.2.8 + mkdirp-classic: 0.5.3 + napi-build-utils: 1.0.2 + node-abi: 3.85.0 + pump: 3.0.3 + rc: 1.2.8 + simple-get: 4.0.1 + tar-fs: 2.1.4 + tunnel-agent: 0.6.0 + prelude-ls@1.2.1: {} + prettier-plugin-astro@0.14.1: + dependencies: + '@astrojs/compiler': 2.13.0 + prettier: 3.6.2 + sass-formatter: 0.7.9 + prettier@2.8.7: optional: true @@ -9339,6 +10908,12 @@ snapshots: prismjs@1.30.0: {} + process-nextick-args@2.0.1: {} + + process@0.11.10: {} + + progress@2.0.3: {} + prompts@2.4.2: dependencies: kleur: 3.0.3 @@ -9361,6 +10936,11 @@ snapshots: proxy-from-env@1.1.0: {} + pump@3.0.3: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + punycode@2.3.1: {} pure-rand@6.1.0: {} @@ -9391,8 +10971,24 @@ snapshots: defu: 6.1.4 destr: 2.0.5 + rc@1.2.8: + dependencies: + deep-extend: 0.6.0 + ini: 1.3.8 + minimist: 1.2.8 + strip-json-comments: 2.0.1 + + react-dom@19.2.0(react@19.2.0): + dependencies: + react: 19.2.0 + scheduler: 0.27.0 + react-is@16.13.1: {} + react-refresh@0.17.0: {} + + react@19.2.0: {} + read-yaml-file@1.1.0: dependencies: graceful-fs: 4.2.11 @@ -9400,12 +10996,34 @@ snapshots: pify: 4.0.1 strip-bom: 3.0.0 + readable-stream@2.3.8: + dependencies: + core-util-is: 1.0.3 + inherits: 2.0.4 + isarray: 1.0.0 + process-nextick-args: 2.0.1 + safe-buffer: 5.1.2 + string_decoder: 1.1.1 + util-deprecate: 1.0.2 + readable-stream@3.6.2: dependencies: inherits: 2.0.4 string_decoder: 1.3.0 util-deprecate: 1.0.2 + readable-stream@4.7.0: + dependencies: + abort-controller: 3.0.0 + buffer: 6.0.3 + events: 3.3.0 + process: 0.11.10 + string_decoder: 1.3.0 + + readable-web-to-node-stream@3.0.4: + dependencies: + readable-stream: 4.7.0 + readdirp@3.6.0: dependencies: picomatch: 2.3.1 @@ -9452,6 +11070,8 @@ snapshots: get-proto: 1.0.1 which-builtin-type: 1.2.1 + regenerator-runtime@0.13.11: {} + regex-recursion@6.0.2: dependencies: regex-utilities: 2.3.0 @@ -9589,6 +11209,12 @@ snapshots: resolve-pkg-maps@1.0.0: {} + resolve@1.22.11: + dependencies: + is-core-module: 2.16.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + resolve@2.0.0-next.5: dependencies: is-core-module: 2.16.1 @@ -9680,6 +11306,8 @@ snapshots: dependencies: tslib: 2.8.1 + s.color@0.0.15: {} + safe-array-concat@1.1.3: dependencies: call-bind: 1.0.8 @@ -9688,6 +11316,8 @@ snapshots: has-symbols: 1.1.0 isarray: 2.0.5 + safe-buffer@5.1.2: {} + safe-buffer@5.2.1: {} safe-push-apply@1.0.0: @@ -9705,8 +11335,14 @@ snapshots: safer-buffer@2.1.2: {} + sass-formatter@0.7.9: + dependencies: + suf-log: 2.5.3 + sax@1.4.1: {} + scheduler@0.27.0: {} + semver@6.3.1: {} semver@7.7.2: {} @@ -9840,6 +11476,14 @@ snapshots: signal-exit@4.1.0: {} + simple-concat@1.0.1: {} + + simple-get@4.0.1: + dependencies: + decompress-response: 6.0.0 + once: 1.4.0 + simple-concat: 1.0.1 + sisteransi@1.0.5: {} sitemap@8.0.1: @@ -9877,6 +11521,10 @@ snapshots: es-errors: 1.3.0 internal-slot: 1.1.0 + stream-meter@1.0.4: + dependencies: + readable-stream: 2.3.8 + stream-replace-string@2.0.0: {} string-width@2.1.1: @@ -9940,6 +11588,10 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.1.1 + string_decoder@1.1.1: + dependencies: + safe-buffer: 5.1.2 + string_decoder@1.3.0: dependencies: safe-buffer: 5.2.1 @@ -9969,8 +11621,17 @@ snapshots: strip-bom@4.0.0: {} + strip-final-newline@2.0.0: {} + + strip-json-comments@2.0.1: {} + strip-json-comments@3.1.1: {} + strtok3@6.3.0: + dependencies: + '@tokenizer/token': 0.3.0 + peek-readable: 4.1.0 + style-to-js@1.1.18: dependencies: style-to-object: 1.0.11 @@ -9979,6 +11640,10 @@ snapshots: dependencies: inline-style-parser: 0.2.4 + suf-log@2.5.3: + dependencies: + s.color: 0.0.15 + supports-color@5.5.0: dependencies: has-flag: 3.0.0 @@ -10015,6 +11680,25 @@ snapshots: express: 4.21.2 swagger-ui-dist: 5.29.5 + tailwindcss@4.1.17: {} + + tapable@2.3.0: {} + + tar-fs@2.1.4: + dependencies: + chownr: 1.1.4 + mkdirp-classic: 0.5.3 + pump: 3.0.3 + tar-stream: 2.2.0 + + tar-stream@2.2.0: + dependencies: + bl: 4.1.0 + end-of-stream: 1.4.5 + fs-constants: 1.0.0 + inherits: 2.0.4 + readable-stream: 3.6.2 + temp@0.9.4: dependencies: mkdirp: 0.5.6 @@ -10028,8 +11712,12 @@ snapshots: through@2.3.8: {} + timm@1.7.1: {} + tiny-inflate@1.0.3: {} + tinycolor2@1.6.0: {} + tinyexec@1.0.1: {} tinyglobby@0.2.15: @@ -10041,12 +11729,19 @@ snapshots: dependencies: os-tmpdir: 1.0.2 + to-fast-properties@2.0.0: {} + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 toidentifier@1.0.1: {} + token-types@4.2.1: + dependencies: + '@tokenizer/token': 0.3.0 + ieee754: 1.2.1 + tr46@0.0.3: {} trim-lines@3.0.1: {} @@ -10084,6 +11779,10 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + tunnel-agent@0.6.0: + dependencies: + safe-buffer: 5.2.1 + turbo-darwin-64@2.5.8: optional: true @@ -10189,6 +11888,8 @@ snapshots: uncrypto@0.1.3: {} + undici-types@5.26.5: {} + undici-types@6.21.0: {} undici-types@7.14.0: {} @@ -10284,6 +11985,12 @@ snapshots: ofetch: 1.4.1 ufo: 1.6.1 + update-browserslist-db@1.1.4(browserslist@4.28.0): + dependencies: + browserslist: 4.28.0 + escalade: 3.2.0 + picocolors: 1.1.1 + uri-js@4.4.1: dependencies: punycode: 2.3.1 @@ -10292,6 +11999,10 @@ snapshots: dependencies: os-homedir: 1.0.2 + utif2@4.1.0: + dependencies: + pako: 1.0.11 + util-deprecate@1.0.2: {} utils-merge@1.0.1: {} @@ -10315,7 +12026,7 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vite@6.4.1(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.1)(tsx@4.20.6)(yaml@2.8.1): + vite@6.4.1(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.1): dependencies: esbuild: 0.25.11 fdir: 6.5.0(picomatch@4.0.3) @@ -10327,13 +12038,13 @@ snapshots: '@types/node': 20.19.22 fsevents: 2.3.3 jiti: 2.6.1 - lightningcss: 1.30.1 + lightningcss: 1.30.2 tsx: 4.20.6 yaml: 2.8.1 - vitefu@1.1.1(vite@6.4.1(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.1)(tsx@4.20.6)(yaml@2.8.1)): + vitefu@1.1.1(vite@6.4.1(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.1)): optionalDependencies: - vite: 6.4.1(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.1)(tsx@4.20.6)(yaml@2.8.1) + vite: 6.4.1(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.1) volar-service-css@0.0.65(@volar/language-service@2.4.23): dependencies: @@ -10453,6 +12164,8 @@ snapshots: webidl-conversions@3.0.1: {} + whatwg-fetch@3.6.20: {} + whatwg-url@5.0.0: dependencies: tr46: 0.0.3 @@ -10535,6 +12248,12 @@ snapshots: word-wrap@1.2.5: {} + wrap-ansi@6.2.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi@7.0.0: dependencies: ansi-styles: 4.3.0 @@ -10559,6 +12278,8 @@ snapshots: y18n@5.0.8: {} + yallist@3.1.1: {} + yaml-language-server@1.15.0: dependencies: ajv: 8.17.1 @@ -10580,8 +12301,20 @@ snapshots: yaml@2.8.1: {} + yargs-parser@20.2.9: {} + yargs-parser@21.1.1: {} + yargs@16.2.0: + dependencies: + cliui: 7.0.4 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 20.2.9 + yargs@17.7.2: dependencies: cliui: 8.0.1 diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 0000000..2c1e992 --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,27 @@ +# Scripts + +Utility scripts for DesterLib. + +## Scripts + +- **sync-changelog.js** - Syncs package changelogs to docs +- **sync-version.js** - Syncs versions across packages +- **verify-versioning.js** - Verifies versioning setup +- **pre-pr-check.js** - Runs checks before creating PR +- **create-pr.sh** - Creates PR with GitHub CLI + +## Usage + +### Before PR + +```bash +pnpm pre-pr # Run all checks +pnpm pr:create # Create PR with checks +``` + +### Versioning + +```bash +pnpm verify:versioning # Verify setup +pnpm changelog:sync # Sync changelogs to docs +``` diff --git a/scripts/create-pr.sh b/scripts/create-pr.sh index a8104fc..b16348b 100755 --- a/scripts/create-pr.sh +++ b/scripts/create-pr.sh @@ -1,12 +1,21 @@ #!/bin/bash -# Auto-generate and create a PR using GitHub CLI -# Usage: ./scripts/create-pr.sh [target-branch] +# Create a PR using GitHub CLI +# Usage: ./scripts/create-pr.sh [target-branch] [--skip-checks] set -e -# Default target branch is main TARGET_BRANCH="${1:-main}" +SKIP_CHECKS=false + +# Parse flags +for arg in "$@"; do + case $arg in + --skip-checks) + SKIP_CHECKS=true + ;; + esac +done # Get current branch CURRENT_BRANCH=$(git branch --show-current) @@ -21,10 +30,19 @@ fi # Check if we're on a branch if [ "$CURRENT_BRANCH" == "main" ]; then echo "āŒ Cannot create PR from main branch" - echo "Please switch to a feature branch" exit 1 fi +# Run pre-PR checks (unless skipped) +if [ "$SKIP_CHECKS" != "true" ]; then + if ! pnpm pre-pr; then + echo "" + echo "āŒ Pre-PR checks failed. Fix issues or use --skip-checks" + exit 1 + fi + echo "" +fi + # Get commits that will be in the PR COMMITS=$(git log $TARGET_BRANCH..$CURRENT_BRANCH --oneline) @@ -66,6 +84,7 @@ $COMMITS - [ ] Documentation updated (if needed) - [ ] No new warnings generated - [ ] Changeset added (if applicable) +- [ ] Pre-PR checks passed --- diff --git a/scripts/pre-pr-check.js b/scripts/pre-pr-check.js new file mode 100755 index 0000000..f3ce4dd --- /dev/null +++ b/scripts/pre-pr-check.js @@ -0,0 +1,60 @@ +#!/usr/bin/env node + +/** + * Pre-PR Check Script + * Runs essential checks: lint, types, format (warn), versioning + */ + +const { execSync } = require("child_process"); +const path = require("path"); + +const colors = { + reset: "\x1b[0m", + green: "\x1b[32m", + yellow: "\x1b[33m", + red: "\x1b[31m", + blue: "\x1b[34m", +}; + +function log(message, color = "reset") { + console.log(`${colors[color]}${message}${colors.reset}`); +} + +const rootDir = path.join(__dirname, ".."); +let failed = false; + +function run(name, command, required = true) { + try { + log(`\nā–¶ ${name}...`, "blue"); + execSync(command, { cwd: rootDir, stdio: "inherit" }); + log(`āœ“ ${name}`, "green"); + return true; + } catch (error) { + if (required) { + log(`āœ— ${name}`, "red"); + failed = true; + } else { + log(`⚠ ${name} (warning)`, "yellow"); + log(" šŸ’” Run 'pnpm format' to fix", "yellow"); + } + return false; + } +} + +log("\nšŸ” Pre-PR Checks", "blue"); +log("=".repeat(50), "blue"); + +run("Lint", "pnpm lint"); +run("Types", "pnpm check-types"); +run("Format", "pnpm format:check", false); // Warning only +run("Versioning", "pnpm verify:versioning"); + +log("\n" + "=".repeat(50), "blue"); + +if (failed) { + log("\nāŒ Checks failed - fix issues above", "red"); + process.exit(1); +} else { + log("\nāœ… All checks passed!", "green"); + process.exit(0); +} diff --git a/scripts/sync-changelog.js b/scripts/sync-changelog.js new file mode 100755 index 0000000..7a9caff --- /dev/null +++ b/scripts/sync-changelog.js @@ -0,0 +1,185 @@ +#!/usr/bin/env node + +/** + * Changelog Sync Script + * + * This script syncs package-specific CHANGELOG.md files (generated by Changesets) + * to their respective documentation pages. + */ + +const fs = require("fs"); +const path = require("path"); + +// ANSI color codes for terminal output +const colors = { + reset: "\x1b[0m", + green: "\x1b[32m", + yellow: "\x1b[33m", + red: "\x1b[31m", + blue: "\x1b[34m", +}; + +function log(message, color = "reset") { + console.log(`${colors[color]}${message}${colors.reset}`); +} + +// Package changelog paths and their corresponding docs pages +const packageChangelogs = [ + { + name: "API Server", + packageName: "api", + changelogPath: path.join(__dirname, "../apps/api/CHANGELOG.md"), + docsPath: path.join( + __dirname, + "../apps/docs/src/content/docs/api/changelog.md" + ), + githubPath: "apps/api/CHANGELOG.md", + description: "Changelog for the DesterLib API Server", + }, + { + name: "CLI Tool", + packageName: "@desterlib/cli", + changelogPath: path.join(__dirname, "../packages/cli/CHANGELOG.md"), + docsPath: path.join( + __dirname, + "../apps/docs/src/content/docs/cli/changelog.md" + ), + githubPath: "packages/cli/CHANGELOG.md", + description: "Changelog for the DesterLib CLI Tool", + }, + { + name: "Documentation", + packageName: "docs", + changelogPath: path.join(__dirname, "../apps/docs/CHANGELOG.md"), + docsPath: path.join( + __dirname, + "../apps/docs/src/content/docs/docs/changelog.md" + ), + githubPath: "apps/docs/CHANGELOG.md", + description: "Changelog for the DesterLib Documentation", + }, +]; + +const GITHUB_REPO = "https://github.com/DesterLib/desterlib"; + +/** + * Parse changelog content and extract meaningful content + */ +function parseChangelog(content) { + if (!content || !content.trim()) { + return null; + } + + // Remove any frontmatter if present + content = content.replace(/^---\s*[\s\S]*?---\s*/, "").trim(); + + // Remove the main title (usually package name as # heading) + // Changesets typically starts with "# package-name" + content = content + .replace(/^#\s+[^\n]+\n*/i, "") + .replace(/^#\s+Changelog\s*/i, "") + .trim(); + + // If content is empty after removing title, return null + if (!content || content.trim().length === 0) { + return null; + } + + return content; +} + +/** + * Sync a single package changelog to its docs page + */ +function syncPackageChangelog(pkg) { + let changelogContent = null; + + // Read package changelog if it exists + if (fs.existsSync(pkg.changelogPath)) { + try { + const content = fs.readFileSync(pkg.changelogPath, "utf8"); + changelogContent = parseChangelog(content); + + if (!changelogContent || !changelogContent.trim()) { + log(`āš ļø ${pkg.name} changelog is empty`, "yellow"); + } else { + log(`āœ“ Found ${pkg.name} changelog`, "green"); + } + } catch (error) { + log(`āŒ Error reading ${pkg.name} changelog: ${error.message}`, "red"); + } + } else { + log( + `āš ļø Warning: ${pkg.name} changelog not found at ${pkg.changelogPath}`, + "yellow" + ); + } + + // Build docs page content + // Note: We don't include an H1 heading here because the frontmatter title provides it + // Starlight/Astro will use the frontmatter title, not an H1 in the content + let docsContent = `--- +title: ${pkg.name} Changelog +description: ${pkg.description} +--- + +All notable changes to ${pkg.name} will be documented here. + +This changelog is automatically generated from [Changesets](https://github.com/changesets/changesets). + +See the [${pkg.name} Changelog on GitHub](${GITHUB_REPO}/blob/main/${pkg.githubPath}) for the source file. + +--- + +`; + + if (changelogContent && changelogContent.trim()) { + // Add the changelog content + docsContent += changelogContent; + } else { + // Add placeholder if no changelog exists + docsContent += `_No changelog entries yet. Changelog will appear here once versions are bumped._\n`; + } + + // Ensure docs directory exists + const docsDir = path.dirname(pkg.docsPath); + if (!fs.existsSync(docsDir)) { + fs.mkdirSync(docsDir, { recursive: true }); + } + + // Write to docs + fs.writeFileSync(pkg.docsPath, docsContent); + + return { + success: true, + hasContent: changelogContent !== null && changelogContent.trim().length > 0, + }; +} + +try { + log("\nšŸ“ Syncing package changelogs to docs", "blue"); + log("─".repeat(50), "blue"); + + let syncedCount = 0; + let contentCount = 0; + + // Sync each package changelog + for (const pkg of packageChangelogs) { + const result = syncPackageChangelog(pkg); + if (result.success) { + syncedCount++; + if (result.hasContent) { + contentCount++; + } + } + } + + log(""); + log(`āœ… Synced ${syncedCount} changelog(s) to docs`, "green"); + log(`āœ“ ${contentCount} changelog(s) have content`, "yellow"); + log(""); +} catch (error) { + log(`āŒ Error syncing changelogs: ${error.message}`, "red"); + log(error.stack, "red"); + process.exit(1); +} diff --git a/scripts/sync-version.js b/scripts/sync-version.js new file mode 100755 index 0000000..a23e074 --- /dev/null +++ b/scripts/sync-version.js @@ -0,0 +1,210 @@ +#!/usr/bin/env node + +/** + * Version Sync Script + * + * This script ensures all version numbers across the monorepo are in sync. + * It reads the version from the root package.json and updates: + * - apps/api/package.json + * - packages/cli/package.json + * - desterlib-flutter/pubspec.yaml + * - desterlib-flutter/lib/api/pubspec.yaml + * - desterlib-flutter/lib/core/config/api_config.dart + */ + +const fs = require("fs"); +const path = require("path"); + +// ANSI color codes for terminal output +const colors = { + reset: "\x1b[0m", + green: "\x1b[32m", + yellow: "\x1b[33m", + red: "\x1b[31m", + blue: "\x1b[34m", +}; + +function log(message, color = "reset") { + console.log(`${colors[color]}${message}${colors.reset}`); +} + +// Read root version +const rootPackagePath = path.join(__dirname, "../package.json"); +const rootPackage = JSON.parse(fs.readFileSync(rootPackagePath, "utf8")); +const version = rootPackage.version; + +if (!version) { + log("āŒ Error: No version found in root package.json", "red"); + process.exit(1); +} + +log(`\nšŸ“¦ Syncing version: ${version}`, "blue"); +log("─".repeat(50), "blue"); + +let errors = 0; +let updates = 0; + +// Update API package.json +try { + const apiPackagePath = path.join(__dirname, "../apps/api/package.json"); + const apiPackage = JSON.parse(fs.readFileSync(apiPackagePath, "utf8")); + + if (apiPackage.version !== version) { + apiPackage.version = version; + fs.writeFileSync( + apiPackagePath, + JSON.stringify(apiPackage, null, 2) + "\n" + ); + log(`āœ… Updated apps/api/package.json: ${version}`, "green"); + updates++; + } else { + log(`āœ“ apps/api/package.json already at ${version}`, "yellow"); + } +} catch (error) { + log(`āŒ Error updating apps/api/package.json: ${error.message}`, "red"); + errors++; +} + +// Update CLI package.json +try { + const cliPackagePath = path.join(__dirname, "../packages/cli/package.json"); + const cliPackage = JSON.parse(fs.readFileSync(cliPackagePath, "utf8")); + + if (cliPackage.version !== version) { + cliPackage.version = version; + fs.writeFileSync( + cliPackagePath, + JSON.stringify(cliPackage, null, 2) + "\n" + ); + log(`āœ… Updated packages/cli/package.json: ${version}`, "green"); + updates++; + } else { + log(`āœ“ packages/cli/package.json already at ${version}`, "yellow"); + } +} catch (error) { + log(`āŒ Error updating packages/cli/package.json: ${error.message}`, "red"); + errors++; +} + +// Update Flutter pubspec.yaml +try { + const flutterPubspecPath = path.join( + __dirname, + "../../desterlib-flutter/pubspec.yaml" + ); + let pubspecContent = fs.readFileSync(flutterPubspecPath, "utf8"); + + const versionRegex = /^version:\s*[\d.]+$/m; + if (versionRegex.test(pubspecContent)) { + const newContent = pubspecContent.replace( + versionRegex, + `version: ${version}` + ); + if (newContent !== pubspecContent) { + fs.writeFileSync(flutterPubspecPath, newContent); + log(`āœ… Updated desterlib-flutter/pubspec.yaml: ${version}`, "green"); + updates++; + } else { + log(`āœ“ desterlib-flutter/pubspec.yaml already at ${version}`, "yellow"); + } + } else { + log(`āŒ Could not find version in desterlib-flutter/pubspec.yaml`, "red"); + errors++; + } +} catch (error) { + log( + `āŒ Error updating desterlib-flutter/pubspec.yaml: ${error.message}`, + "red" + ); + errors++; +} + +// Update Flutter API client pubspec.yaml +try { + const apiClientPubspecPath = path.join( + __dirname, + "../../desterlib-flutter/lib/api/pubspec.yaml" + ); + let pubspecContent = fs.readFileSync(apiClientPubspecPath, "utf8"); + + const versionRegex = /^version:\s*[\d.]+$/m; + if (versionRegex.test(pubspecContent)) { + const newContent = pubspecContent.replace( + versionRegex, + `version: ${version}` + ); + if (newContent !== pubspecContent) { + fs.writeFileSync(apiClientPubspecPath, newContent); + log( + `āœ… Updated desterlib-flutter/lib/api/pubspec.yaml: ${version}`, + "green" + ); + updates++; + } else { + log( + `āœ“ desterlib-flutter/lib/api/pubspec.yaml already at ${version}`, + "yellow" + ); + } + } else { + log( + `āŒ Could not find version in desterlib-flutter/lib/api/pubspec.yaml`, + "red" + ); + errors++; + } +} catch (error) { + log( + `āŒ Error updating desterlib-flutter/lib/api/pubspec.yaml: ${error.message}`, + "red" + ); + errors++; +} + +// Update Flutter API config +try { + const apiConfigPath = path.join( + __dirname, + "../../desterlib-flutter/lib/core/config/api_config.dart" + ); + let configContent = fs.readFileSync(apiConfigPath, "utf8"); + + const versionRegex = /static const String clientVersion = '[^']+';/; + if (versionRegex.test(configContent)) { + const newContent = configContent.replace( + versionRegex, + `static const String clientVersion = '${version}'; // Synced from root package.json` + ); + if (newContent !== configContent) { + fs.writeFileSync(apiConfigPath, newContent); + log( + `āœ… Updated desterlib-flutter/lib/core/config/api_config.dart: ${version}`, + "green" + ); + updates++; + } else { + log( + `āœ“ desterlib-flutter/lib/core/config/api_config.dart already at ${version}`, + "yellow" + ); + } + } else { + log(`āŒ Could not find clientVersion in api_config.dart`, "red"); + errors++; + } +} catch (error) { + log(`āŒ Error updating api_config.dart: ${error.message}`, "red"); + errors++; +} + +// Summary +log("\n" + "─".repeat(50), "blue"); +if (errors > 0) { + log(`āŒ Completed with ${errors} error(s) and ${updates} update(s)`, "red"); + process.exit(1); +} else if (updates > 0) { + log(`āœ… Successfully updated ${updates} file(s)`, "green"); +} else { + log(`āœ“ All files already at version ${version}`, "yellow"); +} +log(""); diff --git a/scripts/verify-versioning.js b/scripts/verify-versioning.js new file mode 100755 index 0000000..f0ce94d --- /dev/null +++ b/scripts/verify-versioning.js @@ -0,0 +1,476 @@ +#!/usr/bin/env node + +/** + * Versioning & Changelog Verification Script + * + * This script verifies that the versioning and changelog system is properly set up + * before merging to main. It checks: + * - Changesets are valid + * - Changelog sync script works + * - Package versions are consistent + * - Changelogs are properly formatted + * - Docs are updated correctly + */ + +const fs = require("fs"); +const path = require("path"); +const { execSync } = require("child_process"); + +// ANSI color codes for terminal output +const colors = { + reset: "\x1b[0m", + green: "\x1b[32m", + yellow: "\x1b[33m", + red: "\x1b[31m", + blue: "\x1b[34m", + cyan: "\x1b[36m", +}; + +function log(message, color = "reset") { + console.log(`${colors[color]}${message}${colors.reset}`); +} + +function logSection(title) { + console.log(""); + log(`═══════════════════════════════════════════════════════════`, "cyan"); + log(`${title}`, "cyan"); + log(`═══════════════════════════════════════════════════════════`, "cyan"); + console.log(""); +} + +function logCheck(name, passed, message = "") { + const icon = passed ? "āœ“" : "āœ—"; + const color = passed ? "green" : "red"; + log(`${icon} ${name}`, color); + if (message) { + log(` ${message}`, passed ? "green" : "yellow"); + } +} + +// Paths +const rootDir = path.join(__dirname, ".."); +const changesetDir = path.join(rootDir, ".changeset"); +const packageJsonPath = path.join(rootDir, "package.json"); +const apiPackageJsonPath = path.join(rootDir, "apps/api/package.json"); +const cliPackageJsonPath = path.join(rootDir, "packages/cli/package.json"); +const docsPackageJsonPath = path.join(rootDir, "apps/docs/package.json"); + +const apiChangelogPath = path.join(rootDir, "apps/api/CHANGELOG.md"); +const cliChangelogPath = path.join(rootDir, "packages/cli/CHANGELOG.md"); +const docsChangelogPath = path.join(rootDir, "apps/docs/CHANGELOG.md"); + +const apiDocsChangelogPath = path.join( + rootDir, + "apps/docs/src/content/docs/api/changelog.md" +); +const cliDocsChangelogPath = path.join( + rootDir, + "apps/docs/src/content/docs/cli/changelog.md" +); +const docsDocsChangelogPath = path.join( + rootDir, + "apps/docs/src/content/docs/docs/changelog.md" +); + +let allChecksPassed = true; + +/** + * Check if changesets exist and are valid + */ +function checkChangesets() { + logSection("1. Checking Changesets"); + + const changesetFiles = fs + .readdirSync(changesetDir) + .filter((file) => file.endsWith(".md") && file !== "README.md"); + + if (changesetFiles.length === 0) { + // No changesets is OK for docs/tests/CI-only changes. + // We still surface a warning so user-facing changes don't accidentally ship without a changeset. + logCheck( + "Changesets present (optional)", + true, + "No changeset files found. This is fine for docs/tests/CI-only changes, but user-facing changes should include a changeset (run: pnpm changeset)." + ); + return; + } + + logCheck( + "Changesets exist", + true, + `Found ${changesetFiles.length} changeset file(s)` + ); + + // Validate changeset format + let validChangesets = 0; + for (const file of changesetFiles) { + const filePath = path.join(changesetDir, file); + const content = fs.readFileSync(filePath, "utf8"); + + // Check if changeset has frontmatter + if (!content.includes("---")) { + logCheck(`Changeset format: ${file}`, false, "Missing frontmatter"); + allChecksPassed = false; + continue; + } + + // Check if changeset has package name + const hasPackage = /"[\w@/-]+":\s*(major|minor|patch)/.test(content); + if (!hasPackage) { + logCheck( + `Changeset format: ${file}`, + false, + "Missing package declaration" + ); + allChecksPassed = false; + continue; + } + + validChangesets++; + logCheck(`Changeset format: ${file}`, true); + } + + if (validChangesets === changesetFiles.length) { + logCheck("All changesets are valid", true); + } else { + allChecksPassed = false; + } +} + +/** + * Check changeset status + */ +function checkChangesetStatus() { + logSection("2. Checking Changeset Status"); + + try { + const output = execSync("pnpm changeset status", { + cwd: rootDir, + encoding: "utf8", + stdio: "pipe", + }); + + // Check if there are packages to be bumped + if (output.includes("NO packages to be bumped")) { + logCheck( + "Changeset status", + true, + "No packages to be bumped (this is OK)" + ); + } else if (output.includes("Packages to be bumped")) { + logCheck("Changeset status", true, "Packages are ready to be bumped"); + log(`\n${output}`, "yellow"); + } else { + logCheck("Changeset status", true, "Changesets are valid"); + } + } catch (error) { + const message = + (error && (error.stdout || error.stderr || error.message)) || + "Unknown error running changeset status"; + + // When running in CI with a shallow clone, Changesets can fail to find the + // base branch (usually "main"). Locally this works fine because the full + // git history and all branches are available. In that specific case we + // don't want to fail the entire verification, since it doesn't indicate a + // real problem with versioning or changesets themselves. + const isHeadDivergedFromMainError = + typeof message === "string" && + message.includes('Failed to find where HEAD diverged from "main"'); + + if (process.env.CI && isHeadDivergedFromMainError) { + logCheck( + "Changeset status", + true, + 'Skipped in CI: unable to find base branch "main" (likely shallow clone or missing branch in checkout).' + ); + return; + } + + logCheck("Changeset status", false, message); + allChecksPassed = false; + } +} + +/** + * Check package versions + */ +function checkPackageVersions() { + logSection("3. Checking Package Versions"); + + try { + const rootPackage = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")); + const apiPackage = JSON.parse(fs.readFileSync(apiPackageJsonPath, "utf8")); + const cliPackage = JSON.parse(fs.readFileSync(cliPackageJsonPath, "utf8")); + const docsPackage = JSON.parse( + fs.readFileSync(docsPackageJsonPath, "utf8") + ); + + logCheck( + "Root package.json exists", + true, + `Version: ${rootPackage.version}` + ); + logCheck("API package.json exists", true, `Version: ${apiPackage.version}`); + logCheck("CLI package.json exists", true, `Version: ${cliPackage.version}`); + logCheck( + "Docs package.json exists", + true, + `Version: ${docsPackage.version}` + ); + + // Check if versions are semantic + const semverRegex = /^\d+\.\d+\.\d+$/; + const versions = [ + { name: "Root", version: rootPackage.version }, + { name: "API", version: apiPackage.version }, + { name: "CLI", version: cliPackage.version }, + { name: "Docs", version: docsPackage.version }, + ]; + + for (const { name, version } of versions) { + if (!semverRegex.test(version)) { + logCheck( + `${name} version format`, + false, + `Invalid version: ${version}` + ); + allChecksPassed = false; + } else { + logCheck(`${name} version format`, true, version); + } + } + } catch (error) { + logCheck("Package versions", false, error.message); + allChecksPassed = false; + } +} + +/** + * Check changelog files + */ +function checkChangelogs() { + logSection("4. Checking Changelog Files"); + + const changelogs = [ + { name: "API", path: apiChangelogPath, required: false }, + { name: "CLI", path: cliChangelogPath, required: false }, + { name: "Docs", path: docsChangelogPath, required: false }, + ]; + + for (const changelog of changelogs) { + const exists = fs.existsSync(changelog.path); + if (changelog.required && !exists) { + logCheck( + `${changelog.name} CHANGELOG.md`, + false, + "Missing required file" + ); + allChecksPassed = false; + } else if (exists) { + const content = fs.readFileSync(changelog.path, "utf8"); + // Check if changelog has content + if (content.trim().length > 0) { + logCheck( + `${changelog.name} CHANGELOG.md`, + true, + "File exists with content" + ); + } else { + logCheck( + `${changelog.name} CHANGELOG.md`, + true, + "File exists but is empty" + ); + } + } else { + logCheck( + `${changelog.name} CHANGELOG.md`, + true, + "File doesn't exist yet (will be generated)" + ); + } + } +} + +/** + * Check docs changelog pages + */ +function checkDocsChangelogs() { + logSection("5. Checking Docs Changelog Pages"); + + const docsChangelogs = [ + { name: "API", path: apiDocsChangelogPath }, + { name: "CLI", path: cliDocsChangelogPath }, + { name: "Docs", path: docsDocsChangelogPath }, + ]; + + for (const changelog of docsChangelogs) { + const exists = fs.existsSync(changelog.path); + if (!exists) { + logCheck(`${changelog.name} docs changelog`, false, "Missing file"); + allChecksPassed = false; + continue; + } + + const content = fs.readFileSync(changelog.path, "utf8"); + + // Check if it has frontmatter + if (!content.includes("---")) { + logCheck( + `${changelog.name} docs changelog`, + false, + "Missing frontmatter" + ); + allChecksPassed = false; + continue; + } + + // Check if it doesn't have duplicate H1 (title should be in frontmatter only) + const h1Matches = content.match(/^#\s+[^\n]+/gm); + if (h1Matches && h1Matches.length > 0) { + logCheck( + `${changelog.name} docs changelog`, + false, + "Contains H1 heading (should use frontmatter title only)" + ); + allChecksPassed = false; + continue; + } + + logCheck( + `${changelog.name} docs changelog`, + true, + "File exists and formatted correctly" + ); + } +} + +/** + * Test changelog sync script + */ +function testChangelogSync() { + logSection("6. Testing Changelog Sync Script"); + + try { + // Run the sync script + execSync("node scripts/sync-changelog.js", { + cwd: rootDir, + encoding: "utf8", + stdio: "pipe", + }); + + logCheck("Changelog sync script", true, "Script runs successfully"); + + // Verify docs changelogs were updated + const docsChangelogs = [ + { name: "API", path: apiDocsChangelogPath }, + { name: "CLI", path: cliDocsChangelogPath }, + { name: "Docs", path: docsDocsChangelogPath }, + ]; + + for (const changelog of docsChangelogs) { + if (fs.existsSync(changelog.path)) { + const content = fs.readFileSync(changelog.path, "utf8"); + if (content.includes("title:") && content.includes("description:")) { + logCheck( + `${changelog.name} docs changelog sync`, + true, + "File synced correctly" + ); + } else { + logCheck( + `${changelog.name} docs changelog sync`, + false, + "File missing frontmatter" + ); + allChecksPassed = false; + } + } + } + } catch (error) { + logCheck("Changelog sync script", false, error.message); + allChecksPassed = false; + } +} + +/** + * Check sidebar configuration + */ +function checkSidebarConfig() { + logSection("7. Checking Sidebar Configuration"); + + try { + const astroConfigPath = path.join(rootDir, "apps/docs/astro.config.mjs"); + const configContent = fs.readFileSync(astroConfigPath, "utf8"); + + // Check if changelog links are in sidebar + const hasApiChangelog = configContent.includes('"api/changelog"'); + const hasCliChangelog = configContent.includes('"cli/changelog"'); + const hasDocsChangelog = configContent.includes('"docs/changelog"'); + const hasMainChangelog = configContent.includes('"changelog"'); + + logCheck("API changelog in sidebar", hasApiChangelog); + logCheck("CLI changelog in sidebar", hasCliChangelog); + logCheck("Docs changelog in sidebar", hasDocsChangelog); + logCheck("Main changelog in sidebar", hasMainChangelog); + + if ( + !hasApiChangelog || + !hasCliChangelog || + !hasDocsChangelog || + !hasMainChangelog + ) { + allChecksPassed = false; + } + } catch (error) { + logCheck("Sidebar configuration", false, error.message); + allChecksPassed = false; + } +} + +/** + * Main verification function + */ +function main() { + log("\nšŸ” Verifying Versioning & Changelog Setup", "blue"); + log("=".repeat(60), "blue"); + console.log(""); + + checkChangesets(); + checkChangesetStatus(); + checkPackageVersions(); + checkChangelogs(); + checkDocsChangelogs(); + testChangelogSync(); + checkSidebarConfig(); + + // Summary + logSection("Summary"); + + if (allChecksPassed) { + log("āœ… All checks passed! Versioning system is properly set up.", "green"); + console.log(""); + log("Next steps:", "cyan"); + log(" 1. Review changesets: pnpm changeset status", "cyan"); + log(" 2. Test version bump: pnpm version (dry run)", "cyan"); + log(" 3. Verify changelogs are generated correctly", "cyan"); + log(" 4. Commit and push changes", "cyan"); + console.log(""); + process.exit(0); + } else { + log( + "āŒ Some checks failed. Please fix the issues above before merging.", + "red" + ); + console.log(""); + log("Common fixes:", "yellow"); + log(" - Create changesets: pnpm changeset", "yellow"); + log(" - Run changelog sync: pnpm changelog:sync", "yellow"); + log(" - Check package versions are consistent", "yellow"); + console.log(""); + process.exit(1); + } +} + +// Run verification +main();