diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b3292e993..fecc0b66d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -16,30 +16,50 @@ jobs: packages: write steps: - name: Checkout code - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Set up QEMU - uses: docker/setup-qemu-action@v2 + uses: docker/setup-qemu-action@v3 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v2 + uses: docker/setup-buildx-action@v3 - name: Login to Docker Hub - uses: docker/login-action@v2 + uses: docker/login-action@v3 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} - name: Login to GitHub Container Registry - uses: docker/login-action@v2 + uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Generate Docker metadata + - name: Generate Docker metadata (slim) + id: meta-slim + uses: docker/metadata-action@v5 + with: + images: | + name=rommapp/romm + name=ghcr.io/rommapp/romm + flavor: | + latest=auto + suffix=-slim,onlatest=true + tags: | + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=semver,pattern={{major}} + labels: | + org.opencontainers.image.version={{version}}-slim + org.opencontainers.image.title="rommapp/romm" + org.opencontainers.image.description="RomM (ROM Manager) allows you to scan, enrich, and browse your game collection with a clean and responsive interface. With support for multiple platforms, various naming schemes and custom tags, RomM is a must-have for anyone who plays on emulators." + org.opencontainers.image.licenses="AGPLv3" + + - name: Generate Docker metadata (full) id: meta - uses: docker/metadata-action@v4 + uses: docker/metadata-action@v5 with: images: | name=rommapp/romm @@ -52,14 +72,27 @@ jobs: org.opencontainers.image.version={{version}} org.opencontainers.image.title="rommapp/romm" org.opencontainers.image.description="RomM (ROM Manager) allows you to scan, enrich, and browse your game collection with a clean and responsive interface. With support for multiple platforms, various naming schemes and custom tags, RomM is a must-have for anyone who plays on emulators." - org.opencontainers.image.licenses="GPL-3.0" + org.opencontainers.image.licenses="AGPLv3" - name: Set version run: | sed -i 's//${{ steps.meta.outputs.version }}/' backend/__version__.py - - name: Build image - uses: docker/build-push-action@v4 + - name: Build slim image + id: build-slim + uses: docker/build-push-action@v6 + with: + file: docker/Dockerfile + context: . + push: true + platforms: linux/arm64,linux/amd64 + tags: ${{ steps.meta-slim.outputs.tags }} + labels: ${{ steps.meta-slim.outputs.labels }} + target: slim-image + + - name: Build full image + id: build-full + uses: docker/build-push-action@v6 with: file: docker/Dockerfile context: . @@ -67,3 +100,4 @@ jobs: platforms: linux/arm64,linux/amd64 tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} + target: full-image diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index ddb70fca6..30b80beab 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -31,7 +31,7 @@ jobs: options: --health-cmd="mysqladmin ping" --health-interval=5s --health-timeout=2s --health-retries=3 steps: - name: Checkout repository - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Install mariadb connectors run: | @@ -43,7 +43,7 @@ jobs: pipx install poetry - name: Set up Python 3.12 - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: "3.12" cache: "poetry" diff --git a/.github/workflows/trunk-check.yml b/.github/workflows/trunk-check.yml index edb56d80c..033b4bea5 100644 --- a/.github/workflows/trunk-check.yml +++ b/.github/workflows/trunk-check.yml @@ -17,6 +17,6 @@ jobs: contents: read # For repo checkout steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Trunk Check uses: trunk-io/trunk-action@v1 diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml index 80b329205..22a178d6c 100644 --- a/.github/workflows/typecheck.yml +++ b/.github/workflows/typecheck.yml @@ -20,10 +20,10 @@ jobs: pull-requests: write steps: - name: Checkout repository - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Set up Node.js - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version: "18" diff --git a/.gitignore b/.gitignore index d6cae8462..b60f6e619 100644 --- a/.gitignore +++ b/.gitignore @@ -40,6 +40,7 @@ __pycache__ # database mariadb +*.sqlite # used to mock the library/config/mounts/etc while testing frontend/assets/romm diff --git a/.trunk/trunk.yaml b/.trunk/trunk.yaml index 1b01f49fe..781cf81b8 100644 --- a/.trunk/trunk.yaml +++ b/.trunk/trunk.yaml @@ -2,12 +2,12 @@ # To learn more about the format of this file, see https://docs.trunk.io/reference/trunk-yaml version: 0.1 cli: - version: 1.22.4 + version: 1.22.8 # Trunk provides extensibility via plugins. (https://docs.trunk.io/plugins) plugins: sources: - id: trunk - ref: v1.6.2 + ref: v1.6.5 uri: https://github.com/trunk-io/plugins # Many linters and tools depend on runtimes - configure them here. (https://docs.trunk.io/runtimes) runtimes: @@ -18,25 +18,25 @@ runtimes: # This is the section where you manage your linters. (https://docs.trunk.io/check/configuration) lint: enabled: - - markdownlint@0.41.0 - - eslint@9.9.1 - - actionlint@1.7.1 - - bandit@1.7.9 - - black@24.8.0 - - checkov@3.2.239 + - markdownlint@0.43.0 + - eslint@9.16.0 + - actionlint@1.7.4 + - bandit@1.8.0 + - black@24.10.0 + - checkov@3.2.332 - git-diff-check - isort@5.13.2 - mypy@1.13.0 - - osv-scanner@1.8.4 - - oxipng@9.1.2 - - prettier@3.3.3 - - ruff@0.6.3 + - osv-scanner@1.9.1 + - oxipng@9.1.3 + - prettier@3.4.2 + - ruff@0.8.2 - shellcheck@0.10.0 - shfmt@3.6.0 - svgo@3.3.2 - taplo@0.9.3 - - trivy@0.54.1 - - trufflehog@3.81.10 + - trivy@0.56.2 + - trufflehog@3.85.0 - yamllint@1.35.1 ignore: - linters: [ALL] diff --git a/.zed/settings.json b/.zed/settings.json deleted file mode 100644 index c808db432..000000000 --- a/.zed/settings.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "languages": { - "Python": { - "tab_size": 4 - }, - "Vue.js": { - "tab_size": 2, - "formatter": { - "external": { - "command": "prettier", - "arguments": ["--stdin-filepath", "{buffer_path}"] - } - } - } - } -} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 341a83d6a..8060e2c59 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -10,7 +10,11 @@ Please note that this project adheres to the Contributor Covenant [code of condu ## Contributing to the Wiki -If you would like to contribute to the project's [documentation](https://github.com/rommapp/romm/wiki), reach out to the maintainers to get edit access. We welcome any contributions that help improve the documentation (new pages, updates, or corrections). +If you would like to contribute to the project's [documentation](https://github.com/rommapp/romm/wiki), open a pull request against [the wiki repo](https://github.com/rommapp/wiki). We welcome any contributions that help improve the documentation (new pages, updates, or corrections). + +## Adding Translations + +If you would like to translate the project into another language, create a new folder under the `frontend/src/locales` directory, and follow the existing language files as a template. Once you've created the new language file, open a pull request to add it to the project. ## How to Contribute Code diff --git a/DEVELOPER_SETUP.md b/DEVELOPER_SETUP.md index 60ae84726..fec76bc6d 100644 --- a/DEVELOPER_SETUP.md +++ b/DEVELOPER_SETUP.md @@ -58,7 +58,7 @@ CFLAGS="-Wno-error=incompatible-pointer-types" poetry install --sync #### - Spin up mariadb in docker ```sh -docker-compose up -d +docker compose up -d ``` #### - Run the backend @@ -125,12 +125,12 @@ trunk check ### - Create the test user and database with root user ```sh -docker exec -i mariadb mariadb -u root -p < backend/romm_test/setup.sql +docker exec -i romm-mariadb-dev mariadb -uroot -p < backend/romm_test/setup.sql ``` ### - Run tests -*\_\_*Migrations will be run automatically when running the tests.\_\_\* +*\_*Migrations will be run automatically when running the tests.\_\_\_ ```sh cd backend diff --git a/README.md b/README.md index d62e7f51c..7c70394f5 100644 --- a/README.md +++ b/README.md @@ -40,13 +40,13 @@ # Overview -RomM (ROM Manager) allows you to scan, enrich, and browse your game collection with a clean and responsive interface. With support for multiple platforms, various naming schemes, and custom tags, RomM is a must-have for anyone who plays on emulators. +RomM (ROM Manager) allows you to scan, enrich, browse and play your game collection with a clean and responsive interface. With support for multiple platforms, various naming schemes, and custom tags, RomM is a must-have for anyone who plays on emulators. ## Features - Scans your existing games library and enhances it with metadata from [IGDB][igdb-api] and [MobyGames][mobygames-api] - Supports a large number of **[platforms][platform-support]** -- Play games directly from the browser using [EmulatorJS][wiki-emulatorjs] +- Play games directly from the browser using [EmulatorJS][wiki-emulatorjs] and RuffleRS - Share your library with friends while [limiting access and permissions][wiki-authentication] - Supports MAME, Nintendo Switch, and Sony Playstation naming schemes - Detects and groups **multifile games** (e.g. PS1 games with multiple CDs) @@ -61,14 +61,7 @@ RomM (ROM Manager) allows you to scan, enrich, and browse your game collection w # Installation -Before running the [image][docker-tags], please ensure that Docker is installed and running on your system. - -1. [Generate API keys][wiki-generate-api-keys] for IGDB and/or MobyGames to fetch metadata. -2. Verify that your library folder structure matches one of the options listed in the [folder structure][folder-structure] section. -3. Create a docker-compose.yml file by referring to the example [docker-compose.yml][docker-compose-example] file for guidance, and customize it for your setup with [the available environment variables][wiki-env-variables]. -4. Launch the container(s) with `docker-compose up -d`. - -> [!NOTE] > **If you are having issues with RomM, please review the [wiki page][wiki-troubleshooting] for troubleshooting steps and common issues.** +To start using RomM, check out the [Quick Start Guide][wiki-quick-start-guide] in the wiki. If you are having issues with RomM, please review the page for [troubleshooting steps][wiki-troubleshooting] and common issues, or join the [Discord][discord-invite] for support from the community. # Configuration @@ -202,7 +195,8 @@ Here are a few projects that we think you might like: - [EmulatorJS](https://emulatorjs.org/): An embeddable, browser-based emulator - [RetroDECK](https://retrodeck.net/): Retro gaming on SteamOS and Linux - [ES-DE Frontend](https://es-de.org/): Emulator frontend for Linux, macOS and Windows -- [Gaseous](https://github.com/gaseous-project/gaseous-server): Another self-hosted ROM manager +- [Gaseous](https://github.com/gaseous-project/gaseous-server): Another ROM manager with web-based emulator +- [Retrom](https://github.com/JMBeresford/retrom): A centralized game library/collection management service - [Steam ROM Manager](https://steamgriddb.github.io/steam-rom-manager/): An app for managing ROMs in Steam @@ -225,9 +219,8 @@ Here are a few projects that we think you might like: [wiki-platforms-icons]: https://github.com/rommapp/romm/wiki/Custom-Platform-Icons [wiki-troubleshooting]: https://github.com/rommapp/romm/wiki/Troubleshooting [wiki-emulatorjs]: https://github.com/rommapp/romm/wiki/EmulatorJS-Player -[wiki-env-variables]: https://github.com/rommapp/romm/wiki/Environment-Variables [wiki-scheduled-tasks]: https://github.com/rommapp/romm/wiki/Scheduled-Tasks -[wiki-generate-api-keys]: https://github.com/rommapp/romm/wiki/Generate-API-Keys +[wiki-quick-start-guide]: https://github.com/rommapp/romm/wiki/Quick-Start-Guide @@ -247,12 +240,11 @@ Here are a few projects that we think you might like: [discord-invite-img]: https://invidget.switchblade.xyz/P5HtHnhUDH [discord-invite]: https://discord.gg/P5HtHnhUDH -[oc-donate-img]: https://opencollective.com/romm/donate/button@2x.png?color=blue +[oc-donate-img]: https://opencollective.com/romm/donate/button.png?color=blue [oc-donate]: https://opencollective.com/romm -[docker-tags]: https://hub.docker.com/r/rommapp/romm/tags [igdb-api]: https://api-docs.igdb.com/#account-creation [mobygames-api]: https://www.mobygames.com/info/api/ [big-bear-casaos]: https://github.com/bigbeartechworld/big-bear-casaos diff --git a/backend/alembic/versions/0009_models_refactor.py b/backend/alembic/versions/0009_models_refactor.py index 21472bb8f..189462beb 100644 --- a/backend/alembic/versions/0009_models_refactor.py +++ b/backend/alembic/versions/0009_models_refactor.py @@ -8,8 +8,8 @@ import sqlalchemy as sa from alembic import op -from sqlalchemy.dialects import mysql from sqlalchemy.exc import OperationalError +from utils.database import CustomJSON # revision identifiers, used by Alembic. revision = "0009_models_refactor" @@ -19,23 +19,30 @@ def upgrade() -> None: + connection = op.get_bind() + try: with op.batch_alter_table("platforms", schema=None) as batch_op: batch_op.alter_column( - "igdb_id", existing_type=mysql.VARCHAR(length=10), nullable=True + "igdb_id", existing_type=sa.VARCHAR(length=10), nullable=True ) batch_op.alter_column( - "sgdb_id", existing_type=mysql.VARCHAR(length=10), nullable=True + "sgdb_id", existing_type=sa.VARCHAR(length=10), nullable=True ) batch_op.alter_column( - "slug", existing_type=mysql.VARCHAR(length=50), nullable=False + "slug", existing_type=sa.VARCHAR(length=50), nullable=False ) batch_op.alter_column( - "name", existing_type=mysql.VARCHAR(length=400), nullable=True + "name", existing_type=sa.VARCHAR(length=400), nullable=True ) # Move primary key to slug - batch_op.drop_constraint(constraint_name="PRIMARY", type_="primary") + pk_constraint_name = connection.dialect.get_pk_constraint( + connection, table_name="platforms" + )["name"] + batch_op.drop_constraint( + constraint_name=pk_constraint_name, type_="primary" + ) batch_op.create_primary_key(constraint_name=None, columns=["slug"]) except ValueError as e: print(f"Cannot drop primary key on platforms table: {e}") @@ -45,48 +52,48 @@ def upgrade() -> None: with op.batch_alter_table("roms", schema=None) as batch_op: batch_op.alter_column( "r_igdb_id", - existing_type=mysql.VARCHAR(length=10), + existing_type=sa.VARCHAR(length=10), new_column_name="igdb_id", ) batch_op.alter_column( "r_sgdb_id", - existing_type=mysql.VARCHAR(length=10), + existing_type=sa.VARCHAR(length=10), new_column_name="sgdb_id", ) batch_op.alter_column( - "r_slug", existing_type=mysql.VARCHAR(length=400), new_column_name="slug" + "r_slug", existing_type=sa.VARCHAR(length=400), new_column_name="slug" ) batch_op.alter_column( - "r_name", existing_type=mysql.VARCHAR(length=350), new_column_name="name" + "r_name", existing_type=sa.VARCHAR(length=350), new_column_name="name" ) batch_op.alter_column( "p_slug", - existing_type=mysql.VARCHAR(length=50), + existing_type=sa.VARCHAR(length=50), new_column_name="platform_slug", nullable=False, ) batch_op.alter_column( - "file_extension", existing_type=mysql.VARCHAR(length=10), nullable=False + "file_extension", existing_type=sa.VARCHAR(length=10), nullable=False ) batch_op.alter_column( - "file_path", existing_type=mysql.VARCHAR(length=1000), nullable=False + "file_path", existing_type=sa.VARCHAR(length=1000), nullable=False ) - batch_op.alter_column("file_size", existing_type=mysql.FLOAT(), nullable=False) + batch_op.alter_column("file_size", existing_type=sa.FLOAT(), nullable=False) batch_op.alter_column( - "file_size_units", existing_type=mysql.VARCHAR(length=10), nullable=False + "file_size_units", existing_type=sa.VARCHAR(length=10), nullable=False ) batch_op.alter_column( "url_screenshots", - existing_type=mysql.LONGTEXT(charset="utf8mb4", collation="utf8mb4_bin"), + existing_type=CustomJSON(), nullable=True, - existing_server_default=sa.text("'[]'"), + existing_server_default=sa.text("(JSON_ARRAY())"), ) batch_op.alter_column( "path_screenshots", - existing_type=mysql.LONGTEXT(charset="utf8mb4", collation="utf8mb4_bin"), + existing_type=CustomJSON(), nullable=True, - existing_server_default=sa.text("'[]'"), + existing_server_default=sa.text("(JSON_ARRAY())"), ) try: @@ -99,51 +106,53 @@ def upgrade() -> None: def downgrade() -> None: + connection = op.get_bind() + with op.batch_alter_table("roms", schema=None) as batch_op: batch_op.alter_column( "igdb_id", - existing_type=mysql.VARCHAR(length=10), + existing_type=sa.VARCHAR(length=10), new_column_name="r_igdb_id", ) batch_op.alter_column( "sgdb_id", - existing_type=mysql.VARCHAR(length=10), + existing_type=sa.VARCHAR(length=10), new_column_name="r_sgdb_id", ) batch_op.alter_column( - "slug", existing_type=mysql.VARCHAR(length=400), new_column_name="r_slug" + "slug", existing_type=sa.VARCHAR(length=400), new_column_name="r_slug" ) batch_op.alter_column( - "name", existing_type=mysql.VARCHAR(length=350), new_column_name="r_name" + "name", existing_type=sa.VARCHAR(length=350), new_column_name="r_name" ) batch_op.alter_column( "platform_slug", - existing_type=mysql.VARCHAR(length=50), + existing_type=sa.VARCHAR(length=50), new_column_name="p_slug", nullable=True, ) batch_op.alter_column( "path_screenshots", - existing_type=mysql.LONGTEXT(charset="utf8mb4", collation="utf8mb4_bin"), + existing_type=CustomJSON(), nullable=False, - existing_server_default=sa.text("'[]'"), + existing_server_default=sa.text("(JSON_ARRAY())"), ) batch_op.alter_column( "url_screenshots", - existing_type=mysql.LONGTEXT(charset="utf8mb4", collation="utf8mb4_bin"), + existing_type=CustomJSON(), nullable=False, - existing_server_default=sa.text("'[]'"), + existing_server_default=sa.text("(JSON_ARRAY())"), ) batch_op.alter_column( - "file_size_units", existing_type=mysql.VARCHAR(length=10), nullable=True + "file_size_units", existing_type=sa.VARCHAR(length=10), nullable=True ) - batch_op.alter_column("file_size", existing_type=mysql.FLOAT(), nullable=True) + batch_op.alter_column("file_size", existing_type=sa.FLOAT(), nullable=True) batch_op.alter_column( - "file_path", existing_type=mysql.VARCHAR(length=1000), nullable=True + "file_path", existing_type=sa.VARCHAR(length=1000), nullable=True ) batch_op.alter_column( - "file_extension", existing_type=mysql.VARCHAR(length=10), nullable=True + "file_extension", existing_type=sa.VARCHAR(length=10), nullable=True ) try: @@ -156,12 +165,18 @@ def downgrade() -> None: try: with op.batch_alter_table("platforms", schema=None) as batch_op: + # Move primary key back to fs_slug + pk_constraint_name = connection.dialect.get_pk_constraint( + connection, table_name="platforms" + )["name"] + batch_op.drop_constraint( + constraint_name=pk_constraint_name, type_="primary" + ) + batch_op.alter_column( - "slug", existing_type=mysql.VARCHAR(length=50), nullable=True + "slug", existing_type=sa.VARCHAR(length=50), nullable=True ) - # Move primary key back to fs_slug - batch_op.drop_constraint(constraint_name="PRIMARY", type_="primary") batch_op.create_primary_key(constraint_name=None, columns=["fs_slug"]) print("Moved primary key back to fs_slug column on platforms table") except ValueError as e: diff --git a/backend/alembic/versions/0010_igdb_id_integerr.py b/backend/alembic/versions/0010_igdb_id_integerr.py index d7455334b..8e7ad272b 100644 --- a/backend/alembic/versions/0010_igdb_id_integerr.py +++ b/backend/alembic/versions/0010_igdb_id_integerr.py @@ -8,7 +8,6 @@ import sqlalchemy as sa from alembic import op -from sqlalchemy.dialects import mysql # revision identifiers, used by Alembic. revision = "0010_igdb_id_integerr" @@ -18,43 +17,44 @@ def upgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### with op.batch_alter_table("platforms", schema=None) as batch_op: - batch_op.execute('UPDATE platforms SET igdb_id = NULL WHERE igdb_id = ""') - batch_op.execute('UPDATE platforms SET sgdb_id = NULL WHERE sgdb_id = ""') + batch_op.execute("UPDATE platforms SET igdb_id = NULL WHERE igdb_id = ''") + batch_op.execute("UPDATE platforms SET sgdb_id = NULL WHERE sgdb_id = ''") batch_op.alter_column( "igdb_id", - existing_type=mysql.VARCHAR(length=10), + existing_type=sa.VARCHAR(length=10), type_=sa.Integer(), existing_nullable=True, + postgresql_using="igdb_id::integer", ) batch_op.alter_column( "sgdb_id", - existing_type=mysql.VARCHAR(length=10), + existing_type=sa.VARCHAR(length=10), type_=sa.Integer(), existing_nullable=True, + postgresql_using="sgdb_id::integer", ) with op.batch_alter_table("roms", schema=None) as batch_op: - batch_op.execute('UPDATE roms SET igdb_id = NULL WHERE igdb_id = ""') - batch_op.execute('UPDATE roms SET sgdb_id = NULL WHERE sgdb_id = ""') + batch_op.execute("UPDATE roms SET igdb_id = NULL WHERE igdb_id = ''") + batch_op.execute("UPDATE roms SET sgdb_id = NULL WHERE sgdb_id = ''") batch_op.alter_column( "igdb_id", - existing_type=mysql.VARCHAR(length=10), + existing_type=sa.VARCHAR(length=10), type_=sa.Integer(), existing_nullable=True, + postgresql_using="igdb_id::integer", ) batch_op.alter_column( "sgdb_id", - existing_type=mysql.VARCHAR(length=10), + existing_type=sa.VARCHAR(length=10), type_=sa.Integer(), existing_nullable=True, + postgresql_using="sgdb_id::integer", ) - # ### end Alembic commands ### - def downgrade() -> None: # ### commands auto generated by Alembic - please adjust! ### @@ -62,13 +62,13 @@ def downgrade() -> None: batch_op.alter_column( "sgdb_id", existing_type=sa.Integer(), - type_=mysql.VARCHAR(length=10), + type_=sa.VARCHAR(length=10), existing_nullable=True, ) batch_op.alter_column( "igdb_id", existing_type=sa.Integer(), - type_=mysql.VARCHAR(length=10), + type_=sa.VARCHAR(length=10), existing_nullable=True, ) @@ -76,13 +76,13 @@ def downgrade() -> None: batch_op.alter_column( "sgdb_id", existing_type=sa.Integer(), - type_=mysql.VARCHAR(length=10), + type_=sa.VARCHAR(length=10), existing_nullable=True, ) batch_op.alter_column( "igdb_id", existing_type=sa.Integer(), - type_=mysql.VARCHAR(length=10), + type_=sa.VARCHAR(length=10), existing_nullable=True, ) diff --git a/backend/alembic/versions/0011_drop_has_cover.py b/backend/alembic/versions/0011_drop_has_cover.py index f6c076363..11cd2f280 100644 --- a/backend/alembic/versions/0011_drop_has_cover.py +++ b/backend/alembic/versions/0011_drop_has_cover.py @@ -8,7 +8,6 @@ import sqlalchemy as sa from alembic import op -from sqlalchemy.dialects import mysql # revision identifiers, used by Alembic. revision = "0011_drop_has_cover" @@ -31,7 +30,7 @@ def downgrade() -> None: batch_op.add_column( sa.Column( "has_cover", - mysql.BOOLEAN(), + sa.BOOLEAN(), autoincrement=False, nullable=True, ) diff --git a/backend/alembic/versions/0012_add_regions_languages.py b/backend/alembic/versions/0012_add_regions_languages.py index 72079553e..3cb2029da 100644 --- a/backend/alembic/versions/0012_add_regions_languages.py +++ b/backend/alembic/versions/0012_add_regions_languages.py @@ -8,7 +8,7 @@ import sqlalchemy as sa from alembic import op -from sqlalchemy.dialects import mysql +from utils.database import CustomJSON, is_postgresql # revision identifiers, used by Alembic. revision = "0012_add_regions_languages" @@ -20,12 +20,12 @@ def upgrade() -> None: # ### commands auto generated by Alembic - please adjust! ### with op.batch_alter_table("roms", schema=None) as batch_op: - batch_op.add_column(sa.Column("regions", sa.JSON(), nullable=True)) - batch_op.add_column(sa.Column("languages", sa.JSON(), nullable=True)) + batch_op.add_column(sa.Column("regions", CustomJSON(), nullable=True)) + batch_op.add_column(sa.Column("languages", CustomJSON(), nullable=True)) with op.batch_alter_table("roms", schema=None) as batch_op: # Set default values for languages and regions - batch_op.execute("UPDATE roms SET languages = '[]'") + batch_op.execute("UPDATE roms SET languages = JSON_ARRAY()") batch_op.execute("UPDATE roms SET regions = JSON_ARRAY(region)") batch_op.drop_column("region") @@ -33,15 +33,15 @@ def upgrade() -> None: def downgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### + connection = op.get_bind() + with op.batch_alter_table("roms", schema=None) as batch_op: - batch_op.add_column( - sa.Column("region", mysql.VARCHAR(length=20), nullable=True) - ) + batch_op.add_column(sa.Column("region", sa.VARCHAR(length=20), nullable=True)) with op.batch_alter_table("roms", schema=None) as batch_op: - batch_op.execute("UPDATE roms SET region = JSON_EXTRACT(regions, '$[0]')") + if is_postgresql(connection): + batch_op.execute("UPDATE roms SET region = regions->>0") + else: + batch_op.execute("UPDATE roms SET region = JSON_EXTRACT(regions, '$[0]')") batch_op.drop_column("languages") batch_op.drop_column("regions") - - # ### end Alembic commands ### diff --git a/backend/alembic/versions/0014_asset_files.py b/backend/alembic/versions/0014_asset_files.py index c2690ed58..f1c994301 100644 --- a/backend/alembic/versions/0014_asset_files.py +++ b/backend/alembic/versions/0014_asset_files.py @@ -13,8 +13,8 @@ from config import ROMM_DB_DRIVER from config.config_manager import SQLITE_DB_BASE_PATH, ConfigManager from sqlalchemy import create_engine, text -from sqlalchemy.dialects import mysql from sqlalchemy.orm import sessionmaker +from utils.database import CustomJSON, is_postgresql # revision identifiers, used by Alembic. revision = "0014_asset_files" @@ -32,26 +32,28 @@ } -def migrate_to_mysql() -> None: - if ROMM_DB_DRIVER != "mariadb": - raise Exception("Version 3.0 requires MariaDB as database driver!") +def migrate_to_supported_engine() -> None: + if ROMM_DB_DRIVER not in ("mariadb", "mysql", "postgresql"): + raise Exception( + "Version 3.0 requires MariaDB, MySQL, or PostgreSQL as database driver!" + ) # Skip if sqlite database is not mounted if not os.path.exists(f"{SQLITE_DB_BASE_PATH}/romm.db"): return - maria_engine = create_engine(ConfigManager.get_db_engine(), pool_pre_ping=True) - maria_session = sessionmaker(bind=maria_engine, expire_on_commit=False) + engine = create_engine(ConfigManager.get_db_engine(), pool_pre_ping=True) + session = sessionmaker(bind=engine, expire_on_commit=False) sqlite_engine = create_engine( f"sqlite:////{SQLITE_DB_BASE_PATH}/romm.db", pool_pre_ping=True ) sqlite_session = sessionmaker(bind=sqlite_engine, expire_on_commit=False) - # Copy all data from sqlite to maria - with maria_session.begin() as maria_conn: + # Copy all data from sqlite to new database + with session.begin() as conn: with sqlite_session.begin() as sqlite_conn: - maria_conn.execute(text("SET FOREIGN_KEY_CHECKS=0")) + conn.execute(text("SET FOREIGN_KEY_CHECKS=0")) tables = sqlite_conn.execute( text("SELECT name FROM sqlite_master WHERE type='table';") @@ -67,20 +69,22 @@ def migrate_to_mysql() -> None: text(f"SELECT * FROM {table_name}") # nosec B608 ).fetchall() - # Insert data into MariaDB table + # Insert data into new tables for row in table_data: mapped_row = {f"{i}": value for i, value in enumerate(row, start=1)} columns = ",".join([f":{i}" for i in range(1, len(row) + 1)]) insert_query = ( f"INSERT INTO {table_name} VALUES ({columns})" # nosec B608 ) - maria_conn.execute(text(insert_query), mapped_row) + conn.execute(text(insert_query), mapped_row) - maria_conn.execute(text("SET FOREIGN_KEY_CHECKS=1")) + conn.execute(text("SET FOREIGN_KEY_CHECKS=1")) def upgrade() -> None: - migrate_to_mysql() + migrate_to_supported_engine() + + connection = op.get_bind() op.create_table( "saves", @@ -154,13 +158,19 @@ def upgrade() -> None: # Drop the primary key (slug) with op.batch_alter_table("platforms", schema=None) as batch_op: - batch_op.drop_constraint(constraint_name="PRIMARY", type_="primary") + pk_constraint_name = connection.dialect.get_pk_constraint( + connection, table_name="platforms" + )["name"] + batch_op.drop_constraint(constraint_name=pk_constraint_name, type_="primary") batch_op.drop_column("n_roms") # Switch to new id column as platform primary key - op.execute( - "ALTER TABLE platforms ADD COLUMN id INTEGER(11) NOT NULL AUTO_INCREMENT PRIMARY KEY" - ) + if is_postgresql(connection): + op.execute("ALTER TABLE platforms ADD COLUMN id SERIAL PRIMARY KEY") + else: + op.execute( + "ALTER TABLE platforms ADD COLUMN id INTEGER(11) NOT NULL AUTO_INCREMENT PRIMARY KEY" + ) # Add new columns to roms table with op.batch_alter_table("roms", schema=None) as batch_op: @@ -170,27 +180,37 @@ def upgrade() -> None: batch_op.add_column( sa.Column("file_size_bytes", sa.BigInteger(), nullable=False) ) - batch_op.add_column(sa.Column("igdb_metadata", mysql.JSON(), nullable=True)) + batch_op.add_column(sa.Column("igdb_metadata", CustomJSON(), nullable=True)) batch_op.add_column(sa.Column("platform_id", sa.Integer(), nullable=False)) batch_op.alter_column( "revision", - existing_type=mysql.VARCHAR(length=20), + existing_type=sa.VARCHAR(length=20), type_=sa.String(length=100), existing_nullable=True, ) # Move data around with op.batch_alter_table("roms", schema=None) as batch_op: - batch_op.execute("update roms set igdb_metadata = '\\{\\}'") + batch_op.execute("update roms set igdb_metadata = JSON_OBJECT()") batch_op.execute( "update roms set path_cover_s = '', path_cover_l = '', url_cover = '' where url_cover = 'https://images.igdb.com/igdb/image/upload/t_cover_big/nocover.png'" ) batch_op.execute( "update roms set file_name_no_ext = regexp_replace(file_name, '\\.[a-z]{2,}$', '')" ) - batch_op.execute( - "update roms inner join platforms on roms.platform_slug = platforms.slug set roms.platform_id = platforms.id" - ) + if is_postgresql(connection): + batch_op.execute( + """ + UPDATE roms + SET platform_id = platforms.id + FROM platforms + WHERE roms.platform_slug = platforms.slug + """ + ) + else: + batch_op.execute( + "update roms inner join platforms on roms.platform_slug = platforms.slug set roms.platform_id = platforms.id" + ) # Process filesize data and prepare for bulk update connection = op.get_bind() @@ -225,36 +245,39 @@ def upgrade() -> None: def downgrade() -> None: + connection = op.get_bind() + with op.batch_alter_table("roms", schema=None) as batch_op: batch_op.add_column( - sa.Column("platform_slug", mysql.VARCHAR(length=50), nullable=False) - ) - batch_op.add_column( - sa.Column("p_igdb_id", mysql.VARCHAR(length=10), nullable=True) + sa.Column("platform_slug", sa.VARCHAR(length=50), nullable=False) ) batch_op.add_column( - sa.Column("p_name", mysql.VARCHAR(length=150), nullable=True) + sa.Column("p_igdb_id", sa.VARCHAR(length=10), nullable=True) ) + batch_op.add_column(sa.Column("p_name", sa.VARCHAR(length=150), nullable=True)) batch_op.add_column( - sa.Column("p_sgdb_id", mysql.VARCHAR(length=10), nullable=True) + sa.Column("p_sgdb_id", sa.VARCHAR(length=10), nullable=True) ) batch_op.add_column( - sa.Column("file_size_units", mysql.VARCHAR(length=10), nullable=False) + sa.Column("file_size_units", sa.VARCHAR(length=10), nullable=False) ) - batch_op.add_column(sa.Column("file_size", mysql.FLOAT(), nullable=False)) + batch_op.add_column(sa.Column("file_size", sa.FLOAT(), nullable=False)) batch_op.drop_constraint("fk_platform_id_roms", type_="foreignkey") with op.batch_alter_table("roms", schema=None) as batch_op: - batch_op.create_foreign_key( - "fk_platform_roms", - "platforms", - ["platform_slug"], - ["slug"], - ondelete="CASCADE", - ) - batch_op.execute( - "update roms inner join platforms on roms.platform_id = platforms.id set roms.platform_slug = platforms.slug" - ) + if is_postgresql(connection): + batch_op.execute( + """ + UPDATE roms + SET platform_slug = platforms.slug + FROM platforms + WHERE roms.platform_id = platforms.id + """ + ) + else: + batch_op.execute( + "update roms inner join platforms on roms.platform_id = platforms.id set roms.platform_slug = platforms.slug" + ) batch_op.execute( "update roms set url_cover = 'https://images.igdb.com/igdb/image/upload/t_cover_big/nocover.png' where url_cover = ''" ) @@ -286,7 +309,7 @@ def downgrade() -> None: batch_op.alter_column( "revision", existing_type=sa.String(length=100), - type_=mysql.VARCHAR(length=20), + type_=sa.VARCHAR(length=20), existing_nullable=True, ) @@ -294,12 +317,22 @@ def downgrade() -> None: batch_op.add_column( sa.Column( "n_roms", - mysql.INTEGER(display_width=11), + sa.INTEGER(), autoincrement=False, nullable=True, ) ) batch_op.drop_column("id") + batch_op.create_primary_key(constraint_name=None, columns=["slug"]) + + with op.batch_alter_table("roms", schema=None) as batch_op: + batch_op.create_foreign_key( + "fk_platform_roms", + "platforms", + ["platform_slug"], + ["slug"], + ondelete="CASCADE", + ) op.drop_table("states") op.drop_table("screenshots") diff --git a/backend/alembic/versions/0015_mobygames_data.py b/backend/alembic/versions/0015_mobygames_data.py index b2f48afa0..f8ca0e1e2 100644 --- a/backend/alembic/versions/0015_mobygames_data.py +++ b/backend/alembic/versions/0015_mobygames_data.py @@ -8,7 +8,7 @@ import sqlalchemy as sa from alembic import op -from sqlalchemy.dialects import mysql +from utils.database import CustomJSON # revision identifiers, used by Alembic. revision = "0015_mobygames_data" @@ -24,10 +24,10 @@ def upgrade() -> None: with op.batch_alter_table("roms", schema=None) as batch_op: batch_op.add_column(sa.Column("moby_id", sa.Integer(), nullable=True)) - batch_op.add_column(sa.Column("moby_metadata", mysql.JSON(), nullable=True)) + batch_op.add_column(sa.Column("moby_metadata", CustomJSON(), nullable=True)) with op.batch_alter_table("roms", schema=None) as batch_op: - batch_op.execute("update roms set moby_metadata = '\\{\\}'") + batch_op.execute("update roms set moby_metadata = JSON_OBJECT()") # ### end Alembic commands ### diff --git a/backend/alembic/versions/0019_resources_refactor.py b/backend/alembic/versions/0019_resources_refactor.py index 22b47ba13..99430cad9 100644 --- a/backend/alembic/versions/0019_resources_refactor.py +++ b/backend/alembic/versions/0019_resources_refactor.py @@ -85,7 +85,7 @@ def upgrade() -> None: connection.execute( sa.text( """ - UPDATE roms + UPDATE roms SET path_cover_s = :path_cover_s, path_cover_l = :path_cover_l, path_screenshots = :path_screenshots diff --git a/backend/alembic/versions/0022_collections_.py b/backend/alembic/versions/0022_collections_.py index bf8a69b44..b0010e320 100644 --- a/backend/alembic/versions/0022_collections_.py +++ b/backend/alembic/versions/0022_collections_.py @@ -14,7 +14,7 @@ from alembic import op from config import RESOURCES_BASE_PATH from sqlalchemy import inspect -from sqlalchemy.dialects import mysql +from utils.database import CustomJSON # revision identifiers, used by Alembic. revision = "0022_collections" @@ -95,7 +95,7 @@ def upgrade() -> None: sa.Column("path_cover_l", sa.String(length=1000), nullable=True), sa.Column("path_cover_s", sa.String(length=1000), nullable=True), sa.Column("url_cover", sa.Text(), nullable=True), - sa.Column("roms", sa.JSON(), nullable=False), + sa.Column("roms", CustomJSON(), nullable=False), sa.Column("user_id", sa.Integer(), nullable=False), sa.Column("is_public", sa.Boolean(), nullable=False), sa.Column( @@ -117,7 +117,7 @@ def upgrade() -> None: with op.batch_alter_table("rom_user", schema=None) as batch_op: batch_op.alter_column( "is_main_sibling", - existing_type=mysql.TINYINT(display_width=1), + existing_type=sa.Boolean(), nullable=True, ) # ### end Alembic commands ### @@ -128,7 +128,7 @@ def downgrade() -> None: with op.batch_alter_table("rom_user", schema=None) as batch_op: batch_op.alter_column( "is_main_sibling", - existing_type=mysql.TINYINT(display_width=1), + existing_type=sa.Boolean(), nullable=False, ) diff --git a/backend/alembic/versions/0023_make_columns_non_nullable.py b/backend/alembic/versions/0023_make_columns_non_nullable.py index 267af7c32..11b9ac008 100644 --- a/backend/alembic/versions/0023_make_columns_non_nullable.py +++ b/backend/alembic/versions/0023_make_columns_non_nullable.py @@ -8,6 +8,7 @@ import sqlalchemy as sa from alembic import op +from utils.database import is_postgresql # revision identifiers, used by Alembic. revision = "0023_make_columns_non_nullable" @@ -17,6 +18,9 @@ def upgrade() -> None: + connection = op.get_bind() + false_value = "FALSE" if is_postgresql(connection) else "0" + with op.batch_alter_table("platforms", schema=None) as batch_op: batch_op.execute("UPDATE platforms SET name = '' WHERE name IS NULL") batch_op.alter_column( @@ -25,20 +29,22 @@ def upgrade() -> None: with op.batch_alter_table("rom_user", schema=None) as batch_op: batch_op.execute( - "UPDATE rom_user SET note_is_public = 0 WHERE note_is_public IS NULL" + f"UPDATE rom_user SET note_is_public = {false_value} WHERE note_is_public IS NULL" # nosec B608 ) batch_op.alter_column( "note_is_public", existing_type=sa.Boolean(), nullable=False ) batch_op.execute( - "UPDATE rom_user SET is_main_sibling = 0 WHERE is_main_sibling IS NULL" + f"UPDATE rom_user SET is_main_sibling = {false_value} WHERE is_main_sibling IS NULL" # nosec B608 ) batch_op.alter_column( "is_main_sibling", existing_type=sa.Boolean(), nullable=False ) with op.batch_alter_table("roms", schema=None) as batch_op: - batch_op.execute("UPDATE roms SET multi = 0 WHERE multi IS NULL") + batch_op.execute( + f"UPDATE roms SET multi = {false_value} WHERE multi IS NULL" # nosec B608 + ) batch_op.alter_column("multi", existing_type=sa.Boolean(), nullable=False) with op.batch_alter_table("users", schema=None) as batch_op: @@ -46,7 +52,9 @@ def upgrade() -> None: batch_op.alter_column( "username", existing_type=sa.String(length=255), nullable=False ) - batch_op.execute("UPDATE users SET enabled = 0 WHERE enabled IS NULL") + batch_op.execute( + f"UPDATE users SET enabled = {false_value} WHERE enabled IS NULL" # nosec B608 + ) batch_op.alter_column("enabled", existing_type=sa.Boolean(), nullable=False) diff --git a/backend/alembic/versions/0024_sibling_roms_db_view.py b/backend/alembic/versions/0024_sibling_roms_db_view.py index f6c438936..413db2db1 100644 --- a/backend/alembic/versions/0024_sibling_roms_db_view.py +++ b/backend/alembic/versions/0024_sibling_roms_db_view.py @@ -8,6 +8,7 @@ import sqlalchemy as sa from alembic import op +from utils.database import is_postgresql # revision identifiers, used by Alembic. revision = "0024_sibling_roms_db_view" @@ -22,10 +23,13 @@ def upgrade() -> None: batch_op.create_index("idx_roms_moby_id", ["moby_id"]) connection = op.get_bind() + null_safe_equal_operator = ( + "IS NOT DISTINCT FROM" if is_postgresql(connection) else "<=>" + ) connection.execute( sa.text( - """ + f""" CREATE VIEW sibling_roms AS SELECT r1.id AS rom_id, @@ -33,21 +37,21 @@ def upgrade() -> None: r1.platform_id AS platform_id, NOW() AS created_at, NOW() AS updated_at, - CASE WHEN r1.igdb_id <=> r2.igdb_id THEN r1.igdb_id END AS igdb_id, - CASE WHEN r1.moby_id <=> r2.moby_id THEN r1.moby_id END AS moby_id + CASE WHEN r1.igdb_id {null_safe_equal_operator} r2.igdb_id THEN r1.igdb_id END AS igdb_id, + CASE WHEN r1.moby_id {null_safe_equal_operator} r2.moby_id THEN r1.moby_id END AS moby_id FROM roms r1 - JOIN - roms r2 - ON + JOIN + roms r2 + ON r1.platform_id = r2.platform_id AND r1.id != r2.id AND ( - (r1.igdb_id = r2.igdb_id AND r1.igdb_id IS NOT NULL AND r1.igdb_id != '') + (r1.igdb_id = r2.igdb_id AND r1.igdb_id IS NOT NULL) OR - (r1.moby_id = r2.moby_id AND r1.moby_id IS NOT NULL AND r1.moby_id != '') + (r1.moby_id = r2.moby_id AND r1.moby_id IS NOT NULL) ); - """ + """ # nosec B608 ), ) diff --git a/backend/alembic/versions/0026_romuser_status_fields.py b/backend/alembic/versions/0026_romuser_status_fields.py index 42c6ed481..e3e5f7f7b 100644 --- a/backend/alembic/versions/0026_romuser_status_fields.py +++ b/backend/alembic/versions/0026_romuser_status_fields.py @@ -8,7 +8,8 @@ import sqlalchemy as sa from alembic import op -from sqlalchemy.dialects import mysql +from sqlalchemy.dialects.postgresql import ENUM +from utils.database import is_postgresql # revision identifiers, used by Alembic. revision = "0026_romuser_status_fields" @@ -18,20 +19,42 @@ def upgrade() -> None: + connection = op.get_bind() with op.batch_alter_table("collections", schema=None) as batch_op: batch_op.alter_column( "path_cover_l", - existing_type=mysql.VARCHAR(length=1000), + existing_type=sa.VARCHAR(length=1000), type_=sa.Text(), existing_nullable=True, ) batch_op.alter_column( "path_cover_s", - existing_type=mysql.VARCHAR(length=1000), + existing_type=sa.VARCHAR(length=1000), type_=sa.Text(), existing_nullable=True, ) + if is_postgresql(connection): + rom_user_status_enum = ENUM( + "INCOMPLETE", + "FINISHED", + "COMPLETED_100", + "RETIRED", + "NEVER_PLAYING", + name="romuserstatus", + create_type=False, + ) + rom_user_status_enum.create(connection, checkfirst=False) + else: + rom_user_status_enum = sa.Enum( + "INCOMPLETE", + "FINISHED", + "COMPLETED_100", + "RETIRED", + "NEVER_PLAYING", + name="romuserstatus", + ) + with op.batch_alter_table("rom_user", schema=None) as batch_op: batch_op.add_column( sa.Column("last_played", sa.DateTime(timezone=True), nullable=True) @@ -42,23 +65,12 @@ def upgrade() -> None: batch_op.add_column(sa.Column("rating", sa.Integer(), nullable=False)) batch_op.add_column(sa.Column("difficulty", sa.Integer(), nullable=False)) batch_op.add_column(sa.Column("completion", sa.Integer(), nullable=False)) - batch_op.add_column( - sa.Column( - "status", - sa.Enum( - "INCOMPLETE", - "FINISHED", - "COMPLETED_100", - "RETIRED", - "NEVER_PLAYING", - name="romuserstatus", - ), - nullable=True, - ) - ) + batch_op.add_column(sa.Column("status", rom_user_status_enum, nullable=True)) def downgrade() -> None: + connection = op.get_bind() + with op.batch_alter_table("rom_user", schema=None) as batch_op: batch_op.drop_column("status") batch_op.drop_column("completion") @@ -69,16 +81,19 @@ def downgrade() -> None: batch_op.drop_column("backlogged") batch_op.drop_column("last_played") + if is_postgresql(connection): + ENUM(name="romuserstatus").drop(connection, checkfirst=False) + with op.batch_alter_table("collections", schema=None) as batch_op: batch_op.alter_column( "path_cover_s", existing_type=sa.Text(), - type_=mysql.VARCHAR(length=1000), + type_=sa.VARCHAR(length=1000), existing_nullable=True, ) batch_op.alter_column( "path_cover_l", existing_type=sa.Text(), - type_=mysql.VARCHAR(length=1000), + type_=sa.VARCHAR(length=1000), existing_nullable=True, ) diff --git a/backend/alembic/versions/0027_platforms_data.py b/backend/alembic/versions/0027_platforms_data.py new file mode 100644 index 000000000..2254d6cf5 --- /dev/null +++ b/backend/alembic/versions/0027_platforms_data.py @@ -0,0 +1,56 @@ +"""platforms_data + +Revision ID: 0027_platforms_data +Revises: 0026_romuser_status_fields +Create Date: 2024-11-17 23:05:31.038917 + +""" + +import sqlalchemy as sa +from alembic import op +from models.platform import DEFAULT_COVER_ASPECT_RATIO + +# revision identifiers, used by Alembic. +revision = "0027_platforms_data" +down_revision = "0026_romuser_status_fields" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("platforms", schema=None) as batch_op: + batch_op.add_column(sa.Column("category", sa.String(length=50), nullable=True)) + batch_op.add_column(sa.Column("generation", sa.Integer(), nullable=True)) + batch_op.add_column( + sa.Column("family_name", sa.String(length=1000), nullable=True) + ) + batch_op.add_column( + sa.Column("family_slug", sa.String(length=1000), nullable=True) + ) + batch_op.add_column(sa.Column("url", sa.String(length=1000), nullable=True)) + batch_op.add_column( + sa.Column("url_logo", sa.String(length=1000), nullable=True) + ) + batch_op.add_column( + sa.Column( + "aspect_ratio", + sa.String(length=10), + nullable=False, + server_default=DEFAULT_COVER_ASPECT_RATIO, + ) + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("platforms", schema=None) as batch_op: + batch_op.drop_column("url_logo") + batch_op.drop_column("url") + batch_op.drop_column("family_name") + batch_op.drop_column("family_slug") + batch_op.drop_column("generation") + batch_op.drop_column("category") + batch_op.drop_column("aspect_ratio") + # ### end Alembic commands ### diff --git a/backend/alembic/versions/0028_user_email.py b/backend/alembic/versions/0028_user_email.py new file mode 100644 index 000000000..935f5411c --- /dev/null +++ b/backend/alembic/versions/0028_user_email.py @@ -0,0 +1,28 @@ +"""empty message + +Revision ID: 0028_user_email +Revises: 0027_platforms_data +Create Date: 2024-12-09 19:26:34.257411 + +""" + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "0028_user_email" +down_revision = "0027_platforms_data" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + with op.batch_alter_table("users", schema=None) as batch_op: + batch_op.add_column(sa.Column("email", sa.String(length=255), nullable=True)) + batch_op.create_index(batch_op.f("ix_users_email"), ["email"], unique=True) + + +def downgrade() -> None: + with op.batch_alter_table("users", schema=None) as batch_op: + batch_op.drop_index(batch_op.f("ix_users_email")) + batch_op.drop_column("email") diff --git a/backend/alembic/versions/0029_platforms_custom_name.py b/backend/alembic/versions/0029_platforms_custom_name.py new file mode 100644 index 000000000..f48199d4d --- /dev/null +++ b/backend/alembic/versions/0029_platforms_custom_name.py @@ -0,0 +1,32 @@ +"""platforms_custom_name + +Revision ID: fc3783d35bdb +Revises: 0028_user_email +Create Date: 2024-12-24 01:32:57.121432 + +""" + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "0029_platforms_custom_name" +down_revision = "0028_user_email" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("platforms", schema=None) as batch_op: + batch_op.add_column( + sa.Column("custom_name", sa.String(length=400), nullable=True) + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("platforms", schema=None) as batch_op: + batch_op.drop_column("custom_name") + # ### end Alembic commands ### diff --git a/backend/alembic/versions/1.6.2_.py b/backend/alembic/versions/1.6.2_.py index 616f30e02..b34cabcd1 100644 --- a/backend/alembic/versions/1.6.2_.py +++ b/backend/alembic/versions/1.6.2_.py @@ -9,6 +9,7 @@ import sqlalchemy as sa from alembic import op from sqlalchemy.exc import OperationalError +from utils.database import CustomJSON # revision identifiers, used by Alembic. revision = "1.6.2" @@ -50,7 +51,7 @@ def upgrade() -> None: sa.Column("has_cover", sa.Boolean(), nullable=True), sa.Column("region", sa.String(length=20), nullable=True), sa.Column("revision", sa.String(length=20), nullable=True), - sa.Column("tags", sa.JSON(), nullable=True), + sa.Column("tags", CustomJSON(), nullable=True), sa.PrimaryKeyConstraint("p_slug", "file_name"), ) except OperationalError: diff --git a/backend/alembic/versions/1.6.3_.py b/backend/alembic/versions/1.6.3_.py index d15caa332..78ea68474 100644 --- a/backend/alembic/versions/1.6.3_.py +++ b/backend/alembic/versions/1.6.3_.py @@ -8,6 +8,7 @@ import sqlalchemy as sa from alembic import op +from utils.database import CustomJSON # revision identifiers, used by Alembic. revision = "1.6.3" @@ -20,7 +21,7 @@ def upgrade() -> None: # ### commands auto generated by Alembic - please adjust! ### with op.batch_alter_table("roms") as batch_op: batch_op.add_column(sa.Column("multi", sa.Boolean(), nullable=True)) - batch_op.add_column(sa.Column("files", sa.JSON(), nullable=True)) + batch_op.add_column(sa.Column("files", CustomJSON(), nullable=True)) # ### end Alembic commands ### diff --git a/backend/alembic/versions/1.8.1_.py b/backend/alembic/versions/1.8.1_.py index 374d1a482..1ea4b759b 100644 --- a/backend/alembic/versions/1.8.1_.py +++ b/backend/alembic/versions/1.8.1_.py @@ -8,6 +8,7 @@ import sqlalchemy as sa from alembic import op +from utils.database import CustomJSON # revision identifiers, used by Alembic. revision = "1.8.1" @@ -20,11 +21,19 @@ def upgrade() -> None: # ### commands auto generated by Alembic - please adjust! ### with op.batch_alter_table("roms") as batch_op: batch_op.add_column( - sa.Column("url_screenshots", sa.JSON(), nullable=False, server_default="[]") + sa.Column( + "url_screenshots", + CustomJSON(), + nullable=False, + server_default=sa.text("(JSON_ARRAY())"), + ) ) batch_op.add_column( sa.Column( - "path_screenshots", sa.JSON(), nullable=False, server_default="[]" + "path_screenshots", + CustomJSON(), + nullable=False, + server_default=sa.text("(JSON_ARRAY())"), ) ) # ### end Alembic commands ### diff --git a/backend/alembic/versions/1.8.3_.py b/backend/alembic/versions/1.8.3_.py index 987d295cb..f2a17fc26 100644 --- a/backend/alembic/versions/1.8.3_.py +++ b/backend/alembic/versions/1.8.3_.py @@ -7,6 +7,7 @@ """ from alembic import op +from utils.database import is_postgresql # revision identifiers, used by Alembic. revision = "1.8.3" @@ -16,7 +17,7 @@ def upgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### + connection = op.get_bind() with op.batch_alter_table("roms") as batch_op: batch_op.execute( "UPDATE roms SET file_path = REPLACE(file_path, '/romm/library/', '')" @@ -27,10 +28,14 @@ def upgrade() -> None: batch_op.execute( "UPDATE roms SET path_cover_l = REPLACE(path_cover_l, '/romm/resources/', '')" ) - batch_op.execute( - "UPDATE roms SET path_screenshots = REPLACE(path_screenshots, '/romm/resources/', '')" - ) - # ### end Alembic commands ### + if is_postgresql(connection): + batch_op.execute( + "UPDATE roms SET path_screenshots = REPLACE(path_screenshots::text, '/romm/resources/', '')::jsonb" + ) + else: + batch_op.execute( + "UPDATE roms SET path_screenshots = REPLACE(path_screenshots, '/romm/resources/', '')" + ) def downgrade() -> None: diff --git a/backend/alembic/versions/1.8_.py b/backend/alembic/versions/1.8_.py index d4f8919a9..3c5b6d7d7 100644 --- a/backend/alembic/versions/1.8_.py +++ b/backend/alembic/versions/1.8_.py @@ -8,6 +8,7 @@ import sqlalchemy as sa from alembic import op +from utils.database import CustomJSON # revision identifiers, used by Alembic. revision = "1.8" @@ -78,9 +79,9 @@ def upgrade() -> None: sa.Column("has_cover", sa.Boolean(), nullable=True), sa.Column("region", sa.String(length=20), nullable=True), sa.Column("revision", sa.String(length=20), nullable=True), - sa.Column("tags", sa.JSON(), nullable=True), + sa.Column("tags", CustomJSON(), nullable=True), sa.Column("multi", sa.Boolean(), nullable=True), - sa.Column("files", sa.JSON(), nullable=True), + sa.Column("files", CustomJSON(), nullable=True), sa.Column("url_cover", sa.Text(), nullable=True), sa.PrimaryKeyConstraint("id"), ) diff --git a/backend/alembic/versions/2.0.0_.py b/backend/alembic/versions/2.0.0_.py index ef83ae4a0..709edec42 100644 --- a/backend/alembic/versions/2.0.0_.py +++ b/backend/alembic/versions/2.0.0_.py @@ -8,6 +8,8 @@ import sqlalchemy as sa from alembic import op +from sqlalchemy.dialects.postgresql import ENUM +from utils.database import is_postgresql # revision identifiers, used by Alembic. revision = "2.0.0" @@ -39,9 +41,12 @@ def upgrade() -> None: def downgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### + connection = op.get_bind() + with op.batch_alter_table("users", schema=None) as batch_op: batch_op.drop_index(batch_op.f("ix_users_username")) op.drop_table("users") - # ### end Alembic commands ### + + if is_postgresql(connection): + ENUM(name="role").drop(connection, checkfirst=False) diff --git a/backend/config/__init__.py b/backend/config/__init__.py index e4221f5ee..7a5860b2d 100644 --- a/backend/config/__init__.py +++ b/backend/config/__init__.py @@ -25,12 +25,13 @@ def str_to_bool(value: str) -> bool: ASSETS_BASE_PATH: Final = f"{ROMM_BASE_PATH}/assets" FRONTEND_RESOURCES_PATH: Final = "/assets/romm/resources" -# MARIADB +# DATABASE DB_HOST: Final = os.environ.get("DB_HOST", "127.0.0.1") DB_PORT: Final = int(os.environ.get("DB_PORT", 3306)) DB_USER: Final = os.environ.get("DB_USER") DB_PASSWD: Final = os.environ.get("DB_PASSWD") DB_NAME: Final = os.environ.get("DB_NAME", "romm") +ROMM_DB_DRIVER: Final = os.environ.get("ROMM_DB_DRIVER", "mariadb") # REDIS REDIS_HOST: Final = os.environ.get("REDIS_HOST", "127.0.0.1") @@ -62,9 +63,6 @@ def str_to_bool(value: str) -> bool: # MOBYGAMES MOBYGAMES_API_KEY: Final = os.environ.get("MOBYGAMES_API_KEY", "") -# DB DRIVERS -ROMM_DB_DRIVER: Final = os.environ.get("ROMM_DB_DRIVER", "mariadb") - # AUTH ROMM_AUTH_SECRET_KEY: Final = os.environ.get( "ROMM_AUTH_SECRET_KEY", secrets.token_hex(32) @@ -76,6 +74,14 @@ def str_to_bool(value: str) -> bool: os.environ.get("DISABLE_DOWNLOAD_ENDPOINT_AUTH", "false") ) +# OIDC +OIDC_ENABLED: Final = str_to_bool(os.environ.get("OIDC_ENABLED", "false")) +OIDC_PROVIDER: Final = os.environ.get("OIDC_PROVIDER", "") +OIDC_CLIENT_ID: Final = os.environ.get("OIDC_CLIENT_ID", "") +OIDC_CLIENT_SECRET: Final = os.environ.get("OIDC_CLIENT_SECRET", "") +OIDC_REDIRECT_URI: Final = os.environ.get("OIDC_REDIRECT_URI", "") +OIDC_SERVER_APPLICATION_URL: Final = os.environ.get("OIDC_SERVER_APPLICATION_URL", "") + # SCANS SCAN_TIMEOUT: Final = int(os.environ.get("SCAN_TIMEOUT", 60 * 60 * 4)) # 4 hours @@ -110,6 +116,11 @@ def str_to_bool(value: str) -> bool: # LOGGING LOGLEVEL: Final = os.environ.get("LOGLEVEL", "INFO") +FORCE_COLOR: Final = str_to_bool(os.environ.get("FORCE_COLOR", "false")) +NO_COLOR: Final = str_to_bool(os.environ.get("NO_COLOR", "false")) + +# SENTRY +SENTRY_DSN: Final = os.environ.get("SENTRY_DSN", None) # TESTING IS_PYTEST_RUN: Final = bool(os.environ.get("PYTEST_VERSION", False)) diff --git a/backend/config/config_manager.py b/backend/config/config_manager.py index 428400e62..1ba06d1f3 100644 --- a/backend/config/config_manager.py +++ b/backend/config/config_manager.py @@ -83,30 +83,36 @@ def get_db_engine() -> URL: str: database connection string """ - if ROMM_DB_DRIVER == "mariadb": - if not DB_USER or not DB_PASSWD: - log.critical( - "Missing database credentials, check your environment variables!" - ) - sys.exit(3) - - return URL.create( - drivername="mariadb+mariadbconnector", - username=DB_USER, - password=DB_PASSWD, - host=DB_HOST, - port=DB_PORT, - database=DB_NAME, - ) - # DEPRECATED if ROMM_DB_DRIVER == "sqlite": log.critical("Sqlite is not supported anymore, migrate to mariaDB") sys.exit(6) # DEPRECATED - log.critical(f"{ROMM_DB_DRIVER} database not supported") - sys.exit(3) + if ROMM_DB_DRIVER == "mariadb": + driver = "mariadb+mariadbconnector" + elif ROMM_DB_DRIVER == "mysql": + driver = "mysql+mysqlconnector" + elif ROMM_DB_DRIVER == "postgresql": + driver = "postgresql+psycopg" + else: + log.critical(f"{ROMM_DB_DRIVER} database not supported") + sys.exit(3) + + if not DB_USER or not DB_PASSWD: + log.critical( + "Missing database credentials, check your environment variables!" + ) + sys.exit(3) + + return URL.create( + drivername=driver, + username=DB_USER, + password=DB_PASSWD, + host=DB_HOST, + port=DB_PORT, + database=DB_NAME, + ) def _parse_config(self): """Parses each entry in the config.yml""" diff --git a/backend/decorators/auth.py b/backend/decorators/auth.py index 9c03e8fcc..e0013991d 100644 --- a/backend/decorators/auth.py +++ b/backend/decorators/auth.py @@ -1,5 +1,14 @@ from typing import Any +from authlib.integrations.starlette_client import OAuth +from config import ( + OIDC_CLIENT_ID, + OIDC_CLIENT_SECRET, + OIDC_ENABLED, + OIDC_PROVIDER, + OIDC_REDIRECT_URI, + OIDC_SERVER_APPLICATION_URL, +) from fastapi import Security from fastapi.security.http import HTTPBasic from fastapi.security.oauth2 import OAuth2PasswordBearer @@ -11,7 +20,9 @@ Scope, ) from starlette.authentication import requires +from starlette.config import Config +# Using the internal password flow oauth2_password_bearer = OAuth2PasswordBearer( tokenUrl="/token", auto_error=False, @@ -22,6 +33,26 @@ }, ) +# Using an OIDC authorization code flow +config = Config( + environ={ + "OIDC_ENABLED": str(OIDC_ENABLED), + "OIDC_PROVIDER": OIDC_PROVIDER, + "OIDC_CLIENT_ID": OIDC_CLIENT_ID, + "OIDC_CLIENT_SECRET": OIDC_CLIENT_SECRET, + "OIDC_REDIRECT_URI": OIDC_REDIRECT_URI, + "OIDC_SERVER_APPLICATION_URL": OIDC_SERVER_APPLICATION_URL, + } +) +oauth = OAuth(config=config) +oauth.register( + name="openid", + client_id=config.get("OIDC_CLIENT_ID"), + client_secret=config.get("OIDC_CLIENT_SECRET"), + server_metadata_url=f'{config.get("OIDC_SERVER_APPLICATION_URL")}/.well-known/openid-configuration', + client_kwargs={"scope": "openid profile email"}, +) + def protected_route( method: Any, @@ -29,7 +60,7 @@ def protected_route( scopes: list[Scope] | None = None, **kwargs, ): - def decorator(func: DecoratedCallable): + def decorator(func: DecoratedCallable) -> DecoratedCallable: fn = requires(scopes or [])(func) return method( path, diff --git a/backend/endpoints/auth.py b/backend/endpoints/auth.py index f273d9753..acc71000f 100644 --- a/backend/endpoints/auth.py +++ b/backend/endpoints/auth.py @@ -1,13 +1,21 @@ from datetime import datetime, timedelta, timezone from typing import Annotated, Final +from config import OIDC_ENABLED, OIDC_REDIRECT_URI +from decorators.auth import oauth from endpoints.forms.identity import OAuth2RequestForm from endpoints.responses import MessageResponse from endpoints.responses.oauth import TokenResponse -from exceptions.auth_exceptions import AuthCredentialsException, DisabledException +from exceptions.auth_exceptions import ( + AuthCredentialsException, + OIDCDisabledException, + OIDCNotConfiguredException, + UserDisabledException, +) from fastapi import Depends, HTTPException, Request, status +from fastapi.responses import RedirectResponse from fastapi.security.http import HTTPBasic -from handler.auth import auth_handler, oauth_handler +from handler.auth import auth_handler, oauth_handler, oidc_handler from handler.database import db_user_handler from utils.router import APIRouter @@ -17,6 +25,58 @@ router = APIRouter() +# Session authentication endpoints +@router.post("/login") +def login( + request: Request, + credentials=Depends(HTTPBasic()), # noqa +) -> MessageResponse: + """Session login endpoint + + Args: + request (Request): Fastapi Request object + credentials: Defaults to Depends(HTTPBasic()). + + Raises: + CredentialsException: Invalid credentials + UserDisabledException: Auth is disabled + + Returns: + MessageResponse: Standard message response + """ + + user = auth_handler.authenticate_user(credentials.username, credentials.password) + if not user: + raise AuthCredentialsException + + if not user.enabled: + raise UserDisabledException + + request.session.update({"iss": "romm:auth", "sub": user.username}) + + # Update last login and active times + now = datetime.now(timezone.utc) + db_user_handler.update_user(user.id, {"last_login": now, "last_active": now}) + + return {"msg": "Successfully logged in"} + + +@router.post("/logout") +def logout(request: Request) -> MessageResponse: + """Session logout endpoint + + Args: + request (Request): Fastapi Request object + + Returns: + MessageResponse: Standard message response + """ + + request.session.clear() + + return {"msg": "Successfully logged out"} + + @router.post("/token") async def token(form_data: Annotated[OAuth2RequestForm, Depends()]) -> TokenResponse: """OAuth2 token endpoint @@ -45,9 +105,15 @@ async def token(form_data: Annotated[OAuth2RequestForm, Depends()]) -> TokenResp status_code=status.HTTP_400_BAD_REQUEST, detail="Missing refresh token" ) - user, claims = await oauth_handler.get_current_active_user_from_bearer_token( + potential_user = await oauth_handler.get_current_active_user_from_bearer_token( token ) + if not potential_user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid refresh token" + ) + + user, claims = potential_user if claims.get("type") != "refresh": raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid refresh token" @@ -133,51 +199,73 @@ async def token(form_data: Annotated[OAuth2RequestForm, Depends()]) -> TokenResp } -@router.post("/login") -def login( - request: Request, credentials=Depends(HTTPBasic()) # noqa -) -> MessageResponse: - """Session login endpoint +# OIDC login and callback endpoints +@router.get("/login/openid") +async def login_via_openid(request: Request): + """OIDC login endpoint Args: request (Request): Fastapi Request object - credentials: Defaults to Depends(HTTPBasic()). Raises: - CredentialsException: Invalid credentials - DisabledException: Auth is disabled + OIDCDisabledException: OAuth is disabled + OIDCNotConfiguredException: OAuth not configured Returns: - MessageResponse: Standard message response + RedirectResponse: Redirect to OIDC provider """ - user = auth_handler.authenticate_user(credentials.username, credentials.password) - if not user: - raise AuthCredentialsException + if not OIDC_ENABLED: + raise OIDCDisabledException - if not user.enabled: - raise DisabledException - - request.session.update({"iss": "romm:auth", "sub": user.username}) - - # Update last login and active times - now = datetime.now(timezone.utc) - db_user_handler.update_user(user.id, {"last_login": now, "last_active": now}) + if not oauth.openid: + raise OIDCNotConfiguredException - return {"msg": "Successfully logged in"} + return await oauth.openid.authorize_redirect(request, OIDC_REDIRECT_URI) -@router.post("/logout") -def logout(request: Request) -> MessageResponse: - """Session logout endpoint +@router.get("/oauth/openid") +async def auth_openid(request: Request): + """OIDC callback endpoint Args: request (Request): Fastapi Request object + Raises: + OIDCDisabledException: OAuth is disabled + OIDCNotConfiguredException: OAuth not configured + AuthCredentialsException: Invalid credentials + UserDisabledException: Auth is disabled + Returns: - MessageResponse: Standard message response + RedirectResponse: Redirect to home page """ - request.session.clear() + if not OIDC_ENABLED: + raise OIDCDisabledException - return {"msg": "Successfully logged out"} + if not oauth.openid: + raise OIDCNotConfiguredException + + token = await oauth.openid.authorize_access_token(request) + potential_user, _userinfo = ( + await oidc_handler.get_current_active_user_from_openid_token(token) + ) + if not potential_user: + raise AuthCredentialsException + + if not potential_user: + raise AuthCredentialsException + + if not potential_user.enabled: + raise UserDisabledException + + request.session.update({"iss": "romm:auth", "sub": potential_user.username}) + + # Update last login and active times + now = datetime.now(timezone.utc) + db_user_handler.update_user( + potential_user.id, {"last_login": now, "last_active": now} + ) + + return RedirectResponse(url="/") diff --git a/backend/endpoints/config.py b/backend/endpoints/configs.py similarity index 96% rename from backend/endpoints/config.py rename to backend/endpoints/configs.py index 1198fa6c5..2a06ab886 100644 --- a/backend/endpoints/config.py +++ b/backend/endpoints/configs.py @@ -33,9 +33,6 @@ def get_config() -> ConfigResponse: EXCLUDED_MULTI_PARTS_FILES=cfg.EXCLUDED_MULTI_PARTS_FILES, PLATFORMS_BINDING=cfg.PLATFORMS_BINDING, PLATFORMS_VERSIONS=cfg.PLATFORMS_VERSIONS, - ROMS_FOLDER_NAME=cfg.ROMS_FOLDER_NAME, - FIRMWARE_FOLDER_NAME=cfg.FIRMWARE_FOLDER_NAME, - HIGH_PRIO_STRUCTURE_PATH=cfg.HIGH_PRIO_STRUCTURE_PATH, ) except ConfigNotReadableException as exc: log.critical(exc.message) diff --git a/backend/endpoints/forms/identity.py b/backend/endpoints/forms/identity.py index 0ffeb5af4..479c76639 100644 --- a/backend/endpoints/forms/identity.py +++ b/backend/endpoints/forms/identity.py @@ -7,12 +7,14 @@ def __init__( self, username: str | None = None, password: str | None = None, + email: str | None = None, role: str | None = None, enabled: bool | None = None, avatar: UploadFile | None = None, ): self.username = username self.password = password + self.email = email self.role = role self.enabled = enabled self.avatar = avatar diff --git a/backend/endpoints/heartbeat.py b/backend/endpoints/heartbeat.py index fcd44c063..908b3a55e 100644 --- a/backend/endpoints/heartbeat.py +++ b/backend/endpoints/heartbeat.py @@ -4,6 +4,8 @@ ENABLE_RESCAN_ON_FILESYSTEM_CHANGE, ENABLE_SCHEDULED_RESCAN, ENABLE_SCHEDULED_UPDATE_SWITCH_TITLEDB, + OIDC_ENABLED, + OIDC_PROVIDER, RESCAN_ON_FILESYSTEM_CHANGE_DELAY, SCHEDULED_RESCAN_CRON, SCHEDULED_UPDATE_SWITCH_TITLEDB_CRON, @@ -30,15 +32,19 @@ def heartbeat() -> HeartbeatResponse: """ return { - "VERSION": get_version(), - "SHOW_SETUP_WIZARD": len(db_user_handler.get_admin_users()) == 0, - "ANY_SOURCE_ENABLED": IGDB_API_ENABLED or MOBY_API_ENABLED, + "SYSTEM": { + "VERSION": get_version(), + "SHOW_SETUP_WIZARD": len(db_user_handler.get_admin_users()) == 0, + }, "METADATA_SOURCES": { + "ANY_SOURCE_ENABLED": IGDB_API_ENABLED or MOBY_API_ENABLED, "IGDB_API_ENABLED": IGDB_API_ENABLED, "MOBY_API_ENABLED": MOBY_API_ENABLED, "STEAMGRIDDB_ENABLED": STEAMGRIDDB_API_ENABLED, }, - "FS_PLATFORMS": fs_platform_handler.get_platforms(), + "FILESYSTEM": { + "FS_PLATFORMS": fs_platform_handler.get_platforms(), + }, "WATCHER": { "ENABLED": ENABLE_RESCAN_ON_FILESYSTEM_CHANGE, "TITLE": "Rescan on filesystem change", @@ -63,4 +69,8 @@ def heartbeat() -> HeartbeatResponse: "DISABLE_RUFFLE_RS": DISABLE_RUFFLE_RS, }, "FRONTEND": {"UPLOAD_TIMEOUT": UPLOAD_TIMEOUT}, + "OIDC": { + "ENABLED": OIDC_ENABLED, + "PROVIDER": OIDC_PROVIDER, + }, } diff --git a/backend/endpoints/platform.py b/backend/endpoints/platform.py index 54d4388f3..db9f80ec2 100644 --- a/backend/endpoints/platform.py +++ b/backend/endpoints/platform.py @@ -112,17 +112,27 @@ def get_platform(request: Request, id: int) -> PlatformSchema: @protected_route(router.put, "/platforms/{id}", [Scope.PLATFORMS_WRITE]) -async def update_platform(request: Request) -> MessageResponse: +async def update_platform(request: Request, id: int) -> PlatformSchema: """Update platform endpoint Args: request (Request): Fastapi Request object + id (int): Platform id Returns: MessageResponse: Standard message response """ + data = await request.json() + platform_db = db_platform_handler.get_platform(id) + + if not platform_db: + raise PlatformNotFoundInDatabaseException(id) + + platform_db.aspect_ratio = data.get("aspect_ratio", platform_db.aspect_ratio) + platform_db.custom_name = data.get("custom_name", platform_db.custom_name) + platform_db = db_platform_handler.add_platform(platform_db) - return {"msg": "Enpoint not available yet"} + return platform_db @protected_route(router.delete, "/platforms/{id}", [Scope.PLATFORMS_WRITE]) diff --git a/backend/endpoints/responses/config.py b/backend/endpoints/responses/config.py index 4420a9354..0b537501c 100644 --- a/backend/endpoints/responses/config.py +++ b/backend/endpoints/responses/config.py @@ -10,6 +10,3 @@ class ConfigResponse(TypedDict): EXCLUDED_MULTI_PARTS_FILES: list[str] PLATFORMS_BINDING: dict[str, str] PLATFORMS_VERSIONS: dict[str, str] - ROMS_FOLDER_NAME: str - FIRMWARE_FOLDER_NAME: str - HIGH_PRIO_STRUCTURE_PATH: str diff --git a/backend/endpoints/responses/heartbeat.py b/backend/endpoints/responses/heartbeat.py index 101c42a81..3e9e37da6 100644 --- a/backend/endpoints/responses/heartbeat.py +++ b/backend/endpoints/responses/heartbeat.py @@ -1,6 +1,11 @@ from typing import TypedDict +class SystemDict(TypedDict): + VERSION: str + SHOW_SETUP_WIZARD: bool + + class WatcherDict(TypedDict): ENABLED: bool TITLE: str @@ -17,11 +22,16 @@ class SchedulerDict(TypedDict): class MetadataSourcesDict(TypedDict): + ANY_SOURCE_ENABLED: bool IGDB_API_ENABLED: bool MOBY_API_ENABLED: bool STEAMGRIDDB_ENABLED: bool +class FilesystemDict(TypedDict): + FS_PLATFORMS: list[str] + + class EmulationDict(TypedDict): DISABLE_EMULATOR_JS: bool DISABLE_RUFFLE_RS: bool @@ -31,13 +41,17 @@ class FrontendDict(TypedDict): UPLOAD_TIMEOUT: int +class OIDCDict(TypedDict): + ENABLED: bool + PROVIDER: str + + class HeartbeatResponse(TypedDict): - VERSION: str - SHOW_SETUP_WIZARD: bool + SYSTEM: SystemDict WATCHER: WatcherDict SCHEDULER: SchedulerDict - ANY_SOURCE_ENABLED: bool METADATA_SOURCES: MetadataSourcesDict - FS_PLATFORMS: list + FILESYSTEM: FilesystemDict EMULATION: EmulationDict FRONTEND: FrontendDict + OIDC: OIDCDict diff --git a/backend/endpoints/responses/identity.py b/backend/endpoints/responses/identity.py index 07005495d..ac43eb2be 100644 --- a/backend/endpoints/responses/identity.py +++ b/backend/endpoints/responses/identity.py @@ -7,6 +7,7 @@ class UserSchema(BaseModel): id: int username: str + email: str | None enabled: bool role: Role oauth_scopes: list[str] diff --git a/backend/endpoints/responses/platform.py b/backend/endpoints/responses/platform.py index 5d7126794..c19300312 100644 --- a/backend/endpoints/responses/platform.py +++ b/backend/endpoints/responses/platform.py @@ -1,6 +1,7 @@ from datetime import datetime -from pydantic import BaseModel, Field +from models.platform import DEFAULT_COVER_ASPECT_RATIO +from pydantic import BaseModel, Field, computed_field from .firmware import FirmwareSchema @@ -9,16 +10,28 @@ class PlatformSchema(BaseModel): id: int slug: str fs_slug: str - name: str rom_count: int + name: str + custom_name: str | None = None igdb_id: int | None = None sgdb_id: int | None = None moby_id: int | None = None - logo_path: str | None = "" + category: str | None = None + generation: int | None = None + family_name: str | None = None + family_slug: str | None = None + url: str | None = None + url_logo: str | None = None + logo_path: str | None = None firmware: list[FirmwareSchema] = Field(default_factory=list) - + aspect_ratio: str = DEFAULT_COVER_ASPECT_RATIO created_at: datetime updated_at: datetime class Config: from_attributes = True + + @computed_field # type: ignore + @property + def display_name(self) -> str: + return self.custom_name or self.name diff --git a/backend/endpoints/responses/rom.py b/backend/endpoints/responses/rom.py index e80ac99c0..d4108afd4 100644 --- a/backend/endpoints/responses/rom.py +++ b/backend/endpoints/responses/rom.py @@ -34,6 +34,7 @@ def rom_user_schema_factory() -> RomUserSchema: rom_id=-1, created_at=now, updated_at=now, + last_played=None, note_raw_markdown="", note_is_public=False, is_main_sibling=False, @@ -54,6 +55,7 @@ class RomUserSchema(BaseModel): rom_id: int created_at: datetime updated_at: datetime + last_played: datetime | None note_raw_markdown: str note_is_public: bool is_main_sibling: bool @@ -100,7 +102,10 @@ class RomSchema(BaseModel): platform_id: int platform_slug: str + platform_fs_slug: str platform_name: str + platform_custom_name: str | None + platform_display_name: str file_name: str file_name_no_tags: str diff --git a/backend/endpoints/responses/search.py b/backend/endpoints/responses/search.py index 0e3011ad9..7f1de633d 100644 --- a/backend/endpoints/responses/search.py +++ b/backend/endpoints/responses/search.py @@ -9,6 +9,7 @@ class SearchRomSchema(BaseModel): summary: str igdb_url_cover: str = "" moby_url_cover: str = "" + platform_id: int class SearchCoverSchema(BaseModel): diff --git a/backend/endpoints/rom.py b/backend/endpoints/rom.py index 0dd449e18..8c6e1b3f7 100644 --- a/backend/endpoints/rom.py +++ b/backend/endpoints/rom.py @@ -20,12 +20,14 @@ from fastapi import HTTPException, Query, Request, UploadFile, status from fastapi.responses import Response from handler.auth.base_handler import Scope -from handler.database import db_platform_handler, db_rom_handler +from handler.database import db_collection_handler, db_platform_handler, db_rom_handler from handler.filesystem import fs_resource_handler, fs_rom_handler from handler.filesystem.base_handler import CoverSize from handler.metadata import meta_igdb_handler, meta_moby_handler from logger.logger import log +from models.rom import Rom, RomUser from PIL import Image +from sqlalchemy import func from starlette.requests import ClientDisconnect from starlette.responses import FileResponse from streaming_form_data import StreamingFormDataParser @@ -115,21 +117,45 @@ def get_roms( Args: request (Request): Fastapi Request object - id (int, optional): Rom internal id + platform_id (int, optional): Platform ID to filter ROMs + collection_id (int, optional): Collection ID to filter ROMs + search_term (str, optional): Search term to filter ROMs + limit (int, optional): Limit the number of ROMs returned + offset (int, optional): Offset for pagination + order_by (str, optional): Field to order ROMs by + order_dir (str, optional): Direction to order ROMs (asc or desc) + last_played (bool, optional): Flag to filter ROMs by last played Returns: - list[SimpleRomSchema]: List of roms stored in the database + list[DetailedRomSchema]: List of ROMs stored in the database """ - roms = db_rom_handler.get_roms( - platform_id=platform_id, - collection_id=collection_id, - search_term=search_term.lower(), - order_by=order_by.lower(), - order_dir=order_dir.lower(), - limit=limit, - offset=offset, - ) + if hasattr(Rom, order_by): + roms = db_rom_handler.get_roms( + platform_id=platform_id, + collection_id=collection_id, + search_term=search_term.lower(), + order_by=order_by.lower(), + order_dir=order_dir.lower(), + limit=limit, + offset=offset, + ) + elif hasattr(RomUser, order_by): + roms = db_rom_handler.get_roms_user( + user_id=request.user.id, + platform_id=platform_id, + collection_id=collection_id, + search_term=search_term, + order_by=order_by, + order_dir=order_dir, + limit=limit, + offset=offset, + ) + else: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid order_by field", + ) roms = [SimpleRomSchema.from_orm_with_request(rom, request) for rom in roms] return [rom for rom in roms if rom] @@ -245,6 +271,7 @@ async def get_rom_content( ZipResponse: Returns a response for nginx to serve a Zip file for multi-part roms """ + current_username = request.user.username if request.user else "unknown" rom = db_rom_handler.get_rom(id) if not rom: @@ -253,6 +280,8 @@ async def get_rom_content( rom_path = f"{LIBRARY_BASE_PATH}/{rom.full_path}" files_to_download = sorted(files or [r["filename"] for r in rom.files]) + log.info(f"User {current_username} is downloading {rom.file_name}") + if not rom.multi: return FileRedirectResponse( download_path=Path(f"/library/{rom.full_path}"), @@ -497,6 +526,14 @@ async def delete_roms( log.info(f"Deleting {rom.file_name} from database") db_rom_handler.delete_rom(id) + # Update collections to remove the deleted rom + collections = db_collection_handler.get_collections_by_rom_id(id) + for collection in collections: + collection.roms = [rom_id for rom_id in collection.roms if rom_id != id] + db_collection_handler.update_collection( + collection.id, {"roms": collection.roms} + ) + try: rmtree(f"{RESOURCES_BASE_PATH}/{rom.fs_resources_path}") except FileNotFoundError: @@ -531,36 +568,22 @@ async def update_rom_user(request: Request, id: int) -> RomUserSchema: id, request.user.id ) or db_rom_handler.add_rom_user(id, request.user.id) - cleaned_data = {} - - if "note_raw_markdown" in data: - cleaned_data.update({"note_raw_markdown": data.get("note_raw_markdown")}) - - if "note_is_public" in data: - cleaned_data.update({"note_is_public": data.get("note_is_public")}) - - if "is_main_sibling" in data: - cleaned_data.update({"is_main_sibling": data.get("is_main_sibling")}) - - if "backlogged" in data: - cleaned_data.update({"backlogged": data.get("backlogged")}) - - if "now_playing" in data: - cleaned_data.update({"now_playing": data.get("now_playing")}) - - if "hidden" in data: - cleaned_data.update({"hidden": data.get("hidden")}) - - if "rating" in data: - cleaned_data.update({"rating": data.get("rating")}) - - if "difficulty" in data: - cleaned_data.update({"difficulty": data.get("difficulty")}) + fields_to_update = [ + "note_raw_markdown", + "note_is_public", + "is_main_sibling", + "backlogged", + "now_playing", + "hidden", + "rating", + "difficulty", + "completion", + "status", + ] - if "completion" in data: - cleaned_data.update({"completion": data.get("completion")}) + cleaned_data = {field: data[field] for field in fields_to_update if field in data} - if "status" in data: - cleaned_data.update({"status": data.get("status")}) + if data.get("update_last_played", False): + cleaned_data.update({"last_played": func.now()}) return db_rom_handler.update_rom_user(db_rom_user.id, cleaned_data) diff --git a/backend/endpoints/search.py b/backend/endpoints/search.py index e6b66b6e7..ef95accf4 100644 --- a/backend/endpoints/search.py +++ b/backend/endpoints/search.py @@ -18,7 +18,7 @@ @protected_route(router.get, "/search/roms", [Scope.ROMS_READ]) async def search_rom( request: Request, - rom_id: str, + rom_id: int, search_term: str | None = None, search_by: str = "name", ) -> list[SearchRomSchema]: @@ -26,7 +26,7 @@ async def search_rom( Args: request (Request): FastAPI request - rom_id (str): Rom ID + rom_id (int): Rom ID source (str): Source of the rom search_term (str, optional): Search term. Defaults to None. search_by (str, optional): Search by name or ID. Defaults to "name". @@ -58,20 +58,23 @@ async def search_rom( log.info(f"Searching by {search_by.lower()}: {search_term}") log.info(emoji.emojize(f":video_game: {rom.platform_slug}: {rom.file_name}")) + + igdb_matched_roms = [] + moby_matched_roms = [] + if search_by.lower() == "id": try: - igdb_matched_roms = await meta_igdb_handler.get_matched_roms_by_id( - int(search_term) - ) - moby_matched_roms = await meta_moby_handler.get_matched_roms_by_id( - int(search_term) - ) + igdb_rom = await meta_igdb_handler.get_matched_rom_by_id(int(search_term)) + moby_rom = await meta_moby_handler.get_matched_rom_by_id(int(search_term)) except ValueError as exc: log.error(f"Search error: invalid ID '{search_term}'") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Tried searching by ID, but '{search_term}' is not a valid ID", ) from exc + else: + igdb_matched_roms = [igdb_rom] if igdb_rom else [] + moby_matched_roms = [moby_rom] if moby_rom else [] elif search_by.lower() == "name": igdb_matched_roms = await meta_igdb_handler.get_matched_roms_by_name( search_term, (await _get_main_platform_igdb_id(rom.platform)) @@ -99,6 +102,7 @@ async def search_rom( "summary": "", "igdb_url_cover": "", "moby_url_cover": "", + "platform_id": rom.platform_id, }, **item, } diff --git a/backend/endpoints/sockets/scan.py b/backend/endpoints/sockets/scan.py index 16a9c469d..cd83a316a 100644 --- a/backend/endpoints/sockets/scan.py +++ b/backend/endpoints/sockets/scan.py @@ -1,6 +1,7 @@ from __future__ import annotations from dataclasses import dataclass +from itertools import batched from typing import Any, Final import emoji @@ -69,7 +70,7 @@ def _get_socket_manager() -> socketio.AsyncRedisManager: return socketio.AsyncRedisManager(str(REDIS_URL), write_only=True) -def _should_scan_rom(scan_type: ScanType, rom: Rom, roms_ids: list): +def _should_scan_rom(scan_type: ScanType, rom: Rom | None, roms_ids: list[str]) -> bool: """Decide if a rom should be scanned or not Args: @@ -79,7 +80,7 @@ def _should_scan_rom(scan_type: ScanType, rom: Rom, roms_ids: list): """ # This logic is tricky so only touch it if you know what you're doing""" - return ( + return bool( (scan_type in {ScanType.NEW_PLATFORMS, ScanType.QUICK} and not rom) or (scan_type == ScanType.COMPLETE) or (scan_type == ScanType.HASHES) @@ -265,16 +266,23 @@ async def _identify_platform( else: log.info(f" {len(fs_roms)} roms found in the file system") - for fs_rom in fs_roms: - scan_stats += await _identify_rom( - platform=platform, - fs_rom=fs_rom, - scan_type=scan_type, - roms_ids=roms_ids, - metadata_sources=metadata_sources, - socket_manager=socket_manager, + for fs_roms_batch in batched(fs_roms, 200): + rom_by_filename_map = db_rom_handler.get_roms_by_filename( + platform_id=platform.id, + file_names={fs_rom["file_name"] for fs_rom in fs_roms_batch}, ) + for fs_rom in fs_roms_batch: + scan_stats += await _identify_rom( + platform=platform, + fs_rom=fs_rom, + rom=rom_by_filename_map.get(fs_rom["file_name"]), + scan_type=scan_type, + roms_ids=roms_ids, + metadata_sources=metadata_sources, + socket_manager=socket_manager, + ) + # Only purge entries if there are some file remaining in the library # This protects against accidental deletion of entries when # the folder structure is not correct or the drive is not mounted @@ -328,6 +336,7 @@ async def _identify_firmware( async def _identify_rom( platform: Platform, fs_rom: FSRom, + rom: Rom | None, scan_type: ScanType, roms_ids: list[str], metadata_sources: list[str], @@ -339,14 +348,17 @@ async def _identify_rom( if redis_client.get(STOP_SCAN_FLAG): return scan_stats - rom = db_rom_handler.get_rom_by_filename(platform.id, fs_rom["file_name"]) - if not _should_scan_rom(scan_type=scan_type, rom=rom, roms_ids=roms_ids): - # Just to update the filesystem data - rom.file_name = fs_rom["file_name"] - rom.multi = fs_rom["multi"] - rom.files = fs_rom["files"] - db_rom_handler.add_rom(rom) + if rom and ( + rom.file_name != fs_rom["file_name"] + or rom.multi != fs_rom["multi"] + or rom.files != fs_rom["files"] + ): + # Just to update the filesystem data + rom.file_name = fs_rom["file_name"] + rom.multi = fs_rom["multi"] + rom.files = fs_rom["files"] + db_rom_handler.add_rom(rom) return scan_stats diff --git a/backend/endpoints/tests/test_config.py b/backend/endpoints/tests/test_config.py index e256089fd..d8ff367c9 100644 --- a/backend/endpoints/tests/test_config.py +++ b/backend/endpoints/tests/test_config.py @@ -21,5 +21,3 @@ def test_config(client): assert config.get("EXCLUDED_MULTI_PARTS_EXT") == [] assert config.get("EXCLUDED_MULTI_PARTS_FILES") == [] assert config.get("PLATFORMS_BINDING") == {} - assert config.get("ROMS_FOLDER_NAME") == "roms" - assert config.get("FIRMWARE_FOLDER_NAME") == "bios" diff --git a/backend/endpoints/tests/test_heartbeat.py b/backend/endpoints/tests/test_heartbeat.py index 25f756bf7..d8ac46449 100644 --- a/backend/endpoints/tests/test_heartbeat.py +++ b/backend/endpoints/tests/test_heartbeat.py @@ -15,7 +15,7 @@ def test_heartbeat(client): assert response.status_code == 200 heartbeat = response.json() - assert heartbeat.get("VERSION") == get_version() + assert heartbeat.get("SYSTEM").get("VERSION") == get_version() assert heartbeat.get("WATCHER").get("ENABLED") assert heartbeat.get("WATCHER").get("TITLE") == "Rescan on filesystem change" assert heartbeat.get("SCHEDULER").get("RESCAN").get("ENABLED") diff --git a/backend/endpoints/tests/test_identity.py b/backend/endpoints/tests/test_identity.py index e66043d34..39985ac41 100644 --- a/backend/endpoints/tests/test_identity.py +++ b/backend/endpoints/tests/test_identity.py @@ -74,6 +74,7 @@ def test_add_user_from_admin_user(client, access_token, new_user_role): params={ "username": "new_user", "password": "new_user_password", + "email": "new_user@example.com", "role": new_user_role.value, }, headers={"Authorization": f"Bearer {access_token}"}, @@ -124,6 +125,7 @@ def test_add_user_from_unauthorized_user( params={ "username": "new_user", "password": "new_user_password", + "email": "new_user@example.com", "role": Role.VIEWER.value, }, headers={"Authorization": f"Bearer {access_token}"}, @@ -137,6 +139,7 @@ def test_add_user_with_existing_username(client, access_token, admin_user): params={ "username": admin_user.username, "password": "new_user_password", + "email": "new_user@example.com", "role": Role.VIEWER.value, }, headers={"Authorization": f"Bearer {access_token}"}, diff --git a/backend/endpoints/user.py b/backend/endpoints/user.py index b5862cc13..98940147b 100644 --- a/backend/endpoints/user.py +++ b/backend/endpoints/user.py @@ -25,17 +25,20 @@ [], status_code=status.HTTP_201_CREATED, ) -def add_user(request: Request, username: str, password: str, role: str) -> UserSchema: +def add_user( + request: Request, username: str, password: str, email: str, role: str +) -> UserSchema: """Create user endpoint Args: request (Request): Fastapi Requests object username (str): User username password (str): User password + email (str): User email role (str): RomM Role object represented as string Returns: - UserSchema: Created user info + UserSchema: Newly created user """ # If there are admin users already, enforce the USERS_WRITE scope. @@ -48,9 +51,18 @@ def add_user(request: Request, username: str, password: str, role: str) -> UserS detail="Forbidden", ) - existing_user = db_user_handler.get_user_by_username(username) - if existing_user: - msg = f"Username {username} already exists" + existing_user_by_username = db_user_handler.get_user_by_username(username.lower()) + if existing_user_by_username: + msg = f"Username {username.lower()} already exists" + log.error(msg) + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=msg, + ) + + existing_user_by_email = db_user_handler.get_user_by_email(email.lower()) + if existing_user_by_email: + msg = f"Uesr with email {email.lower()} already exists" log.error(msg) raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, @@ -58,8 +70,9 @@ def add_user(request: Request, username: str, password: str, role: str) -> UserS ) user = User( - username=username, + username=username.lower(), hashed_password=auth_handler.get_password_hash(password), + email=email.lower(), role=Role[role.upper()], ) @@ -77,7 +90,7 @@ def get_users(request: Request) -> list[UserSchema]: list[UserSchema]: All users stored in the RomM's database """ - return db_user_handler.get_users() + return [u for u in db_user_handler.get_users()] @protected_route(router.get, "/users/me", [Scope.ME_READ]) @@ -112,7 +125,7 @@ def get_user(request: Request, id: int) -> UserSchema: return user -@protected_route(router.put, "/users/{id}", [Scope.USERS_WRITE]) +@protected_route(router.put, "/users/{id}", [Scope.ME_WRITE]) async def update_user( request: Request, id: int, form_data: Annotated[UserForm, Depends()] ) -> UserSchema: @@ -160,6 +173,18 @@ async def update_user( form_data.password ) + if form_data.email and form_data.email != db_user.email: + existing_user = db_user_handler.get_user_by_email(form_data.email.lower()) + if existing_user: + msg = f"User with email {form_data.email} already exists" + log.error(msg) + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=msg, + ) + + cleaned_data["email"] = form_data.email.lower() + # You can't change your own role if form_data.role and request.user.id != id: cleaned_data["role"] = Role[form_data.role.upper()] # type: ignore[assignment] diff --git a/backend/exceptions/auth_exceptions.py b/backend/exceptions/auth_exceptions.py index bdb41bb22..66db919bf 100644 --- a/backend/exceptions/auth_exceptions.py +++ b/backend/exceptions/auth_exceptions.py @@ -10,7 +10,7 @@ detail="Invalid authentication scheme", ) -DisabledException = HTTPException( +UserDisabledException = HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Disabled user", ) @@ -20,3 +20,13 @@ detail="Could not validate credentials", headers={"WWW-Authenticate": "Bearer"}, ) + +OIDCDisabledException = HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="OAuth disabled", +) + +OIDCNotConfiguredException = HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="OAuth not configured", +) diff --git a/backend/handler/auth/__init__.py b/backend/handler/auth/__init__.py index 6fb038ba7..9e87a7167 100644 --- a/backend/handler/auth/__init__.py +++ b/backend/handler/auth/__init__.py @@ -1,4 +1,5 @@ -from .base_handler import AuthHandler, OAuthHandler +from .base_handler import AuthHandler, OAuthHandler, OpenIDHandler auth_handler = AuthHandler() oauth_handler = OAuthHandler() +oidc_handler = OpenIDHandler() diff --git a/backend/handler/auth/base_handler.py b/backend/handler/auth/base_handler.py index 0ce5e0413..f753ff285 100644 --- a/backend/handler/auth/base_handler.py +++ b/backend/handler/auth/base_handler.py @@ -1,13 +1,15 @@ import enum +import uuid from datetime import datetime, timedelta, timezone -from typing import Final +from typing import Any, Final -from config import ROMM_AUTH_SECRET_KEY -from exceptions.auth_exceptions import OAuthCredentialsException +from config import OIDC_ENABLED, ROMM_AUTH_SECRET_KEY +from exceptions.auth_exceptions import OAuthCredentialsException, UserDisabledException from fastapi import HTTPException, status from joserfc import jwt from joserfc.errors import BadSignatureError from joserfc.jwk import OctKey +from logger.logger import log from passlib.context import CryptContext from starlette.requests import HTTPConnection @@ -101,21 +103,14 @@ async def get_current_active_user_from_session(self, conn: HTTPConnection): # Key exists therefore user is probably authenticated user = db_user_handler.get_user_by_username(username) - if user is None: + if user is None or not user.enabled: conn.session.clear() - - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="User not found", - ) - - if not user.enabled: - conn.session.clear() - - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail=f"Inactive user {user.username}", + log.error( + "User '%s' %s", + username, + "not found" if user is None else "not enabled", ) + return None return user @@ -145,7 +140,7 @@ async def get_current_active_user_from_bearer_token(self, token: str): issuer = payload.claims.get("iss") if not issuer or issuer != "romm:oauth": - return None + return None, None username = payload.claims.get("sub") if username is None: @@ -156,8 +151,57 @@ async def get_current_active_user_from_bearer_token(self, token: str): raise OAuthCredentialsException if not user.enabled: + raise UserDisabledException + + return user, payload.claims + + +class OpenIDHandler: + async def get_current_active_user_from_openid_token(self, token: Any): + from handler.database import db_user_handler + from models.user import Role, User + + if not OIDC_ENABLED: + return None, None + + userinfo = token.get("userinfo") + if userinfo is None: + log.error("Userinfo is missing from token.") raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, detail="Inactive user" + status_code=status.HTTP_400_BAD_REQUEST, + detail="Userinfo is missing from token.", ) - return user, payload.claims + email = userinfo.get("email") + if email is None: + log.error("Email is missing from token.") + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Email is missing from token.", + ) + if userinfo.get("email_verified", None) is not True: + log.error("Email is not verified.") + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Email is not verified.", + ) + + preferred_username = userinfo.get("preferred_username") + + user = db_user_handler.get_user_by_email(email) + if user is None: + log.info("User with email '%s' not found, creating new user", email) + user = User( + username=preferred_username, + hashed_password=str(uuid.uuid4()), + email=email, + enabled=True, + role=Role.VIEWER, + ) + db_user_handler.add_user(user) + + if not user.enabled: + raise UserDisabledException + + log.info("User successfully authenticated: %s", email) + return user, userinfo diff --git a/backend/handler/auth/middleware.py b/backend/handler/auth/middleware.py index 6a6db036b..112c1b09d 100644 --- a/backend/handler/auth/middleware.py +++ b/backend/handler/auth/middleware.py @@ -107,6 +107,7 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: jwt_claims = self._validate_jwt_payload(jwt_payload) scope["session"] = jwt_claims initial_session_was_empty = False + except BadSignatureError: scope["session"] = {} else: diff --git a/backend/handler/auth/tests/__init__.py b/backend/handler/auth/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/handler/auth/tests/test_oauth.py b/backend/handler/auth/tests/test_oauth.py index 89bbc2e32..4978aaab7 100644 --- a/backend/handler/auth/tests/test_oauth.py +++ b/backend/handler/auth/tests/test_oauth.py @@ -23,6 +23,8 @@ async def test_get_current_active_user_from_bearer_token(admin_user): }, ) user, claims = await oauth_handler.get_current_active_user_from_bearer_token(token) + if not user or not claims: + pytest.fail("User or claims not found") assert user.id == admin_user.id assert claims["sub"] == admin_user.username @@ -61,7 +63,7 @@ async def test_get_current_active_user_from_bearer_token_disabled_user(admin_use await oauth_handler.get_current_active_user_from_bearer_token(token) except HTTPException as e: assert e.status_code == 401 - assert e.detail == "Inactive user" + assert e.detail == "Disabled user" def test_protected_route(): diff --git a/backend/handler/auth/tests/test_oidc.py b/backend/handler/auth/tests/test_oidc.py new file mode 100644 index 000000000..b6d94d760 --- /dev/null +++ b/backend/handler/auth/tests/test_oidc.py @@ -0,0 +1,91 @@ +from unittest.mock import MagicMock + +import pytest +from fastapi import HTTPException +from handler.auth.base_handler import OpenIDHandler +from joserfc.jwt import Token + +# Mock constants +OIDC_SERVER_APPLICATION_URL = "http://mock-oidc-server" +OIDC_ENABLED = True + + +@pytest.fixture +def mock_oidc_disabled(mocker): + mocker.patch("handler.auth.base_handler.OIDC_ENABLED", False) + + +@pytest.fixture +def mock_oidc_enabled(mocker): + mocker.patch("handler.auth.base_handler.OIDC_ENABLED", True) + + +@pytest.fixture +def mock_token(): + return { + "access_token": "", + "token_type": "Bearer", + "expires_in": 300, + "id_token": "", + "expires_at": 1735397872, + "userinfo": { + "iss": "http://localhost:9000/application/o/romm/", + "sub": "", + "aud": "", + "exp": 1735397871, + "iat": 1735397571, + "auth_time": 1735397571, + "acr": "goauthentik.io/providers/oauth2/default", + "amr": ["pwd"], + "nonce": "", + "sid": "", + "email": "test@example.com", + "email_verified": True, + "name": "Test User", + "given_name": "Test User", + "preferred_username": "testuser", + "nickname": "testuser", + "groups": ["Default Users"], + }, + } + + +async def test_oidc_disabled(mock_oidc_disabled, mock_token): + """Test that OIDC is disabled.""" + oidc_handler = OpenIDHandler() + user, userinfo = await oidc_handler.get_current_active_user_from_openid_token( + mock_token + ) + assert user is None + assert userinfo is None + + +async def test_oidc_valid_token_decoding(mocker, mock_oidc_enabled, mock_token): + """Test token decoding with valid RSA key and token.""" + mock_jwt_payload = Token( + header={"alg": "RS256"}, + claims={"iss": OIDC_SERVER_APPLICATION_URL, "email": "test@example.com"}, + ) + mock_user = MagicMock(enabled=True) + mocker.patch( + "handler.database.db_user_handler.get_user_by_email", return_value=mock_user + ) + + oidc_handler = OpenIDHandler() + user, userinfo = await oidc_handler.get_current_active_user_from_openid_token( + mock_token + ) + + assert user is not None + assert userinfo is not None + + assert user == mock_user + assert userinfo.get("email") == mock_jwt_payload.claims.get("email") + + +async def test_oidc_invalid_token_signature(mock_oidc_enabled): + """Test token decoding raises exception for invalid signature.""" + oidc_handler = OpenIDHandler() + token = {"id_token": "invalid_signature_token"} + with pytest.raises(HTTPException): + await oidc_handler.get_current_active_user_from_openid_token(token) diff --git a/backend/handler/database/collections_handler.py b/backend/handler/database/collections_handler.py index 8502673bb..4faadc963 100644 --- a/backend/handler/database/collections_handler.py +++ b/backend/handler/database/collections_handler.py @@ -35,6 +35,14 @@ def get_collections(self, session: Session = None) -> Select[tuple[Collection]]: .all() ) + @begin_session + def get_collections_by_rom_id( + self, rom_id: int, session: Session = None + ) -> list[Collection]: + return session.scalars( + select(Collection).filter(Collection.roms.contains(rom_id)) + ).all() + @begin_session def update_collection( self, id: int, data: dict, session: Session = None diff --git a/backend/handler/database/roms_handler.py b/backend/handler/database/roms_handler.py index ca9ccf1de..c75dbdd17 100644 --- a/backend/handler/database/roms_handler.py +++ b/backend/handler/database/roms_handler.py @@ -1,10 +1,12 @@ import functools +from collections.abc import Iterable from decorators.database import begin_session from models.collection import Collection from models.rom import Rom, RomUser from sqlalchemy import and_, delete, func, or_, select, update from sqlalchemy.orm import Query, Session, selectinload +from utils.database import json_array_contains_value from .base_handler import DBBaseHandler @@ -140,6 +142,27 @@ def get_rom_by_filename( query.filter_by(platform_id=platform_id, file_name=file_name).limit(1) ) + @begin_session + def get_roms_by_filename( + self, + platform_id: int, + file_names: Iterable[str], + query: Query = None, + session: Session = None, + ) -> dict[str, Rom]: + """Retrieve a dictionary of roms by their file names.""" + query = query or select(Rom) + roms = ( + session.scalars( + query.filter(Rom.file_name.in_(file_names)).filter_by( + platform_id=platform_id + ) + ) + .unique() + .all() + ) + return {rom.file_name: rom for rom in roms} + @begin_session @with_details def get_rom_by_filename_no_tags( @@ -165,7 +188,11 @@ def get_rom_collections( return ( session.scalars( select(Collection) - .filter(func.json_contains(Collection.roms, f"{rom.id}")) + .filter( + json_array_contains_value( + Collection.roms, str(rom.id), session=session + ) + ) .order_by(Collection.name.asc()) ) .unique() @@ -225,6 +252,34 @@ def get_rom_user( select(RomUser).filter_by(rom_id=rom_id, user_id=user_id).limit(1) ) + @begin_session + @with_simple + def get_roms_user( + self, + *, + user_id: int, + platform_id: int | None = None, + collection_id: int | None = None, + search_term: str = "", + order_by: str = "name", + order_dir: str = "asc", + limit: int | None = None, + offset: int | None = None, + query: Query = None, + session: Session = None, + ) -> list[Rom]: + filtered_query = ( + query.join(RomUser) + .filter(RomUser.user_id == user_id) + .order_by(RomUser.last_played.desc()) + ) + filtered_query = self._filter( + filtered_query, platform_id, collection_id, search_term, session + ) + offset_query = filtered_query.offset(offset) + limited_query = offset_query.limit(limit) + return session.scalars(limited_query).unique().all() + @begin_session def get_rom_user_by_id(self, id: int, session: Session = None) -> RomUser | None: return session.scalar(select(RomUser).filter_by(id=id).limit(1)) diff --git a/backend/handler/database/users_handler.py b/backend/handler/database/users_handler.py index 92a467424..1a5da23c3 100644 --- a/backend/handler/database/users_handler.py +++ b/backend/handler/database/users_handler.py @@ -17,6 +17,10 @@ def get_user_by_username( ) -> User | None: return session.scalar(select(User).filter_by(username=username).limit(1)) + @begin_session + def get_user_by_email(self, email: str, session: Session = None) -> User | None: + return session.scalar(select(User).filter_by(email=email).limit(1)) + @begin_session def get_user(self, id: int, session: Session = None) -> User | None: return session.get(User, id) diff --git a/backend/handler/filesystem/base_handler.py b/backend/handler/filesystem/base_handler.py index d5c34a6f8..297b3b017 100644 --- a/backend/handler/filesystem/base_handler.py +++ b/backend/handler/filesystem/base_handler.py @@ -8,7 +8,7 @@ TAG_REGEX = re.compile(r"\(([^)]+)\)|\[([^]]+)\]") EXTENSION_REGEX = re.compile(r"\.(([a-z]+\.)*\w+)$") -LANGUAGES = [ +LANGUAGES = ( ("Ar", "Arabic"), ("Da", "Danish"), ("De", "German"), @@ -27,9 +27,9 @@ ("Sv", "Swedish"), ("Zh", "Chinese"), ("nolang", "No Language"), -] +) -REGIONS = [ +REGIONS = ( ("A", "Australia"), ("AS", "Asia"), ("B", "Brazil"), @@ -57,13 +57,13 @@ ("UNK", "Unknown"), ("UNL", "Unlicensed"), ("W", "World"), -] +) REGIONS_BY_SHORTCODE = {region[0].lower(): region[1] for region in REGIONS} -REGIONS_NAME_KEYS = [region[1].lower() for region in REGIONS] +REGIONS_NAME_KEYS = frozenset(region[1].lower() for region in REGIONS) LANGUAGES_BY_SHORTCODE = {lang[0].lower(): lang[1] for lang in LANGUAGES} -LANGUAGES_NAME_KEYS = [lang[1].lower() for lang in LANGUAGES] +LANGUAGES_NAME_KEYS = frozenset(lang[1].lower() for lang in LANGUAGES) class CoverSize(Enum): diff --git a/backend/handler/filesystem/roms_handler.py b/backend/handler/filesystem/roms_handler.py index 347813996..e76145560 100644 --- a/backend/handler/filesystem/roms_handler.py +++ b/backend/handler/filesystem/roms_handler.py @@ -36,23 +36,27 @@ FSHandler, ) -# list of known compressed file MIME types -COMPRESSED_MIME_TYPES: Final = [ - "application/zip", - "application/x-tar", - "application/x-gzip", - "application/x-7z-compressed", - "application/x-bzip2", -] - -# list of known file extensions that are compressed -COMPRESSED_FILE_EXTENSIONS = [ - ".zip", - ".tar", - ".gz", - ".7z", - ".bz2", -] +# Known compressed file MIME types +COMPRESSED_MIME_TYPES: Final = frozenset( + ( + "application/x-7z-compressed", + "application/x-bzip2", + "application/x-gzip", + "application/x-tar", + "application/zip", + ) +) + +# Known file extensions that are compressed +COMPRESSED_FILE_EXTENSIONS = frozenset( + ( + ".7z", + ".bz2", + ".gz", + ".tar", + ".zip", + ) +) FILE_READ_CHUNK_SIZE = 1024 * 8 @@ -346,14 +350,17 @@ def get_roms(self, platform_fs_slug: str) -> list[FSRom]: for rom in self._exclude_multi_roms(fs_multi_roms) ] - return [ - FSRom( - multi=rom["multi"], - file_name=rom["file_name"], - files=self.get_rom_files(rom["file_name"], roms_file_path), - ) - for rom in fs_roms - ] + return sorted( + [ + FSRom( + multi=rom["multi"], + file_name=rom["file_name"], + files=self.get_rom_files(rom["file_name"], roms_file_path), + ) + for rom in fs_roms + ], + key=lambda rom: rom["file_name"], + ) def file_exists(self, path: str, file_name: str) -> bool: """Check if file exists in filesystem diff --git a/backend/handler/metadata/igdb_handler.py b/backend/handler/metadata/igdb_handler.py index c367101a1..679056a6b 100644 --- a/backend/handler/metadata/igdb_handler.py +++ b/backend/handler/metadata/igdb_handler.py @@ -1,6 +1,5 @@ import functools import re -import time from typing import Final, NotRequired, TypedDict import httpx @@ -8,7 +7,7 @@ from adapters.services.igdb_types import GameCategory from config import IGDB_CLIENT_ID, IGDB_CLIENT_SECRET, IS_PYTEST_RUN from fastapi import HTTPException, status -from handler.redis_handler import sync_cache +from handler.redis_handler import async_cache from logger.logger import log from unidecode import unidecode as uc from utils.context import ctx_httpx_client @@ -35,6 +34,18 @@ class IGDBPlatform(TypedDict): slug: str igdb_id: int | None name: NotRequired[str] + category: NotRequired[str] + generation: NotRequired[str] + family_name: NotRequired[str] + family_slug: NotRequired[str] + url: NotRequired[str] + url_logo: NotRequired[str] + logo_path: NotRequired[str] + + +class IGDBMetadataPlatform(TypedDict): + igdb_id: int + name: str class IGDBAgeRating(TypedDict): @@ -43,11 +54,6 @@ class IGDBAgeRating(TypedDict): rating_cover_url: str -class IGDBMetadataPlatform(TypedDict): - igdb_id: int - name: str - - class IGDBRelatedGame(TypedDict): id: int name: str @@ -88,12 +94,10 @@ class IGDBRom(TypedDict): igdb_metadata: NotRequired[IGDBMetadata] -def extract_metadata_from_igdb_rom( - rom: dict, video_id: str | None = None -) -> IGDBMetadata: +def extract_metadata_from_igdb_rom(rom: dict) -> IGDBMetadata: return IGDBMetadata( { - "youtube_video_id": video_id, + "youtube_video_id": str(pydash.get(rom, "videos[0].video_id", None)), "total_rating": str(round(rom.get("total_rating", 0.0), 2)), "aggregated_rating": str(round(rom.get("aggregated_rating", 0.0), 2)), "first_release_date": rom.get("first_release_date", None), @@ -120,7 +124,9 @@ def extract_metadata_from_igdb_rom( id=e["id"], slug=e["slug"], name=e["name"], - cover_url=pydash.get(e, "cover.url", ""), + cover_url=MetadataHandler._normalize_cover_url( + pydash.get(e, "cover.url", "").replace("t_thumb", "t_1080p") + ), type="expansion", ) for e in rom.get("expansions", []) @@ -130,7 +136,9 @@ def extract_metadata_from_igdb_rom( id=d["id"], slug=d["slug"], name=d["name"], - cover_url=pydash.get(d, "cover.url", ""), + cover_url=MetadataHandler._normalize_cover_url( + pydash.get(d, "cover.url", "").replace("t_thumb", "t_1080p") + ), type="dlc", ) for d in rom.get("dlcs", []) @@ -140,7 +148,9 @@ def extract_metadata_from_igdb_rom( id=r["id"], slug=r["slug"], name=r["name"], - cover_url=pydash.get(r, "cover.url", ""), + cover_url=MetadataHandler._normalize_cover_url( + pydash.get(r, "cover.url", "").replace("t_thumb", "t_1080p") + ), type="remaster", ) for r in rom.get("remasters", []) @@ -150,7 +160,9 @@ def extract_metadata_from_igdb_rom( id=r["id"], slug=r["slug"], name=r["name"], - cover_url=pydash.get(r, "cover.url", ""), + cover_url=MetadataHandler._normalize_cover_url( + pydash.get(r, "cover.url", "").replace("t_thumb", "t_1080p") + ), type="remake", ) for r in rom.get("remakes", []) @@ -160,7 +172,9 @@ def extract_metadata_from_igdb_rom( id=g["id"], slug=g["slug"], name=g["name"], - cover_url=pydash.get(g, "cover.url", ""), + cover_url=MetadataHandler._normalize_cover_url( + pydash.get(g, "cover.url", "").replace("t_thumb", "t_1080p") + ), type="expanded", ) for g in rom.get("expanded_games", []) @@ -170,7 +184,9 @@ def extract_metadata_from_igdb_rom( id=p["id"], slug=p["slug"], name=p["name"], - cover_url=pydash.get(p, "cover.url", ""), + cover_url=MetadataHandler._normalize_cover_url( + pydash.get(p, "cover.url", "").replace("t_thumb", "t_1080p") + ), type="port", ) for p in rom.get("ports", []) @@ -180,7 +196,9 @@ def extract_metadata_from_igdb_rom( id=s["id"], slug=s["slug"], name=s["name"], - cover_url=pydash.get(s, "cover.url", ""), + cover_url=MetadataHandler._normalize_cover_url( + pydash.get(s, "cover.url", "").replace("t_thumb", "t_1080p") + ), type="similar", ) for s in rom.get("similar_games", []) @@ -199,7 +217,6 @@ def __init__(self) -> None: self.games_fields = GAMES_FIELDS self.search_endpoint = f"{self.BASE_URL}/search" self.search_fields = SEARCH_FIELDS - self.video_endpoint = f"{self.BASE_URL}/game_videos" self.pagination_limit = 200 self.twitch_auth = TwitchAuth() self.headers = { @@ -318,6 +335,7 @@ def is_exact_match(rom: dict, search_term: str) -> bool: for rom_name in rom_names ) + log.debug("Searching in games endpoint with category %s", category_filter) roms = await self._request( self.games_endpoint, data=f'search "{search_term}"; fields {",".join(self.games_fields)}; where platforms=[{platform_igdb_id}] {category_filter};', @@ -327,11 +345,16 @@ def is_exact_match(rom: dict, search_term: str) -> bool: if is_exact_match(rom, search_term): return rom + log.debug("Searching expanded in search endpoint") roms_expanded = await self._request( self.search_endpoint, data=f'fields {",".join(self.search_fields)}; where game.platforms=[{platform_igdb_id}] & (name ~ *"{search_term}"* | alternative_name ~ *"{search_term}"*);', ) if roms_expanded: + log.debug( + "Searching expanded in games endpoint for expanded game %s", + roms_expanded[0]["game"], + ) extra_roms = await self._request( self.games_endpoint, f'fields {",".join(self.games_fields)}; where id={roms_expanded[0]["game"]["id"]};', @@ -354,13 +377,24 @@ async def get_platform(self, slug: str) -> IGDBPlatform: self.platform_endpoint, data=f'fields {",".join(self.platforms_fields)}; where slug="{slug.lower()}";', ) - platform = pydash.get(platforms, "[0]", None) if platform: return IGDBPlatform( - igdb_id=platform["id"], + igdb_id=platform.get("id", None), slug=slug, - name=platform["name"], + name=platform.get("name", slug), + category=IGDB_PLATFORM_CATEGORIES.get( + platform.get("category", 0), "Unknown" + ), + generation=platform.get("generation", None), + family_name=pydash.get(platform, "platform_family.name", None), + family_slug=pydash.get(platform, "platform_family.slug", None), + url=platform.get("url", None), + url_logo=self._normalize_cover_url( + pydash.get(platform, "platform_logo.url", "").replace( + "t_thumb", "t_1080p" + ) + ), ) # Check if platform is a version if not found @@ -448,13 +482,19 @@ async def get_rom(self, file_name: str, platform_igdb_id: int) -> IGDBRom: search_term = self.normalize_search_term(search_term) + log.debug("Searching for %s on IGDB with category", search_term) rom = await self._search_rom(search_term, platform_igdb_id, with_category=True) if not rom: + log.debug("Searching for %s on IGDB without category", search_term) rom = await self._search_rom(search_term, platform_igdb_id) # Split the search term since igdb struggles with colons if not rom and ":" in search_term: for term in search_term.split(":")[::-1]: + log.debug( + "Searching for %s on IGDB without category after splitting semicolon", + term, + ) rom = await self._search_rom(term, platform_igdb_id) if rom: break @@ -462,6 +502,10 @@ async def get_rom(self, file_name: str, platform_igdb_id: int) -> IGDBRom: # Some MAME games have two titles split by a slash if not rom and "/" in search_term: for term in search_term.split("/"): + log.debug( + "Searching for %s on IGDB without category after splitting slash", + term, + ) rom = await self._search_rom(term.strip(), platform_igdb_id) if rom: break @@ -469,13 +513,6 @@ async def get_rom(self, file_name: str, platform_igdb_id: int) -> IGDBRom: if not rom: return fallback_rom - # Get the video ID for the game - video_ids = await self._request( - self.video_endpoint, - f'fields video_id; where game={rom["id"]};', - ) - video_id = pydash.get(video_ids, "[0].video_id", None) - return IGDBRom( igdb_id=rom["id"], slug=rom["slug"], @@ -488,7 +525,7 @@ async def get_rom(self, file_name: str, platform_igdb_id: int) -> IGDBRom: self._normalize_cover_url(s.get("url", "")).replace("t_thumb", "t_720p") for s in rom.get("screenshots", []) ], - igdb_metadata=extract_metadata_from_igdb_rom(rom, video_id), + igdb_metadata=extract_metadata_from_igdb_rom(rom), ) @check_twitch_token @@ -505,13 +542,6 @@ async def get_rom_by_id(self, igdb_id: int) -> IGDBRom: if not rom: return IGDBRom(igdb_id=None) - # Get the video ID for the game - video_ids = await self._request( - self.video_endpoint, - f'fields video_id; where game={rom["id"]};', - ) - video_id = pydash.get(video_ids, "[0].video_id", None) - return IGDBRom( igdb_id=rom["id"], slug=rom["slug"], @@ -524,16 +554,16 @@ async def get_rom_by_id(self, igdb_id: int) -> IGDBRom: self._normalize_cover_url(s.get("url", "")).replace("t_thumb", "t_720p") for s in rom.get("screenshots", []) ], - igdb_metadata=extract_metadata_from_igdb_rom(rom, video_id), + igdb_metadata=extract_metadata_from_igdb_rom(rom), ) @check_twitch_token - async def get_matched_roms_by_id(self, igdb_id: int) -> list[IGDBRom]: + async def get_matched_rom_by_id(self, igdb_id: int) -> IGDBRom | None: if not IGDB_API_ENABLED: - return [] + return None rom = await self.get_rom_by_id(igdb_id) - return [rom] if rom["igdb_id"] else [] + return rom if rom["igdb_id"] else None @check_twitch_token async def get_matched_roms_by_name( @@ -633,12 +663,12 @@ def __init__(self): self.timeout = 10 async def _update_twitch_token(self) -> str: - token = None - expires_in = 0 - if not IGDB_API_ENABLED: return "" + token = None + expires_in = 0 + httpx_client = ctx_httpx_client.get() try: log.debug( @@ -667,9 +697,8 @@ async def _update_twitch_token(self) -> str: if not token or expires_in == 0: return "" - # Set token in redis to expire in seconds - sync_cache.set("romm:twitch_token", token, ex=expires_in - 10) - sync_cache.set("romm:twitch_token_expires_at", time.time() + expires_in - 10) + # Set token in Redis to expire some seconds before it actually expires. + await async_cache.set("romm:twitch_token", token, ex=expires_in - 10) log.info("Twitch token fetched!") @@ -684,19 +713,26 @@ async def get_oauth_token(self) -> str: return "" # Fetch the token cache - token = sync_cache.get("romm:twitch_token") - token_expires_at = sync_cache.get("romm:twitch_token_expires_at") - - if not token or time.time() > float(token_expires_at or 0): + token = await async_cache.get("romm:twitch_token") + if not token: log.warning("Twitch token invalid: fetching a new one...") return await self._update_twitch_token() return token -PLATFORMS_FIELDS = ["id", "name"] +PLATFORMS_FIELDS = ( + "id", + "name", + "category", + "generation", + "url", + "platform_family.name", + "platform_family.slug", + "platform_logo.url", +) -GAMES_FIELDS = [ +GAMES_FIELDS = ( "id", "name", "slug", @@ -745,9 +781,10 @@ async def get_oauth_token(self) -> str: "similar_games.name", "similar_games.cover.url", "age_ratings.rating", -] + "videos.video_id", +) -SEARCH_FIELDS = ["game.id", "name"] +SEARCH_FIELDS = ("game.id", "name") # Generated from the following code on https://www.igdb.com/platforms/: # Array.from(document.querySelectorAll(".media-body a")).map(a => ({ @@ -755,7 +792,7 @@ async def get_oauth_token(self) -> str: # name: a.innerText # })) -IGDB_PLATFORM_LIST = [ +IGDB_PLATFORM_LIST = ( {"slug": "visionos", "name": "visionOS"}, {"slug": "meta-quest-3", "name": "Meta Quest 3"}, {"slug": "atari2600", "name": "Atari 2600"}, @@ -972,7 +1009,17 @@ async def get_oauth_token(self) -> str: {"slug": "onlive-game-system", "name": "OnLive Game System"}, {"slug": "vc", "name": "Virtual Console"}, {"slug": "airconsole", "name": "AirConsole"}, -] +) + +IGDB_PLATFORM_CATEGORIES: dict[int, str] = { + 0: "Unknown", + 1: "Console", + 2: "Arcade", + 3: "Platform", + 4: "Operative System", + 5: "Portable Console", + 6: "Computer", +} IGDB_AGE_RATINGS: dict[int, IGDBAgeRating] = { 1: { diff --git a/backend/handler/metadata/moby_handler.py b/backend/handler/metadata/moby_handler.py index 53cba5d3f..e32ee3148 100644 --- a/backend/handler/metadata/moby_handler.py +++ b/backend/handler/metadata/moby_handler.py @@ -302,12 +302,12 @@ async def get_rom_by_id(self, moby_id: int) -> MobyGamesRom: return MobyGamesRom({k: v for k, v in rom.items() if v}) # type: ignore[misc] - async def get_matched_roms_by_id(self, moby_id: int) -> list[MobyGamesRom]: + async def get_matched_rom_by_id(self, moby_id: int) -> MobyGamesRom | None: if not MOBY_API_ENABLED: - return [] + return None rom = await self.get_rom_by_id(moby_id) - return [rom] if rom["moby_id"] else [] + return rom if rom["moby_id"] else None async def get_matched_roms_by_name( self, search_term: str, platform_moby_id: int diff --git a/backend/handler/scan_handler.py b/backend/handler/scan_handler.py index 3592e391a..f78b8f3ee 100644 --- a/backend/handler/scan_handler.py +++ b/backend/handler/scan_handler.py @@ -1,4 +1,5 @@ import asyncio +import zlib from enum import Enum from typing import Any @@ -10,7 +11,7 @@ from handler.metadata import meta_igdb_handler, meta_moby_handler from handler.metadata.igdb_handler import IGDBPlatform, IGDBRom from handler.metadata.moby_handler import MobyGamesPlatform, MobyGamesRom -from logger.formatter import BLUE +from logger.formatter import BLUE, RED from logger.formatter import highlight as hl from logger.logger import log from models.assets import Save, Screenshot, State @@ -19,7 +20,36 @@ from models.rom import Rom from models.user import User -NON_HASHABLE_PLATFORMS = ["pc", "win", "mac", "linux"] +NON_HASHABLE_PLATFORMS = frozenset( + ( + "amazon-alexa", + "amazon-fire-tv", + "android", + "gear-vr", + "ios", + "ipad", + "linux", + "mac", + "meta-quest-2", + "meta-quest-3", + "oculus-go", + "oculus-quest", + "oculus-rift", + "pc", + "ps3", + "ps4", + "ps4--1", + "ps5", + "psvr", + "psvr2", + "series-x", + "switch", + "wiiu", + "win", + "xbox-360", + "xboxone", + ) +) class ScanType(Enum): @@ -244,10 +274,17 @@ async def scan_rom( if platform.slug in NON_HASHABLE_PLATFORMS: rom_attrs.update({"crc_hash": "", "md5_hash": "", "sha1_hash": ""}) else: - rom_hashes = fs_rom_handler.get_rom_hashes( - rom_attrs["file_name"], roms_path - ) - rom_attrs.update(**rom_hashes) + try: + rom_hashes = fs_rom_handler.get_rom_hashes( + rom_attrs["file_name"], roms_path + ) + rom_attrs.update(**rom_hashes) + except zlib.error as e: + # Return empty hashes if calculating them fails for corrupted files + log.error( + f"Hashes of {rom_attrs['file_name']} couldn't be calculated: {hl(str(e), color=RED)}" + ) + rom_attrs.update({"crc_hash": "", "md5_hash": "", "sha1_hash": ""}) # If no metadata scan is required if scan_type == ScanType.HASHES: diff --git a/backend/logger/formatter.py b/backend/logger/formatter.py index 2a5b2dcc4..501e664d9 100644 --- a/backend/logger/formatter.py +++ b/backend/logger/formatter.py @@ -2,6 +2,7 @@ import os from colorama import Fore, Style, init +from config import FORCE_COLOR, NO_COLOR RED = Fore.RED GREEN = Fore.GREEN @@ -10,12 +11,12 @@ BLUE = Fore.BLUE -def should_strip_ansi(): +def should_strip_ansi() -> bool: """Determine if ANSI escape codes should be stripped.""" # Check if an explicit environment variable is set to control color behavior - if os.getenv("FORCE_COLOR", "false").lower() == "true": + if FORCE_COLOR: return False - if os.getenv("NO_COLOR", "false").lower() == "true": + if NO_COLOR: return True # For other environments, strip colors if not a TTY @@ -31,7 +32,7 @@ class Formatter(logging.Formatter): Logger formatter. """ - def format(self, record): + def format(self, record: logging.LogRecord) -> str: """ Formats a log record with color-coded output based on the log level. @@ -41,8 +42,8 @@ def format(self, record): Returns: The formatted log record as a string. """ - level: str = "%(levelname)s" - dots: str = f"{Fore.RESET}:" + level = "%(levelname)s" + dots = f"{Fore.RESET}:" identifier = ( f"\t {Fore.BLUE}[RomM]{Fore.LIGHTMAGENTA_EX}[{str(record.module_name).lower()}]" if hasattr(record, "module_name") @@ -58,9 +59,9 @@ def format(self, record): if hasattr(record, "module_name") else f" {Fore.BLUE}[RomM]{Fore.LIGHTMAGENTA_EX}[%(module)s]" ) - msg: str = f"{Style.RESET_ALL}%(message)s" - date: str = f"{Fore.CYAN}[%(asctime)s] " - formats: dict = { + msg = f"{Style.RESET_ALL}%(message)s" + date = f"{Fore.CYAN}[%(asctime)s] " + formats = { logging.DEBUG: f"{Fore.LIGHTMAGENTA_EX}{level}{dots}{identifier}{date}{msg}", logging.INFO: f"{Fore.GREEN}{level}{dots}{identifier}{date}{msg}", logging.WARNING: f"{Fore.YELLOW}{level}{dots}{identifier_warning}{date}{msg}", diff --git a/backend/main.py b/backend/main.py index ba962e756..06cf9f19a 100644 --- a/backend/main.py +++ b/backend/main.py @@ -4,6 +4,7 @@ import alembic.config import endpoints.sockets.scan # noqa +import sentry_sdk import uvicorn from config import ( DEV_HOST, @@ -11,11 +12,12 @@ DISABLE_CSRF_PROTECTION, IS_PYTEST_RUN, ROMM_AUTH_SECRET_KEY, + SENTRY_DSN, ) from endpoints import ( auth, collections, - config, + configs, feeds, firmware, heartbeat, @@ -48,6 +50,11 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: yield +sentry_sdk.init( + dsn=SENTRY_DSN, + release="romm@" + get_version(), +) + app = FastAPI( title="RomM API", version=get_version(), @@ -102,7 +109,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: app.include_router(states.router, prefix="/api") app.include_router(tasks.router, prefix="/api") app.include_router(feeds.router, prefix="/api") -app.include_router(config.router, prefix="/api") +app.include_router(configs.router, prefix="/api") app.include_router(stats.router, prefix="/api") app.include_router(raw.router, prefix="/api") app.include_router(screenshots.router, prefix="/api") diff --git a/backend/models/collection.py b/backend/models/collection.py index 261a91d4d..2de5e96bd 100644 --- a/backend/models/collection.py +++ b/backend/models/collection.py @@ -4,8 +4,9 @@ from models.base import BaseModel from models.user import User -from sqlalchemy import JSON, ForeignKey, String, Text +from sqlalchemy import ForeignKey, String, Text from sqlalchemy.orm import Mapped, mapped_column, relationship +from utils.database import CustomJSON class Collection(BaseModel): @@ -24,7 +25,7 @@ class Collection(BaseModel): ) roms: Mapped[set[int]] = mapped_column( - JSON, default=[], doc="Rom id's that belong to this collection" + CustomJSON(), default=[], doc="Rom id's that belong to this collection" ) user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE")) diff --git a/backend/models/platform.py b/backend/models/platform.py index 2e0d9ffdc..370affac6 100644 --- a/backend/models/platform.py +++ b/backend/models/platform.py @@ -11,6 +11,9 @@ from models.firmware import Firmware +DEFAULT_COVER_ASPECT_RATIO = "2 / 3" + + class Platform(BaseModel): __tablename__ = "platforms" @@ -21,6 +24,13 @@ class Platform(BaseModel): slug: Mapped[str] = mapped_column(String(length=50)) fs_slug: Mapped[str] = mapped_column(String(length=50)) name: Mapped[str] = mapped_column(String(length=400)) + custom_name: Mapped[str | None] = mapped_column(String(length=400), default="") + category: Mapped[str | None] = mapped_column(String(length=50), default="") + generation: Mapped[int | None] + family_name: Mapped[str | None] = mapped_column(String(length=1000), default="") + family_slug: Mapped[str | None] = mapped_column(String(length=1000), default="") + url: Mapped[str | None] = mapped_column(String(length=1000), default="") + url_logo: Mapped[str | None] = mapped_column(String(length=1000), default="") logo_path: Mapped[str | None] = mapped_column(String(length=1000), default="") roms: Mapped[list[Rom]] = relationship(back_populates="platform") @@ -28,6 +38,10 @@ class Platform(BaseModel): lazy="selectin", back_populates="platform" ) + aspect_ratio: Mapped[str] = mapped_column( + String(length=10), server_default=DEFAULT_COVER_ASPECT_RATIO + ) + # This runs a subquery to get the count of roms for the platform rom_count = column_property( select(func.count(Rom.id)).where(Rom.platform_id == id).scalar_subquery() diff --git a/backend/models/rom.py b/backend/models/rom.py index e198fcb15..f60aec013 100644 --- a/backend/models/rom.py +++ b/backend/models/rom.py @@ -8,18 +8,18 @@ from config import FRONTEND_RESOURCES_PATH from models.base import BaseModel from sqlalchemy import ( - JSON, BigInteger, DateTime, Enum, ForeignKey, + Index, Integer, String, Text, UniqueConstraint, ) -from sqlalchemy.dialects.mysql.json import JSON as MySQLJSON from sqlalchemy.orm import Mapped, mapped_column, relationship +from utils.database import CustomJSON if TYPE_CHECKING: from models.assets import Save, Screenshot, State @@ -43,6 +43,11 @@ class Rom(BaseModel): sgdb_id: Mapped[int | None] moby_id: Mapped[int | None] + __table_args__ = ( + Index("idx_roms_igdb_id", "igdb_id"), + Index("idx_roms_moby_id", "moby_id"), + ) + file_name: Mapped[str] = mapped_column(String(length=450)) file_name_no_tags: Mapped[str] = mapped_column(String(length=450)) file_name_no_ext: Mapped[str] = mapped_column(String(length=450)) @@ -54,10 +59,10 @@ class Rom(BaseModel): slug: Mapped[str | None] = mapped_column(String(length=400)) summary: Mapped[str | None] = mapped_column(Text) igdb_metadata: Mapped[dict[str, Any] | None] = mapped_column( - MySQLJSON, default=dict + CustomJSON(), default=dict ) moby_metadata: Mapped[dict[str, Any] | None] = mapped_column( - MySQLJSON, default=dict + CustomJSON(), default=dict ) path_cover_s: Mapped[str | None] = mapped_column(Text, default="") @@ -67,17 +72,17 @@ class Rom(BaseModel): ) revision: Mapped[str | None] = mapped_column(String(100)) - regions: Mapped[list[str] | None] = mapped_column(JSON, default=[]) - languages: Mapped[list[str] | None] = mapped_column(JSON, default=[]) - tags: Mapped[list[str] | None] = mapped_column(JSON, default=[]) + regions: Mapped[list[str] | None] = mapped_column(CustomJSON(), default=[]) + languages: Mapped[list[str] | None] = mapped_column(CustomJSON(), default=[]) + tags: Mapped[list[str] | None] = mapped_column(CustomJSON(), default=[]) - path_screenshots: Mapped[list[str] | None] = mapped_column(JSON, default=[]) + path_screenshots: Mapped[list[str] | None] = mapped_column(CustomJSON(), default=[]) url_screenshots: Mapped[list[str] | None] = mapped_column( - JSON, default=[], doc="URLs to screenshots stored in IGDB" + CustomJSON(), default=[], doc="URLs to screenshots stored in IGDB" ) multi: Mapped[bool] = mapped_column(default=False) - files: Mapped[list[RomFile] | None] = mapped_column(JSON, default=[]) + files: Mapped[list[RomFile] | None] = mapped_column(CustomJSON(), default=[]) crc_hash: Mapped[str | None] = mapped_column(String(100)) md5_hash: Mapped[str | None] = mapped_column(String(100)) sha1_hash: Mapped[str | None] = mapped_column(String(100)) @@ -110,6 +115,14 @@ def platform_fs_slug(self) -> str: def platform_name(self) -> str: return self.platform.name + @property + def platform_custom_name(self) -> str | None: + return self.platform.custom_name + + @property + def platform_display_name(self) -> str: + return self.platform.custom_name or self.platform.name + @cached_property def full_path(self) -> str: return f"{self.file_path}/{self.file_name}" @@ -120,9 +133,12 @@ def has_cover(self) -> bool: @cached_property def merged_screenshots(self) -> list[str]: - return [s.download_path for s in self.screenshots] + [ - f"{FRONTEND_RESOURCES_PATH}/{s}" for s in self.path_screenshots - ] + screenshots = [s.download_path for s in self.screenshots] + if self.path_screenshots: + screenshots += [ + f"{FRONTEND_RESOURCES_PATH}/{s}" for s in self.path_screenshots + ] + return screenshots def get_collections(self) -> list[Collection]: from handler.database import db_rom_handler @@ -132,47 +148,61 @@ def get_collections(self) -> list[Collection]: # Metadata fields @property def youtube_video_id(self) -> str: - return self.igdb_metadata.get("youtube_video_id", "") + if self.igdb_metadata: + return self.igdb_metadata.get("youtube_video_id", "") + return "" @property def alternative_names(self) -> list[str]: return ( - self.igdb_metadata.get("alternative_names", None) - or self.moby_metadata.get("alternate_titles", None) + (self.igdb_metadata or {}).get("alternative_names", None) + or (self.moby_metadata or {}).get("alternate_titles", None) or [] ) @property def first_release_date(self) -> int: - return self.igdb_metadata.get("first_release_date", 0) + if self.igdb_metadata: + return self.igdb_metadata.get("first_release_date", 0) + return 0 @property def genres(self) -> list[str]: return ( - self.igdb_metadata.get("genres", None) - or self.moby_metadata.get("genres", None) + (self.igdb_metadata or {}).get("genres", None) + or (self.moby_metadata or {}).get("genres", None) or [] ) @property def franchises(self) -> list[str]: - return self.igdb_metadata.get("franchises", []) + if self.igdb_metadata: + return self.igdb_metadata.get("franchises", []) + return [] @property def collections(self) -> list[str]: - return self.igdb_metadata.get("collections", []) + if self.igdb_metadata: + return self.igdb_metadata.get("collections", []) + return [] @property def companies(self) -> list[str]: - return self.igdb_metadata.get("companies", []) + if self.igdb_metadata: + return self.igdb_metadata.get("companies", []) + return [] @property def game_modes(self) -> list[str]: - return self.igdb_metadata.get("game_modes", []) + if self.igdb_metadata: + return self.igdb_metadata.get("game_modes", []) + return [] @property def age_ratings(self) -> list[str]: - return [r["rating"] for r in self.igdb_metadata.get("age_ratings", [])] + if self.igdb_metadata: + return [r["rating"] for r in self.igdb_metadata.get("age_ratings", [])] + return [] @property def fs_resources_path(self) -> str: diff --git a/backend/models/user.py b/backend/models/user.py index 92c7d260a..8a1f39164 100644 --- a/backend/models/user.py +++ b/backend/models/user.py @@ -30,9 +30,12 @@ class User(BaseModel, SimpleUser): username: Mapped[str] = mapped_column(String(length=255), unique=True, index=True) hashed_password: Mapped[str | None] = mapped_column(String(length=255)) + email: Mapped[str | None] = mapped_column( + String(length=255), unique=True, index=True + ) enabled: Mapped[bool] = mapped_column(default=True) - role: Mapped[Role | None] = mapped_column(Enum(Role), default=Role.VIEWER) - avatar_path: Mapped[str | None] = mapped_column(String(length=255), default="") + role: Mapped[Role] = mapped_column(Enum(Role), default=Role.VIEWER) + avatar_path: Mapped[str] = mapped_column(String(length=255), default="") last_login: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) last_active: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) diff --git a/backend/scheduler.py b/backend/scheduler.py index 485cc822c..2105c65ac 100644 --- a/backend/scheduler.py +++ b/backend/scheduler.py @@ -1,7 +1,15 @@ +import sentry_sdk +from config import SENTRY_DSN from logger.logger import log from tasks.scan_library import scan_library_task from tasks.tasks import tasks_scheduler from tasks.update_switch_titledb import update_switch_titledb_task +from utils import get_version + +sentry_sdk.init( + dsn=SENTRY_DSN, + release="romm@" + get_version(), +) if __name__ == "__main__": # Initialize the tasks diff --git a/backend/utils/__init__.py b/backend/utils/__init__.py index 4d3c83144..a0861fc26 100644 --- a/backend/utils/__init__.py +++ b/backend/utils/__init__.py @@ -3,7 +3,7 @@ def get_version() -> str: """Returns current version tag""" - if not __version__ == "": + if __version__ != "": return __version__ return "development" diff --git a/backend/utils/archive_7zip.py b/backend/utils/archive_7zip.py index 2a7325653..320de9d97 100644 --- a/backend/utils/archive_7zip.py +++ b/backend/utils/archive_7zip.py @@ -18,7 +18,6 @@ def __init__( self._size = 0 def write(self, s: bytes | bytearray) -> int: - print(f"{self.__class__.__name__}: write. filename={self.filename}") length = len(s) self._size += length self.on_write(s) diff --git a/backend/utils/database.py b/backend/utils/database.py new file mode 100644 index 000000000..2ef7d700d --- /dev/null +++ b/backend/utils/database.py @@ -0,0 +1,25 @@ +from typing import Any + +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql as sa_pg +from sqlalchemy.orm import Session +from sqlalchemy.sql import ColumnElement, func + + +def CustomJSON(**kwargs: Any) -> sa.JSON: + """Custom SQLAlchemy JSON type that uses JSONB on PostgreSQL.""" + return sa.JSON(**kwargs).with_variant(sa_pg.JSONB(**kwargs), "postgresql") + + +def is_postgresql(conn: sa.Connection) -> bool: + return conn.engine.name == "postgresql" + + +def json_array_contains_value( + column: sa.Column, value: Any, *, session: Session +) -> ColumnElement: + """Check if a JSON array column contains a single value.""" + conn = session.get_bind() + if is_postgresql(conn): + return sa.type_coerce(column, sa_pg.JSONB()).has_key(value) + return func.json_contains(column, value) diff --git a/backend/watcher.py b/backend/watcher.py index 37a3ca448..3e3cb15db 100644 --- a/backend/watcher.py +++ b/backend/watcher.py @@ -1,10 +1,12 @@ import os from datetime import timedelta +import sentry_sdk from config import ( ENABLE_RESCAN_ON_FILESYSTEM_CHANGE, LIBRARY_BASE_PATH, RESCAN_ON_FILESYSTEM_CHANGE_DELAY, + SENTRY_DSN, ) from config.config_manager import config_manager as cm from endpoints.sockets.scan import scan_platforms @@ -12,9 +14,15 @@ from handler.scan_handler import ScanType from logger.logger import log from tasks.tasks import tasks_scheduler +from utils import get_version from watchdog.events import FileSystemEventHandler from watchdog.observers import Observer +sentry_sdk.init( + dsn=SENTRY_DSN, + release="romm@" + get_version(), +) + path = ( cm.get_config().HIGH_PRIO_STRUCTURE_PATH if os.path.exists(cm.get_config().HIGH_PRIO_STRUCTURE_PATH) diff --git a/backend/worker.py b/backend/worker.py index a67e2cb68..cda32afd2 100644 --- a/backend/worker.py +++ b/backend/worker.py @@ -1,8 +1,17 @@ +import sentry_sdk +from config import SENTRY_DSN from handler.redis_handler import redis_client from rq import Connection, Queue, Worker +from utils import get_version listen = ["high", "default", "low"] +sentry_sdk.init( + dsn=SENTRY_DSN, + release="romm@" + get_version(), +) + + if __name__ == "__main__": # Start the worker with Connection(redis_client): diff --git a/docker-compose.yml b/docker-compose.yml index d5d3987a6..574a8623a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,23 +1,95 @@ # Please see the full example under examples/docker-compose.example.yml services: - mariadb: - image: mariadb:latest - container_name: mariadb + romm-mariadb-dev: + image: mariadb:11.3.2 + container_name: romm-mariadb-dev restart: unless-stopped + env_file: + - .env environment: - MARIADB_ROOT_PASSWORD=$DB_ROOT_PASSWD - MARIADB_DATABASE=$DB_NAME - MARIADB_USER=$DB_USER - MARIADB_PASSWORD=$DB_PASSWD + volumes: + - maria-db:/var/lib/mysql ports: - $DB_PORT:3306 - valkey: + romm-valkey-dev: image: valkey/valkey:8 - container_name: valkey + container_name: romm-valkey-dev restart: unless-stopped + env_file: + - .env ports: - $REDIS_PORT:6379 + + romm-postgres-dev: + image: docker.io/library/postgres:16-alpine + container_name: romm-postgresql-dev + restart: unless-stopped + env_file: + - .env + environment: + POSTGRES_PASSWORD: $POSTGRES_PASSWORD + POSTGRES_USER: $POSTGRES_USER + POSTGRES_DB: $POSTGRES_DB + volumes: + - postgres-db:/var/lib/postgresql/data + ports: + - 5432:5432 + + romm-authentik-server: + image: ghcr.io/goauthentik/server:2024.10.4 + container_name: romm-authentik-server + restart: unless-stopped + command: server + env_file: + - .env + environment: + AUTHENTIK_REDIS__HOST: romm-valkey-dev + AUTHENTIK_POSTGRESQL__HOST: romm-postgres-dev + AUTHENTIK_POSTGRESQL__USER: $POSTGRES_USER + AUTHENTIK_POSTGRESQL__NAME: $POSTGRES_DB + AUTHENTIK_POSTGRESQL__PASSWORD: $POSTGRES_PASSWORD + AUTHENTIK_SECRET_KEY: $AUTHENTIK_SECRET_KEY + AUTHENTIK_BOOTSTRAP_PASSWORD: $AUTHENTIK_BOOTSTRAP_PASSWORD + volumes: + - authentik-media:/media + - authentik-templates:/templates + ports: + - 9000:9000 + - 9443:9443 + depends_on: + - romm-postgres-dev + - romm-valkey-dev + + romm-authentik-worker: + image: ghcr.io/goauthentik/server:2024.10.4 + container_name: romm-authentik-worker + restart: unless-stopped + command: worker env_file: - .env + environment: + AUTHENTIK_REDIS__HOST: romm-valkey-dev + AUTHENTIK_POSTGRESQL__HOST: romm-postgres-dev + AUTHENTIK_POSTGRESQL__USER: $POSTGRES_USER + AUTHENTIK_POSTGRESQL__NAME: $POSTGRES_DB + AUTHENTIK_POSTGRESQL__PASSWORD: $POSTGRES_PASSWORD + AUTHENTIK_SECRET_KEY: $AUTHENTIK_SECRET_KEY + AUTHENTIK_BOOTSTRAP_PASSWORD: $AUTHENTIK_BOOTSTRAP_PASSWORD + volumes: + - authentik-media:/media + - authentik-templates:/templates + depends_on: + - romm-postgres-dev + - romm-valkey-dev + +volumes: + maria-db: + postgres-db: + authentik-media: + authentik-templates: diff --git a/docker/Dockerfile b/docker/Dockerfile index d80a5936a..9c3592ce6 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,10 +1,15 @@ # Stages: -# - front-build-stage: Build frontend +# - frontend-build: Build frontend # - backend-build: Build backend environment +# - backend-dev-build: Similar to `backend-build`, but also compiles and installs development dependencies # - rahasher-build: Build RAHasher +# - emulator-stage: Fetch and extract emulators # - nginx-build: Build nginx modules # - production-stage: Setup frontend and backend -# - final-stage: Move everything to final stage +# - slim-image: Slim image with only the necessary files +# - full-image: Full image with emulator stage +# - dev-slim: Slim image with development dependencies +# - dev-full: Full image with emulator stage and development dependencies # Versions: ARG ALPINE_VERSION=3.20 @@ -13,7 +18,7 @@ ARG NODE_VERSION=20.16 ARG PYTHON_VERSION=3.12 -FROM node:${NODE_VERSION}-alpine${ALPINE_VERSION} AS front-build-stage +FROM node:${NODE_VERSION}-alpine${ALPINE_VERSION} AS frontend-build WORKDIR /front COPY ./frontend/package*.json ./ @@ -27,12 +32,15 @@ FROM python:${PYTHON_VERSION}-alpine${ALPINE_VERSION} AS backend-build # git is needed to install py7zr fork # libffi-dev is needed to fix poetry dependencies for >= v1.8 on arm64 +# libpq-dev is needed to build psycopg-c +# mariadb-connector-c-dev is needed to build mariadb-connector RUN apk add --no-cache \ gcc \ git \ + libffi-dev \ + libpq-dev \ mariadb-connector-c-dev \ - musl-dev \ - libffi-dev + musl-dev RUN pip install poetry @@ -42,6 +50,11 @@ ENV POETRY_NO_INTERACTION=1 \ WORKDIR /src COPY ./pyproject.toml ./poetry.lock /src/ +RUN poetry install --no-ansi --no-cache --only main + + +FROM backend-build AS backend-dev-build + RUN poetry install --no-ansi --no-cache @@ -66,6 +79,23 @@ RUN git clone --recursive --branch "${RALIBRETRO_VERSION}" --depth 1 https://git make HAVE_CHD=1 -f ./Makefile.RAHasher +FROM alpine:${ALPINE_VERSION} AS emulator-stage + +RUN apk add --no-cache \ + 7zip \ + wget + +ARG EMULATORJS_VERSION=4.2.0 +RUN wget "https://github.com/EmulatorJS/EmulatorJS/releases/download/v${EMULATORJS_VERSION}/${EMULATORJS_VERSION}.7z" && \ + 7z x -y "${EMULATORJS_VERSION}.7z" -o/emulatorjs && \ + rm -rf "${EMULATORJS_VERSION}.7z"; + +ARG RUFFLE_VERSION=nightly-2024-12-28 +ARG RUFFLE_FILE=ruffle-nightly-2024_12_28-web-selfhosted.zip +RUN wget "https://github.com/ruffle-rs/ruffle/releases/download/${RUFFLE_VERSION}/${RUFFLE_FILE}" && \ + unzip -o "${RUFFLE_FILE}" -d /ruffle && \ + rm -f "${RUFFLE_FILE}"; + FROM alpine:${ALPINE_VERSION} AS nginx-build RUN apk add --no-cache \ @@ -99,31 +129,32 @@ RUN git clone https://github.com/evanmiller/mod_zip.git && \ FROM nginx:${NGINX_VERSION}-alpine${ALPINE_VERSION} AS production-stage ARG WEBSERVER_FOLDER=/var/www/html -COPY --from=rahasher-build /RALibretro/bin64/RAHasher /usr/bin/RAHasher -COPY --from=nginx-build ./nginx/objs/ngx_http_zip_module.so /usr/lib/nginx/modules/ - -COPY --from=front-build-stage /front/dist ${WEBSERVER_FOLDER} -COPY ./frontend/assets/default ${WEBSERVER_FOLDER}/assets/default -COPY ./frontend/assets/emulatorjs ${WEBSERVER_FOLDER}/assets/emulatorjs -COPY ./frontend/assets/ruffle ${WEBSERVER_FOLDER}/assets/ruffle -COPY ./frontend/assets/scrappers ${WEBSERVER_FOLDER}/assets/scrappers -COPY ./frontend/assets/platforms ${WEBSERVER_FOLDER}/assets/platforms -COPY ./frontend/assets/webrcade/feed ${WEBSERVER_FOLDER}/assets/webrcade/feed -RUN mkdir -p ${WEBSERVER_FOLDER}/assets/romm && \ - ln -s /romm/resources ${WEBSERVER_FOLDER}/assets/romm/resources && \ - ln -s /romm/assets ${WEBSERVER_FOLDER}/assets/romm/assets - # Install required packages and dependencies RUN apk add --no-cache \ bash \ libmagic \ mariadb-connector-c \ + libpq \ p7zip \ python3 \ - tini \ tzdata \ valkey +COPY --from=rahasher-build /RALibretro/bin64/RAHasher /usr/bin/RAHasher +COPY --from=nginx-build ./nginx/objs/ngx_http_zip_module.so /usr/lib/nginx/modules/ +COPY --from=frontend-build /front/dist ${WEBSERVER_FOLDER} + +COPY ./frontend/assets/dashboard-icons ${WEBSERVER_FOLDER}/assets/dashboard-icons +COPY ./frontend/assets/default ${WEBSERVER_FOLDER}/assets/default +COPY ./frontend/assets/platforms ${WEBSERVER_FOLDER}/assets/platforms +COPY ./frontend/assets/scrappers ${WEBSERVER_FOLDER}/assets/scrappers +COPY ./frontend/assets/webrcade/feed ${WEBSERVER_FOLDER}/assets/webrcade/feed +COPY ./frontend/assets/emulatorjs ${WEBSERVER_FOLDER}/assets/emulatorjs +COPY ./frontend/assets/ruffle ${WEBSERVER_FOLDER}/assets/ruffle +RUN mkdir -p ${WEBSERVER_FOLDER}/assets/romm && \ + ln -s /romm/resources ${WEBSERVER_FOLDER}/assets/romm/resources && \ + ln -s /romm/assets ${WEBSERVER_FOLDER}/assets/romm/assets + COPY ./backend /backend # Setup init script and config files @@ -136,13 +167,14 @@ RUN addgroup -g 1000 -S romm && adduser -u 1000 -D -S -G romm romm && \ mkdir /romm /redis-data && chown romm:romm /romm /redis-data -FROM scratch AS final-stage +FROM scratch AS slim-image COPY --from=production-stage / / COPY --from=backend-build /src/.venv /src/.venv # Fix virtualenv link to python binary RUN ln -sf "$(which python)" /src/.venv/bin/python + ENV PATH="/src/.venv/bin:${PATH}" # Declare the supported volumes @@ -152,5 +184,23 @@ VOLUME ["/romm/resources", "/romm/library", "/romm/assets", "/romm/config", "/re EXPOSE 8080 6379/tcp WORKDIR /romm -ENTRYPOINT ["/sbin/tini", "--"] +ENTRYPOINT ["/docker-entrypoint.sh"] CMD ["/init"] + + +FROM slim-image AS full-image +ARG WEBSERVER_FOLDER=/var/www/html +COPY --from=emulator-stage /emulatorjs ${WEBSERVER_FOLDER}/assets/emulatorjs +COPY --from=emulator-stage /ruffle ${WEBSERVER_FOLDER}/assets/ruffle + + +FROM slim-image AS dev-slim +COPY --from=backend-dev-build /src/.venv /src/.venv +# Fix virtualenv link to python binary +RUN ln -sf "$(which python)" /src/.venv/bin/python + + +FROM full-image AS dev-full +COPY --from=backend-dev-build /src/.venv /src/.venv +# Fix virtualenv link to python binary +RUN ln -sf "$(which python)" /src/.venv/bin/python diff --git a/docker/init_scripts/docker-entrypoint.sh b/docker/init_scripts/docker-entrypoint.sh new file mode 100755 index 000000000..387e22df9 --- /dev/null +++ b/docker/init_scripts/docker-entrypoint.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash + +# Load environment variables from variants with a _FILE suffix. +# The following logic reads variables with a _FILE suffix and +# loads the contents of the file specified in the variable +# into the variable without the suffix. +for var_name in $(printenv | cut -d= -f1 | grep "_FILE$" || true); do + # If variable is empty, skip. + if [[ -z ${!var_name} ]]; then + continue + fi + + var_name_no_suffix=${var_name%"_FILE"} + + # If the variable without the suffix is already set, raise an error. + if [[ -n ${!var_name_no_suffix} ]]; then + echo "ERROR: Both ${var_name_no_suffix} and ${var_name} are set (but are exclusive)" >&2 + exit 1 + fi + + file_path="${!var_name}" + + # If file does not exist, raise an error. + if [[ ! -f ${file_path} ]]; then + echo "ERROR: File ${file_path} from ${var_name} does not exist" >&2 + exit 1 + fi + + echo "Setting ${var_name_no_suffix} from ${var_name} at ${file_path}" + export "${var_name_no_suffix}"="$(cat "${file_path}")" + + # Unset the _FILE variable. + unset "${var_name}" +done + +exec "$@" diff --git a/docker/init_scripts/init b/docker/init_scripts/init index 14a02105f..62e137ffa 100755 --- a/docker/init_scripts/init +++ b/docker/init_scripts/init @@ -115,19 +115,43 @@ watchdog_process_pid() { fi } +stop_process_pid() { + PROCESS=$1 + if [[ -f "/tmp/${PROCESS}.pid" ]]; then + PID=$(cat "/tmp/${PROCESS}.pid") || true + if [[ -d "/proc/${PID}" ]]; then + info_log "stopping ${PROCESS}" + kill "${PID}" || true + # wait for process exit + while [[ -e "/proc/${PID}" ]]; do sleep 0.1; done + fi + fi +} + +shutdown() { + # shutdown in reverse order + stop_process_pid scheduler + stop_process_pid worker + stop_process_pid watcher + stop_process_pid nginx + stop_process_pid gunicorn + stop_process_pid valkey-server +} + # switch to backend directory cd /backend || { error_log "/backend directory doesn't seem to exist"; } info_log "Starting up, please wait..." +# setup trap handler +exited=0 +trap 'exited=1 && shutdown' SIGINT SIGTERM EXIT + # clear any leftover PID files rm /tmp/*.pid -f # function definition done, lets start our main loop -while true; do - # check for died processes every 5 seconds - sleep 5 - +while ! ((exited)); do # Start Valkey server if we dont have a corresponding PID file # and REDIS_HOST is not set (which would mean we're using an external Redis/Valkey) if [[ -z ${REDIS_HOST:=""} ]]; then @@ -138,7 +162,7 @@ while true; do # but only if it was not successful since the last full docker container start if [[ ${ALEMBIC_SUCCESS:="false"} == "false" ]]; then if alembic upgrade head; then - debug_log "database schema migrations suceeded" + debug_log "database schema migrations succeeded" ALEMBIC_SUCCESS="true" else error_log "Something went horribly wrong with our database" @@ -165,4 +189,7 @@ while true; do watchdog_process_pid python worker # Start scheduler if we dont have a corresponding PID file watchdog_process_pid python scheduler + + # check for died processes every 5 seconds + sleep 5 done diff --git a/env.template b/env.template index 61f12eafd..ad2075084 100644 --- a/env.template +++ b/env.template @@ -26,8 +26,21 @@ DB_ROOT_PASSWD= REDIS_HOST=127.0.0.1 REDIS_PORT=6379 +# Authentik +POSTGRES_DB=authentik +POSTGRES_USER=authentik +POSTGRES_PASSWORD= +AUTHENTIK_SECRET_KEY= +AUTHENTIK_BOOTSTRAP_PASSWORD= + # Authentication ROMM_AUTH_SECRET_KEY= +OIDC_ENABLED= +OIDC_PROVIDER= +OIDC_CLIENT_ID= +OIDC_CLIENT_SECRET= +OIDC_REDIRECT_URI= +OIDC_SERVER_APPLICATION_URL= # Filesystem watcher (optional) ENABLE_RESCAN_ON_FILESYSTEM_CHANGE=true diff --git a/frontend/assets/dashboard-icons/LICENSE b/frontend/assets/dashboard-icons/LICENSE new file mode 100644 index 000000000..9c70098bf --- /dev/null +++ b/frontend/assets/dashboard-icons/LICENSE @@ -0,0 +1,3 @@ +**Icons and Assets** + +Unless otherwise indicated, all images and assets in this folder, including product names, trademarks, and registered trademarks, are the property of their respective owners. These images and assets are used for identification purposes only and their use does not imply endorsement. diff --git a/frontend/assets/dashboard-icons/auth0.png b/frontend/assets/dashboard-icons/auth0.png new file mode 100644 index 000000000..15c36ccbc Binary files /dev/null and b/frontend/assets/dashboard-icons/auth0.png differ diff --git a/frontend/assets/dashboard-icons/authelia.png b/frontend/assets/dashboard-icons/authelia.png new file mode 100644 index 000000000..f55f51814 Binary files /dev/null and b/frontend/assets/dashboard-icons/authelia.png differ diff --git a/frontend/assets/dashboard-icons/authentik.png b/frontend/assets/dashboard-icons/authentik.png new file mode 100644 index 000000000..211b671c5 Binary files /dev/null and b/frontend/assets/dashboard-icons/authentik.png differ diff --git a/frontend/assets/dashboard-icons/aws.png b/frontend/assets/dashboard-icons/aws.png new file mode 100644 index 000000000..3ef9f2e67 Binary files /dev/null and b/frontend/assets/dashboard-icons/aws.png differ diff --git a/frontend/assets/dashboard-icons/azure.png b/frontend/assets/dashboard-icons/azure.png new file mode 100644 index 000000000..b279db671 Binary files /dev/null and b/frontend/assets/dashboard-icons/azure.png differ diff --git a/frontend/assets/dashboard-icons/firebase.png b/frontend/assets/dashboard-icons/firebase.png new file mode 100644 index 000000000..fb9d6421d Binary files /dev/null and b/frontend/assets/dashboard-icons/firebase.png differ diff --git a/frontend/assets/dashboard-icons/google.png b/frontend/assets/dashboard-icons/google.png new file mode 100644 index 000000000..ed78b78d2 Binary files /dev/null and b/frontend/assets/dashboard-icons/google.png differ diff --git a/frontend/assets/dashboard-icons/hydra.png b/frontend/assets/dashboard-icons/hydra.png new file mode 100644 index 000000000..4a75b75ba Binary files /dev/null and b/frontend/assets/dashboard-icons/hydra.png differ diff --git a/frontend/assets/dashboard-icons/keycloak.png b/frontend/assets/dashboard-icons/keycloak.png new file mode 100644 index 000000000..9e040dbb1 Binary files /dev/null and b/frontend/assets/dashboard-icons/keycloak.png differ diff --git a/frontend/assets/dashboard-icons/okta.png b/frontend/assets/dashboard-icons/okta.png new file mode 100644 index 000000000..5c69bc276 Binary files /dev/null and b/frontend/assets/dashboard-icons/okta.png differ diff --git a/frontend/assets/dashboard-icons/ping.png b/frontend/assets/dashboard-icons/ping.png new file mode 100644 index 000000000..db87cbb4f Binary files /dev/null and b/frontend/assets/dashboard-icons/ping.png differ diff --git a/frontend/assets/dashboard-icons/zitadel.png b/frontend/assets/dashboard-icons/zitadel.png new file mode 100644 index 000000000..843035233 Binary files /dev/null and b/frontend/assets/dashboard-icons/zitadel.png differ diff --git a/frontend/assets/emulatorjs/loading_black.png b/frontend/assets/emulatorjs/loading_black.png index 85d7b7cd0..757af49b3 100644 Binary files a/frontend/assets/emulatorjs/loading_black.png and b/frontend/assets/emulatorjs/loading_black.png differ diff --git a/frontend/assets/emulatorjs/powered_by_emulatorjs.png b/frontend/assets/emulatorjs/powered_by_emulatorjs.png index 8888ee01f..70aa1f801 100644 Binary files a/frontend/assets/emulatorjs/powered_by_emulatorjs.png and b/frontend/assets/emulatorjs/powered_by_emulatorjs.png differ diff --git a/frontend/assets/isotipo.png b/frontend/assets/isotipo.png index bd297392d..e8dc48e5e 100644 Binary files a/frontend/assets/isotipo.png and b/frontend/assets/isotipo.png differ diff --git a/frontend/assets/login_bg.png b/frontend/assets/login_bg.png index 7300e6499..bbfad6cdd 100644 Binary files a/frontend/assets/login_bg.png and b/frontend/assets/login_bg.png differ diff --git a/frontend/assets/platforms/3do.ico b/frontend/assets/platforms/3do.ico index 06c289f36..d97f073b5 100644 Binary files a/frontend/assets/platforms/3do.ico and b/frontend/assets/platforms/3do.ico differ diff --git a/frontend/assets/platforms/3ds.ico b/frontend/assets/platforms/3ds.ico index 1822d7231..e5e76de01 100644 Binary files a/frontend/assets/platforms/3ds.ico and b/frontend/assets/platforms/3ds.ico differ diff --git a/frontend/assets/platforms/GamePark - GP32.ico b/frontend/assets/platforms/GamePark - GP32.ico index d419c2308..984e3b45e 100644 Binary files a/frontend/assets/platforms/GamePark - GP32.ico and b/frontend/assets/platforms/GamePark - GP32.ico differ diff --git a/frontend/assets/platforms/acpc.ico b/frontend/assets/platforms/acpc.ico index bc40324c9..bf9c8998c 100644 Binary files a/frontend/assets/platforms/acpc.ico and b/frontend/assets/platforms/acpc.ico differ diff --git a/frontend/assets/platforms/adventure-vision.ico b/frontend/assets/platforms/adventure-vision.ico index 76a7fe2b9..06145c506 100644 Binary files a/frontend/assets/platforms/adventure-vision.ico and b/frontend/assets/platforms/adventure-vision.ico differ diff --git a/frontend/assets/platforms/amiga.ico b/frontend/assets/platforms/amiga.ico index a02a6760e..f472a1cd8 100644 Binary files a/frontend/assets/platforms/amiga.ico and b/frontend/assets/platforms/amiga.ico differ diff --git a/frontend/assets/platforms/amiibo.ico b/frontend/assets/platforms/amiibo.ico index 54b481839..8b6f60d53 100644 Binary files a/frontend/assets/platforms/amiibo.ico and b/frontend/assets/platforms/amiibo.ico differ diff --git a/frontend/assets/platforms/android.ico b/frontend/assets/platforms/android.ico index 2464b2fd7..f3f9d9b82 100644 Binary files a/frontend/assets/platforms/android.ico and b/frontend/assets/platforms/android.ico differ diff --git a/frontend/assets/platforms/apple-pippin.ico b/frontend/assets/platforms/apple-pippin.ico index 0b5fe476e..2d495bfa6 100644 Binary files a/frontend/assets/platforms/apple-pippin.ico and b/frontend/assets/platforms/apple-pippin.ico differ diff --git a/frontend/assets/platforms/appleii.ico b/frontend/assets/platforms/appleii.ico index fe5dec792..af54bc1b1 100644 Binary files a/frontend/assets/platforms/appleii.ico and b/frontend/assets/platforms/appleii.ico differ diff --git a/frontend/assets/platforms/arcade.ico b/frontend/assets/platforms/arcade.ico index 98f0a70ff..ebf4e857b 100644 Binary files a/frontend/assets/platforms/arcade.ico and b/frontend/assets/platforms/arcade.ico differ diff --git a/frontend/assets/platforms/arcadia-2001.ico b/frontend/assets/platforms/arcadia-2001.ico index 7df15f43d..11c161972 100644 Binary files a/frontend/assets/platforms/arcadia-2001.ico and b/frontend/assets/platforms/arcadia-2001.ico differ diff --git a/frontend/assets/platforms/astrocade.ico b/frontend/assets/platforms/astrocade.ico index 816c16e79..8fbbfb52f 100644 Binary files a/frontend/assets/platforms/astrocade.ico and b/frontend/assets/platforms/astrocade.ico differ diff --git a/frontend/assets/platforms/atari-jaguar-cd.ico b/frontend/assets/platforms/atari-jaguar-cd.ico index 8deb111b8..4957c4a0c 100644 Binary files a/frontend/assets/platforms/atari-jaguar-cd.ico and b/frontend/assets/platforms/atari-jaguar-cd.ico differ diff --git a/frontend/assets/platforms/atari-st.ico b/frontend/assets/platforms/atari-st.ico index 1339e8979..69e839be2 100644 Binary files a/frontend/assets/platforms/atari-st.ico and b/frontend/assets/platforms/atari-st.ico differ diff --git a/frontend/assets/platforms/atari.ico b/frontend/assets/platforms/atari.ico index 3629bd06c..042375e1c 100644 Binary files a/frontend/assets/platforms/atari.ico and b/frontend/assets/platforms/atari.ico differ diff --git a/frontend/assets/platforms/atari2600.ico b/frontend/assets/platforms/atari2600.ico index 3629bd06c..042375e1c 100644 Binary files a/frontend/assets/platforms/atari2600.ico and b/frontend/assets/platforms/atari2600.ico differ diff --git a/frontend/assets/platforms/atari5200.ico b/frontend/assets/platforms/atari5200.ico index 2f0fb327a..b56fd3205 100644 Binary files a/frontend/assets/platforms/atari5200.ico and b/frontend/assets/platforms/atari5200.ico differ diff --git a/frontend/assets/platforms/atari7800.ico b/frontend/assets/platforms/atari7800.ico index 5f785fb62..469dd3bd5 100644 Binary files a/frontend/assets/platforms/atari7800.ico and b/frontend/assets/platforms/atari7800.ico differ diff --git a/frontend/assets/platforms/atari8bit.ico b/frontend/assets/platforms/atari8bit.ico index f0a02e5d3..50a387739 100644 Binary files a/frontend/assets/platforms/atari8bit.ico and b/frontend/assets/platforms/atari8bit.ico differ diff --git a/frontend/assets/platforms/atomiswave.ico b/frontend/assets/platforms/atomiswave.ico index 313b4998e..98505c382 100644 Binary files a/frontend/assets/platforms/atomiswave.ico and b/frontend/assets/platforms/atomiswave.ico differ diff --git a/frontend/assets/platforms/bbcmicro.ico b/frontend/assets/platforms/bbcmicro.ico index 7334b80fe..a37c2f49a 100644 Binary files a/frontend/assets/platforms/bbcmicro.ico and b/frontend/assets/platforms/bbcmicro.ico differ diff --git a/frontend/assets/platforms/beena.ico b/frontend/assets/platforms/beena.ico index 605533982..e7dd47cf9 100644 Binary files a/frontend/assets/platforms/beena.ico and b/frontend/assets/platforms/beena.ico differ diff --git a/frontend/assets/platforms/c-plus-4.ico b/frontend/assets/platforms/c-plus-4.ico index 99c389f8c..f9de12e97 100644 Binary files a/frontend/assets/platforms/c-plus-4.ico and b/frontend/assets/platforms/c-plus-4.ico differ diff --git a/frontend/assets/platforms/c64.ico b/frontend/assets/platforms/c64.ico index 785a08725..41cc0d991 100644 Binary files a/frontend/assets/platforms/c64.ico and b/frontend/assets/platforms/c64.ico differ diff --git a/frontend/assets/platforms/casio-loopy.ico b/frontend/assets/platforms/casio-loopy.ico index 7595faac4..e272074a1 100644 Binary files a/frontend/assets/platforms/casio-loopy.ico and b/frontend/assets/platforms/casio-loopy.ico differ diff --git a/frontend/assets/platforms/casio-pv-1000.ico b/frontend/assets/platforms/casio-pv-1000.ico index ff3a22cf1..e0584bbb3 100644 Binary files a/frontend/assets/platforms/casio-pv-1000.ico and b/frontend/assets/platforms/casio-pv-1000.ico differ diff --git a/frontend/assets/platforms/chihiro.ico b/frontend/assets/platforms/chihiro.ico index 7b5babb73..121c22ee2 100644 Binary files a/frontend/assets/platforms/chihiro.ico and b/frontend/assets/platforms/chihiro.ico differ diff --git a/frontend/assets/platforms/coleco.ico b/frontend/assets/platforms/coleco.ico index 20ed9be51..7ec1b87f2 100644 Binary files a/frontend/assets/platforms/coleco.ico and b/frontend/assets/platforms/coleco.ico differ diff --git a/frontend/assets/platforms/colecovision.ico b/frontend/assets/platforms/colecovision.ico index 20ed9be51..7ec1b87f2 100644 Binary files a/frontend/assets/platforms/colecovision.ico and b/frontend/assets/platforms/colecovision.ico differ diff --git a/frontend/assets/platforms/cps1.ico b/frontend/assets/platforms/cps1.ico index d58b6a638..4d5ba8c8c 100644 Binary files a/frontend/assets/platforms/cps1.ico and b/frontend/assets/platforms/cps1.ico differ diff --git a/frontend/assets/platforms/cps2.ico b/frontend/assets/platforms/cps2.ico index 5fd476185..7a00a542c 100644 Binary files a/frontend/assets/platforms/cps2.ico and b/frontend/assets/platforms/cps2.ico differ diff --git a/frontend/assets/platforms/cps3.ico b/frontend/assets/platforms/cps3.ico index 4f3848e1c..1b7e1a540 100644 Binary files a/frontend/assets/platforms/cps3.ico and b/frontend/assets/platforms/cps3.ico differ diff --git a/frontend/assets/platforms/creativision.ico b/frontend/assets/platforms/creativision.ico index e5be1c702..fe2e6c26d 100644 Binary files a/frontend/assets/platforms/creativision.ico and b/frontend/assets/platforms/creativision.ico differ diff --git a/frontend/assets/platforms/daphne.ico b/frontend/assets/platforms/daphne.ico index aeaebbade..58dead460 100644 Binary files a/frontend/assets/platforms/daphne.ico and b/frontend/assets/platforms/daphne.ico differ diff --git a/frontend/assets/platforms/dc.ico b/frontend/assets/platforms/dc.ico index f1e9e1fcf..0da956dbf 100644 Binary files a/frontend/assets/platforms/dc.ico and b/frontend/assets/platforms/dc.ico differ diff --git a/frontend/assets/platforms/default.ico b/frontend/assets/platforms/default.ico index 86bda25c8..9cb86982f 100644 Binary files a/frontend/assets/platforms/default.ico and b/frontend/assets/platforms/default.ico differ diff --git a/frontend/assets/platforms/doom.ico b/frontend/assets/platforms/doom.ico index 70741a089..66f59adb5 100644 Binary files a/frontend/assets/platforms/doom.ico and b/frontend/assets/platforms/doom.ico differ diff --git a/frontend/assets/platforms/dos.ico b/frontend/assets/platforms/dos.ico index badcb3fcb..f177aa675 100644 Binary files a/frontend/assets/platforms/dos.ico and b/frontend/assets/platforms/dos.ico differ diff --git a/frontend/assets/platforms/e-reader.ico b/frontend/assets/platforms/e-reader.ico index c4f32b635..d3a7f761a 100644 Binary files a/frontend/assets/platforms/e-reader.ico and b/frontend/assets/platforms/e-reader.ico differ diff --git a/frontend/assets/platforms/epoch-super-cassette-vision.ico b/frontend/assets/platforms/epoch-super-cassette-vision.ico index 19b0930f6..b24915aab 100644 Binary files a/frontend/assets/platforms/epoch-super-cassette-vision.ico and b/frontend/assets/platforms/epoch-super-cassette-vision.ico differ diff --git a/frontend/assets/platforms/fairchild-channel-f.ico b/frontend/assets/platforms/fairchild-channel-f.ico index 6d09f42a3..3b4294107 100644 Binary files a/frontend/assets/platforms/fairchild-channel-f.ico and b/frontend/assets/platforms/fairchild-channel-f.ico differ diff --git a/frontend/assets/platforms/fairchild.ico b/frontend/assets/platforms/fairchild.ico index 27360b4cd..2038a02e1 100644 Binary files a/frontend/assets/platforms/fairchild.ico and b/frontend/assets/platforms/fairchild.ico differ diff --git a/frontend/assets/platforms/famicom.ico b/frontend/assets/platforms/famicom.ico index 0ad68ab09..f6acb0b83 100644 Binary files a/frontend/assets/platforms/famicom.ico and b/frontend/assets/platforms/famicom.ico differ diff --git a/frontend/assets/platforms/fba2012.ico b/frontend/assets/platforms/fba2012.ico index ff7e0316a..e550cb269 100644 Binary files a/frontend/assets/platforms/fba2012.ico and b/frontend/assets/platforms/fba2012.ico differ diff --git a/frontend/assets/platforms/fbneo.ico b/frontend/assets/platforms/fbneo.ico index ff7e0316a..e550cb269 100644 Binary files a/frontend/assets/platforms/fbneo.ico and b/frontend/assets/platforms/fbneo.ico differ diff --git a/frontend/assets/platforms/fds.ico b/frontend/assets/platforms/fds.ico index 0ad68ab09..f6acb0b83 100644 Binary files a/frontend/assets/platforms/fds.ico and b/frontend/assets/platforms/fds.ico differ diff --git a/frontend/assets/platforms/flash.ico b/frontend/assets/platforms/flash.ico index 13774ce1e..319b0b162 100644 Binary files a/frontend/assets/platforms/flash.ico and b/frontend/assets/platforms/flash.ico differ diff --git a/frontend/assets/platforms/g-and-w.ico b/frontend/assets/platforms/g-and-w.ico index e32ddd906..0d47a8261 100644 Binary files a/frontend/assets/platforms/g-and-w.ico and b/frontend/assets/platforms/g-and-w.ico differ diff --git a/frontend/assets/platforms/game-dot-com.ico b/frontend/assets/platforms/game-dot-com.ico index 5ad41007e..9e348fce2 100644 Binary files a/frontend/assets/platforms/game-dot-com.ico and b/frontend/assets/platforms/game-dot-com.ico differ diff --git a/frontend/assets/platforms/game-master.ico b/frontend/assets/platforms/game-master.ico index 0c31bbb7c..fba6c97fb 100644 Binary files a/frontend/assets/platforms/game-master.ico and b/frontend/assets/platforms/game-master.ico differ diff --git a/frontend/assets/platforms/gamegear.ico b/frontend/assets/platforms/gamegear.ico index 456949389..6a70e2543 100644 Binary files a/frontend/assets/platforms/gamegear.ico and b/frontend/assets/platforms/gamegear.ico differ diff --git a/frontend/assets/platforms/gb.ico b/frontend/assets/platforms/gb.ico index 9ad74561b..a3685e013 100644 Binary files a/frontend/assets/platforms/gb.ico and b/frontend/assets/platforms/gb.ico differ diff --git a/frontend/assets/platforms/gba.ico b/frontend/assets/platforms/gba.ico index 046b56ee9..e8bb3662b 100644 Binary files a/frontend/assets/platforms/gba.ico and b/frontend/assets/platforms/gba.ico differ diff --git a/frontend/assets/platforms/gbc.ico b/frontend/assets/platforms/gbc.ico index bd795fbc1..921cbe997 100644 Binary files a/frontend/assets/platforms/gbc.ico and b/frontend/assets/platforms/gbc.ico differ diff --git a/frontend/assets/platforms/genesis-slash-megadrive.ico b/frontend/assets/platforms/genesis-slash-megadrive.ico index cee06625f..cd2c5de6c 100644 Binary files a/frontend/assets/platforms/genesis-slash-megadrive.ico and b/frontend/assets/platforms/genesis-slash-megadrive.ico differ diff --git a/frontend/assets/platforms/gizmondo.ico b/frontend/assets/platforms/gizmondo.ico index 9fe39ba49..2db7cfe26 100644 Binary files a/frontend/assets/platforms/gizmondo.ico and b/frontend/assets/platforms/gizmondo.ico differ diff --git a/frontend/assets/platforms/gp32.ico b/frontend/assets/platforms/gp32.ico index de986985f..96a07792d 100644 Binary files a/frontend/assets/platforms/gp32.ico and b/frontend/assets/platforms/gp32.ico differ diff --git a/frontend/assets/platforms/hyperscan.ico b/frontend/assets/platforms/hyperscan.ico index 07d6ca47d..4f6ff9f58 100644 Binary files a/frontend/assets/platforms/hyperscan.ico and b/frontend/assets/platforms/hyperscan.ico differ diff --git a/frontend/assets/platforms/intellivision.ico b/frontend/assets/platforms/intellivision.ico index 2939b1feb..eddb18a05 100644 Binary files a/frontend/assets/platforms/intellivision.ico and b/frontend/assets/platforms/intellivision.ico differ diff --git a/frontend/assets/platforms/ios.ico b/frontend/assets/platforms/ios.ico index 1cca0a839..ac3a22909 100644 Binary files a/frontend/assets/platforms/ios.ico and b/frontend/assets/platforms/ios.ico differ diff --git a/frontend/assets/platforms/ique-player.ico b/frontend/assets/platforms/ique-player.ico index ea869f57d..f7fee2540 100644 Binary files a/frontend/assets/platforms/ique-player.ico and b/frontend/assets/platforms/ique-player.ico differ diff --git a/frontend/assets/platforms/j2me.ico b/frontend/assets/platforms/j2me.ico index 840277a2e..472be26ae 100644 Binary files a/frontend/assets/platforms/j2me.ico and b/frontend/assets/platforms/j2me.ico differ diff --git a/frontend/assets/platforms/jaguar.ico b/frontend/assets/platforms/jaguar.ico index fdb97bc7a..a6ee3656f 100644 Binary files a/frontend/assets/platforms/jaguar.ico and b/frontend/assets/platforms/jaguar.ico differ diff --git a/frontend/assets/platforms/java.ico b/frontend/assets/platforms/java.ico index 840277a2e..472be26ae 100644 Binary files a/frontend/assets/platforms/java.ico and b/frontend/assets/platforms/java.ico differ diff --git a/frontend/assets/platforms/leappad.ico b/frontend/assets/platforms/leappad.ico index 0080d3df6..c7ba3d421 100644 Binary files a/frontend/assets/platforms/leappad.ico and b/frontend/assets/platforms/leappad.ico differ diff --git a/frontend/assets/platforms/leapster.ico b/frontend/assets/platforms/leapster.ico index 1b933276b..7a60377cf 100644 Binary files a/frontend/assets/platforms/leapster.ico and b/frontend/assets/platforms/leapster.ico differ diff --git a/frontend/assets/platforms/loopy.ico b/frontend/assets/platforms/loopy.ico index 7595faac4..e272074a1 100644 Binary files a/frontend/assets/platforms/loopy.ico and b/frontend/assets/platforms/loopy.ico differ diff --git a/frontend/assets/platforms/lynx.ico b/frontend/assets/platforms/lynx.ico index bdc20d86b..6f5a80869 100644 Binary files a/frontend/assets/platforms/lynx.ico and b/frontend/assets/platforms/lynx.ico differ diff --git a/frontend/assets/platforms/mac.ico b/frontend/assets/platforms/mac.ico index 3c2451181..d53594b0c 100644 Binary files a/frontend/assets/platforms/mac.ico and b/frontend/assets/platforms/mac.ico differ diff --git a/frontend/assets/platforms/md.ico b/frontend/assets/platforms/md.ico index e2d5a7ad2..491732466 100644 Binary files a/frontend/assets/platforms/md.ico and b/frontend/assets/platforms/md.ico differ diff --git a/frontend/assets/platforms/megaduck.ico b/frontend/assets/platforms/megaduck.ico index 265af75ff..6ce34a494 100644 Binary files a/frontend/assets/platforms/megaduck.ico and b/frontend/assets/platforms/megaduck.ico differ diff --git a/frontend/assets/platforms/ms.ico b/frontend/assets/platforms/ms.ico index a64f3177c..31bac32dc 100644 Binary files a/frontend/assets/platforms/ms.ico and b/frontend/assets/platforms/ms.ico differ diff --git a/frontend/assets/platforms/msx.ico b/frontend/assets/platforms/msx.ico index fa05214e6..46bea3057 100644 Binary files a/frontend/assets/platforms/msx.ico and b/frontend/assets/platforms/msx.ico differ diff --git a/frontend/assets/platforms/msx2.ico b/frontend/assets/platforms/msx2.ico index 6817dae91..db9463e24 100644 Binary files a/frontend/assets/platforms/msx2.ico and b/frontend/assets/platforms/msx2.ico differ diff --git a/frontend/assets/platforms/n64.ico b/frontend/assets/platforms/n64.ico index 25458be47..7fef674b5 100644 Binary files a/frontend/assets/platforms/n64.ico and b/frontend/assets/platforms/n64.ico differ diff --git a/frontend/assets/platforms/nds.ico b/frontend/assets/platforms/nds.ico index 2197573b9..b1eb02466 100644 Binary files a/frontend/assets/platforms/nds.ico and b/frontend/assets/platforms/nds.ico differ diff --git a/frontend/assets/platforms/neo-geo-cd.ico b/frontend/assets/platforms/neo-geo-cd.ico index 0a1d94fb1..f4dedd944 100644 Binary files a/frontend/assets/platforms/neo-geo-cd.ico and b/frontend/assets/platforms/neo-geo-cd.ico differ diff --git a/frontend/assets/platforms/neo-geo-pocket-color.ico b/frontend/assets/platforms/neo-geo-pocket-color.ico index 308201ebd..dbc397104 100644 Binary files a/frontend/assets/platforms/neo-geo-pocket-color.ico and b/frontend/assets/platforms/neo-geo-pocket-color.ico differ diff --git a/frontend/assets/platforms/neo-geo-pocket.ico b/frontend/assets/platforms/neo-geo-pocket.ico index eb43fd7cf..e969acbc5 100644 Binary files a/frontend/assets/platforms/neo-geo-pocket.ico and b/frontend/assets/platforms/neo-geo-pocket.ico differ diff --git a/frontend/assets/platforms/neocd.ico b/frontend/assets/platforms/neocd.ico index 0a1d94fb1..f4dedd944 100644 Binary files a/frontend/assets/platforms/neocd.ico and b/frontend/assets/platforms/neocd.ico differ diff --git a/frontend/assets/platforms/neogeoaes.ico b/frontend/assets/platforms/neogeoaes.ico index 8943904d5..b64f98f57 100644 Binary files a/frontend/assets/platforms/neogeoaes.ico and b/frontend/assets/platforms/neogeoaes.ico differ diff --git a/frontend/assets/platforms/neogeomvs.ico b/frontend/assets/platforms/neogeomvs.ico index 8943904d5..b64f98f57 100644 Binary files a/frontend/assets/platforms/neogeomvs.ico and b/frontend/assets/platforms/neogeomvs.ico differ diff --git a/frontend/assets/platforms/nes.ico b/frontend/assets/platforms/nes.ico index e6fb0217e..9ab6800a5 100644 Binary files a/frontend/assets/platforms/nes.ico and b/frontend/assets/platforms/nes.ico differ diff --git a/frontend/assets/platforms/new-nintendo-3ds.ico b/frontend/assets/platforms/new-nintendo-3ds.ico index 4a55e55e8..ff32f1e0f 100644 Binary files a/frontend/assets/platforms/new-nintendo-3ds.ico and b/frontend/assets/platforms/new-nintendo-3ds.ico differ diff --git a/frontend/assets/platforms/ngage.ico b/frontend/assets/platforms/ngage.ico index bbc39f2da..3667eda2d 100644 Binary files a/frontend/assets/platforms/ngage.ico and b/frontend/assets/platforms/ngage.ico differ diff --git a/frontend/assets/platforms/ngc.ico b/frontend/assets/platforms/ngc.ico index 034c7709c..fa7a6de80 100644 Binary files a/frontend/assets/platforms/ngc.ico and b/frontend/assets/platforms/ngc.ico differ diff --git a/frontend/assets/platforms/ngp.ico b/frontend/assets/platforms/ngp.ico index eb43fd7cf..e969acbc5 100644 Binary files a/frontend/assets/platforms/ngp.ico and b/frontend/assets/platforms/ngp.ico differ diff --git a/frontend/assets/platforms/nintendo-64dd.ico b/frontend/assets/platforms/nintendo-64dd.ico index 134021e03..293c540b2 100644 Binary files a/frontend/assets/platforms/nintendo-64dd.ico and b/frontend/assets/platforms/nintendo-64dd.ico differ diff --git a/frontend/assets/platforms/nintendo-dsi.ico b/frontend/assets/platforms/nintendo-dsi.ico index 6d7bccbf9..1e1d3196f 100644 Binary files a/frontend/assets/platforms/nintendo-dsi.ico and b/frontend/assets/platforms/nintendo-dsi.ico differ diff --git a/frontend/assets/platforms/nuon.ico b/frontend/assets/platforms/nuon.ico index 82f771442..7530b0c4a 100644 Binary files a/frontend/assets/platforms/nuon.ico and b/frontend/assets/platforms/nuon.ico differ diff --git a/frontend/assets/platforms/odyssey--1.ico b/frontend/assets/platforms/odyssey--1.ico index b0fd33a23..2cae7c726 100644 Binary files a/frontend/assets/platforms/odyssey--1.ico and b/frontend/assets/platforms/odyssey--1.ico differ diff --git a/frontend/assets/platforms/odyssey-2-slash-videopac-g7000.ico b/frontend/assets/platforms/odyssey-2-slash-videopac-g7000.ico index b56fc876f..403e12f28 100644 Binary files a/frontend/assets/platforms/odyssey-2-slash-videopac-g7000.ico and b/frontend/assets/platforms/odyssey-2-slash-videopac-g7000.ico differ diff --git a/frontend/assets/platforms/odyssey.ico b/frontend/assets/platforms/odyssey.ico index ed6e31666..4d41f30f4 100644 Binary files a/frontend/assets/platforms/odyssey.ico and b/frontend/assets/platforms/odyssey.ico differ diff --git a/frontend/assets/platforms/pc-98.ico b/frontend/assets/platforms/pc-98.ico index a006abeda..d42ad07a2 100644 Binary files a/frontend/assets/platforms/pc-98.ico and b/frontend/assets/platforms/pc-98.ico differ diff --git a/frontend/assets/platforms/pc-fx.ico b/frontend/assets/platforms/pc-fx.ico index 13419e9c5..70d589fd7 100644 Binary files a/frontend/assets/platforms/pc-fx.ico and b/frontend/assets/platforms/pc-fx.ico differ diff --git a/frontend/assets/platforms/pce.ico b/frontend/assets/platforms/pce.ico index 7a17f7b16..7f4f3dc24 100644 Binary files a/frontend/assets/platforms/pce.ico and b/frontend/assets/platforms/pce.ico differ diff --git a/frontend/assets/platforms/pcecd.ico b/frontend/assets/platforms/pcecd.ico index 07555b46a..3c7642e35 100644 Binary files a/frontend/assets/platforms/pcecd.ico and b/frontend/assets/platforms/pcecd.ico differ diff --git a/frontend/assets/platforms/philips-cd-i.ico b/frontend/assets/platforms/philips-cd-i.ico index cfb192df3..cfb1bd310 100644 Binary files a/frontend/assets/platforms/philips-cd-i.ico and b/frontend/assets/platforms/philips-cd-i.ico differ diff --git a/frontend/assets/platforms/picno.ico b/frontend/assets/platforms/picno.ico index 3ef02407d..877f30326 100644 Binary files a/frontend/assets/platforms/picno.ico and b/frontend/assets/platforms/picno.ico differ diff --git a/frontend/assets/platforms/pico-8.ico b/frontend/assets/platforms/pico-8.ico index e886acaab..00ccf80d4 100644 Binary files a/frontend/assets/platforms/pico-8.ico and b/frontend/assets/platforms/pico-8.ico differ diff --git a/frontend/assets/platforms/pico.ico b/frontend/assets/platforms/pico.ico index e886acaab..00ccf80d4 100644 Binary files a/frontend/assets/platforms/pico.ico and b/frontend/assets/platforms/pico.ico differ diff --git a/frontend/assets/platforms/pinball.ico b/frontend/assets/platforms/pinball.ico index 38d503497..307478547 100644 Binary files a/frontend/assets/platforms/pinball.ico and b/frontend/assets/platforms/pinball.ico differ diff --git a/frontend/assets/platforms/playdia.ico b/frontend/assets/platforms/playdia.ico index da8840433..2700bdd8a 100644 Binary files a/frontend/assets/platforms/playdia.ico and b/frontend/assets/platforms/playdia.ico differ diff --git a/frontend/assets/platforms/pocket-challenge-v2.ico b/frontend/assets/platforms/pocket-challenge-v2.ico index f5f3aca40..c7e74b1d8 100644 Binary files a/frontend/assets/platforms/pocket-challenge-v2.ico and b/frontend/assets/platforms/pocket-challenge-v2.ico differ diff --git a/frontend/assets/platforms/pocket-challenge-w.ico b/frontend/assets/platforms/pocket-challenge-w.ico index e2f0867a3..1ba34e592 100644 Binary files a/frontend/assets/platforms/pocket-challenge-w.ico and b/frontend/assets/platforms/pocket-challenge-w.ico differ diff --git a/frontend/assets/platforms/pocketstation.ico b/frontend/assets/platforms/pocketstation.ico index f0194a35e..588aad6da 100644 Binary files a/frontend/assets/platforms/pocketstation.ico and b/frontend/assets/platforms/pocketstation.ico differ diff --git a/frontend/assets/platforms/pokemon-mini.ico b/frontend/assets/platforms/pokemon-mini.ico index 71c1f31e3..2703f481e 100644 Binary files a/frontend/assets/platforms/pokemon-mini.ico and b/frontend/assets/platforms/pokemon-mini.ico differ diff --git a/frontend/assets/platforms/ps.ico b/frontend/assets/platforms/ps.ico index b50e4eeb5..836b5a3db 100644 Binary files a/frontend/assets/platforms/ps.ico and b/frontend/assets/platforms/ps.ico differ diff --git a/frontend/assets/platforms/ps2.ico b/frontend/assets/platforms/ps2.ico index a979d1074..308e74c79 100644 Binary files a/frontend/assets/platforms/ps2.ico and b/frontend/assets/platforms/ps2.ico differ diff --git a/frontend/assets/platforms/ps3.ico b/frontend/assets/platforms/ps3.ico index 1bdf0e0cf..b29f7f174 100644 Binary files a/frontend/assets/platforms/ps3.ico and b/frontend/assets/platforms/ps3.ico differ diff --git a/frontend/assets/platforms/ps4--1.ico b/frontend/assets/platforms/ps4--1.ico index fa48f9536..0901bdd1c 100644 Binary files a/frontend/assets/platforms/ps4--1.ico and b/frontend/assets/platforms/ps4--1.ico differ diff --git a/frontend/assets/platforms/psp.ico b/frontend/assets/platforms/psp.ico index c7afba68a..05c0b09f8 100644 Binary files a/frontend/assets/platforms/psp.ico and b/frontend/assets/platforms/psp.ico differ diff --git a/frontend/assets/platforms/psvita.ico b/frontend/assets/platforms/psvita.ico index 53ddac4a4..332131d60 100644 Binary files a/frontend/assets/platforms/psvita.ico and b/frontend/assets/platforms/psvita.ico differ diff --git a/frontend/assets/platforms/rca-studio-ii.ico b/frontend/assets/platforms/rca-studio-ii.ico index 0745d4989..6e845a58d 100644 Binary files a/frontend/assets/platforms/rca-studio-ii.ico and b/frontend/assets/platforms/rca-studio-ii.ico differ diff --git a/frontend/assets/platforms/rpgmaker.ico b/frontend/assets/platforms/rpgmaker.ico index 07bc0ea61..3b2812f83 100644 Binary files a/frontend/assets/platforms/rpgmaker.ico and b/frontend/assets/platforms/rpgmaker.ico differ diff --git a/frontend/assets/platforms/satellaview.ico b/frontend/assets/platforms/satellaview.ico index 92740223e..7ac5d3762 100644 Binary files a/frontend/assets/platforms/satellaview.ico and b/frontend/assets/platforms/satellaview.ico differ diff --git a/frontend/assets/platforms/saturn.ico b/frontend/assets/platforms/saturn.ico index 38ed68c65..f5314f7c2 100644 Binary files a/frontend/assets/platforms/saturn.ico and b/frontend/assets/platforms/saturn.ico differ diff --git a/frontend/assets/platforms/scummvm.ico b/frontend/assets/platforms/scummvm.ico index c1da1da3b..db00192a3 100644 Binary files a/frontend/assets/platforms/scummvm.ico and b/frontend/assets/platforms/scummvm.ico differ diff --git a/frontend/assets/platforms/sega-master-system.ico b/frontend/assets/platforms/sega-master-system.ico index a64f3177c..31bac32dc 100644 Binary files a/frontend/assets/platforms/sega-master-system.ico and b/frontend/assets/platforms/sega-master-system.ico differ diff --git a/frontend/assets/platforms/sega-pico.ico b/frontend/assets/platforms/sega-pico.ico index 272b5c0a3..d4c09ca9c 100644 Binary files a/frontend/assets/platforms/sega-pico.ico and b/frontend/assets/platforms/sega-pico.ico differ diff --git a/frontend/assets/platforms/sega32.ico b/frontend/assets/platforms/sega32.ico index 7305e85c0..a50fbabbf 100644 Binary files a/frontend/assets/platforms/sega32.ico and b/frontend/assets/platforms/sega32.ico differ diff --git a/frontend/assets/platforms/segacd.ico b/frontend/assets/platforms/segacd.ico index af0e4d620..eec13eeeb 100644 Binary files a/frontend/assets/platforms/segacd.ico and b/frontend/assets/platforms/segacd.ico differ diff --git a/frontend/assets/platforms/segasgone.ico b/frontend/assets/platforms/segasgone.ico index 6b9f5f71d..3f4d47c29 100644 Binary files a/frontend/assets/platforms/segasgone.ico and b/frontend/assets/platforms/segasgone.ico differ diff --git a/frontend/assets/platforms/sfam.ico b/frontend/assets/platforms/sfam.ico index 92740223e..7ac5d3762 100644 Binary files a/frontend/assets/platforms/sfam.ico and b/frontend/assets/platforms/sfam.ico differ diff --git a/frontend/assets/platforms/sg1000.ico b/frontend/assets/platforms/sg1000.ico index 6b9f5f71d..3f4d47c29 100644 Binary files a/frontend/assets/platforms/sg1000.ico and b/frontend/assets/platforms/sg1000.ico differ diff --git a/frontend/assets/platforms/sgb.ico b/frontend/assets/platforms/sgb.ico index 1a6025ca6..4bc454f51 100644 Binary files a/frontend/assets/platforms/sgb.ico and b/frontend/assets/platforms/sgb.ico differ diff --git a/frontend/assets/platforms/sgfx.ico b/frontend/assets/platforms/sgfx.ico index 99f1f49d4..e2c2fa9e5 100644 Binary files a/frontend/assets/platforms/sgfx.ico and b/frontend/assets/platforms/sgfx.ico differ diff --git a/frontend/assets/platforms/sharp-x68000.ico b/frontend/assets/platforms/sharp-x68000.ico index 5ff3a782a..ece294764 100644 Binary files a/frontend/assets/platforms/sharp-x68000.ico and b/frontend/assets/platforms/sharp-x68000.ico differ diff --git a/frontend/assets/platforms/sms.ico b/frontend/assets/platforms/sms.ico index a64f3177c..31bac32dc 100644 Binary files a/frontend/assets/platforms/sms.ico and b/frontend/assets/platforms/sms.ico differ diff --git a/frontend/assets/platforms/snes.ico b/frontend/assets/platforms/snes.ico index be5f9b58a..6f2e9bee4 100644 Binary files a/frontend/assets/platforms/snes.ico and b/frontend/assets/platforms/snes.ico differ diff --git a/frontend/assets/platforms/snes_alt.ico b/frontend/assets/platforms/snes_alt.ico index 92740223e..7ac5d3762 100644 Binary files a/frontend/assets/platforms/snes_alt.ico and b/frontend/assets/platforms/snes_alt.ico differ diff --git a/frontend/assets/platforms/study-box.ico b/frontend/assets/platforms/study-box.ico index 2ab74f349..7c6df171b 100644 Binary files a/frontend/assets/platforms/study-box.ico and b/frontend/assets/platforms/study-box.ico differ diff --git a/frontend/assets/platforms/sufami-turbo.ico b/frontend/assets/platforms/sufami-turbo.ico index 92740223e..7ac5d3762 100644 Binary files a/frontend/assets/platforms/sufami-turbo.ico and b/frontend/assets/platforms/sufami-turbo.ico differ diff --git a/frontend/assets/platforms/super-acan.ico b/frontend/assets/platforms/super-acan.ico index 13b8c0d8c..2dcd8c35d 100644 Binary files a/frontend/assets/platforms/super-acan.ico and b/frontend/assets/platforms/super-acan.ico differ diff --git a/frontend/assets/platforms/supergrafx.ico b/frontend/assets/platforms/supergrafx.ico index 0945c5582..7c43084b3 100644 Binary files a/frontend/assets/platforms/supergrafx.ico and b/frontend/assets/platforms/supergrafx.ico differ diff --git a/frontend/assets/platforms/supervision.ico b/frontend/assets/platforms/supervision.ico index 281721cd2..b9fc2e77a 100644 Binary files a/frontend/assets/platforms/supervision.ico and b/frontend/assets/platforms/supervision.ico differ diff --git a/frontend/assets/platforms/switch.ico b/frontend/assets/platforms/switch.ico index 7cd3fc6ad..dd7efa3fe 100644 Binary files a/frontend/assets/platforms/switch.ico and b/frontend/assets/platforms/switch.ico differ diff --git a/frontend/assets/platforms/tic.ico b/frontend/assets/platforms/tic.ico index 6ba77c719..6e37f56bf 100644 Binary files a/frontend/assets/platforms/tic.ico and b/frontend/assets/platforms/tic.ico differ diff --git a/frontend/assets/platforms/turbografx-16-slash-pc-engine-cd.ico b/frontend/assets/platforms/turbografx-16-slash-pc-engine-cd.ico index 07555b46a..3c7642e35 100644 Binary files a/frontend/assets/platforms/turbografx-16-slash-pc-engine-cd.ico and b/frontend/assets/platforms/turbografx-16-slash-pc-engine-cd.ico differ diff --git a/frontend/assets/platforms/turbografx16--1.ico b/frontend/assets/platforms/turbografx16--1.ico index 07555b46a..3c7642e35 100644 Binary files a/frontend/assets/platforms/turbografx16--1.ico and b/frontend/assets/platforms/turbografx16--1.ico differ diff --git a/frontend/assets/platforms/vb.ico b/frontend/assets/platforms/vb.ico index cd8c263d8..9135ff1e7 100644 Binary files a/frontend/assets/platforms/vb.ico and b/frontend/assets/platforms/vb.ico differ diff --git a/frontend/assets/platforms/vectrex.ico b/frontend/assets/platforms/vectrex.ico index 41540949c..7c6e6bf02 100644 Binary files a/frontend/assets/platforms/vectrex.ico and b/frontend/assets/platforms/vectrex.ico differ diff --git a/frontend/assets/platforms/vic-20.ico b/frontend/assets/platforms/vic-20.ico index 9532226eb..71d80ff72 100644 Binary files a/frontend/assets/platforms/vic-20.ico and b/frontend/assets/platforms/vic-20.ico differ diff --git a/frontend/assets/platforms/videopac-g7400.ico b/frontend/assets/platforms/videopac-g7400.ico index d39eac726..47e8439dc 100644 Binary files a/frontend/assets/platforms/videopac-g7400.ico and b/frontend/assets/platforms/videopac-g7400.ico differ diff --git a/frontend/assets/platforms/videopac.ico b/frontend/assets/platforms/videopac.ico index 5947f0d19..d3d5d92a3 100644 Binary files a/frontend/assets/platforms/videopac.ico and b/frontend/assets/platforms/videopac.ico differ diff --git a/frontend/assets/platforms/virtualboy.ico b/frontend/assets/platforms/virtualboy.ico index cd8c263d8..9135ff1e7 100644 Binary files a/frontend/assets/platforms/virtualboy.ico and b/frontend/assets/platforms/virtualboy.ico differ diff --git a/frontend/assets/platforms/visicom.ico b/frontend/assets/platforms/visicom.ico index 3dd157ab8..72bef5490 100644 Binary files a/frontend/assets/platforms/visicom.ico and b/frontend/assets/platforms/visicom.ico differ diff --git a/frontend/assets/platforms/vsmile.ico b/frontend/assets/platforms/vsmile.ico index a51c033da..4114b0caf 100644 Binary files a/frontend/assets/platforms/vsmile.ico and b/frontend/assets/platforms/vsmile.ico differ diff --git a/frontend/assets/platforms/wasm-4.ico b/frontend/assets/platforms/wasm-4.ico index 624fbcd86..15d1eb3f3 100644 Binary files a/frontend/assets/platforms/wasm-4.ico and b/frontend/assets/platforms/wasm-4.ico differ diff --git a/frontend/assets/platforms/watara-slash-quickshot-supervision.ico b/frontend/assets/platforms/watara-slash-quickshot-supervision.ico index 8b81fb6b8..43f29f93f 100644 Binary files a/frontend/assets/platforms/watara-slash-quickshot-supervision.ico and b/frontend/assets/platforms/watara-slash-quickshot-supervision.ico differ diff --git a/frontend/assets/platforms/wii.ico b/frontend/assets/platforms/wii.ico index 1e0cd036e..1416bdfb4 100644 Binary files a/frontend/assets/platforms/wii.ico and b/frontend/assets/platforms/wii.ico differ diff --git a/frontend/assets/platforms/wiiu.ico b/frontend/assets/platforms/wiiu.ico index 9047bc408..ca3b3f184 100644 Binary files a/frontend/assets/platforms/wiiu.ico and b/frontend/assets/platforms/wiiu.ico differ diff --git a/frontend/assets/platforms/win.ico b/frontend/assets/platforms/win.ico index f9b4e6c0c..4240ec95c 100644 Binary files a/frontend/assets/platforms/win.ico and b/frontend/assets/platforms/win.ico differ diff --git a/frontend/assets/platforms/wonderswan-color.ico b/frontend/assets/platforms/wonderswan-color.ico index d9b16cfb0..645cd635c 100644 Binary files a/frontend/assets/platforms/wonderswan-color.ico and b/frontend/assets/platforms/wonderswan-color.ico differ diff --git a/frontend/assets/platforms/wonderswan.ico b/frontend/assets/platforms/wonderswan.ico index 8382e0341..c01ddb7a0 100644 Binary files a/frontend/assets/platforms/wonderswan.ico and b/frontend/assets/platforms/wonderswan.ico differ diff --git a/frontend/assets/platforms/ws.ico b/frontend/assets/platforms/ws.ico index 8382e0341..c01ddb7a0 100644 Binary files a/frontend/assets/platforms/ws.ico and b/frontend/assets/platforms/ws.ico differ diff --git a/frontend/assets/platforms/xbox.ico b/frontend/assets/platforms/xbox.ico index 1f87c5d8f..5c28a7d21 100644 Binary files a/frontend/assets/platforms/xbox.ico and b/frontend/assets/platforms/xbox.ico differ diff --git a/frontend/assets/platforms/xbox360.ico b/frontend/assets/platforms/xbox360.ico index c0b6bbb07..f31292d7e 100644 Binary files a/frontend/assets/platforms/xbox360.ico and b/frontend/assets/platforms/xbox360.ico differ diff --git a/frontend/assets/platforms/xboxone.ico b/frontend/assets/platforms/xboxone.ico index 8af93d4e3..999f92cce 100644 Binary files a/frontend/assets/platforms/xboxone.ico and b/frontend/assets/platforms/xboxone.ico differ diff --git a/frontend/assets/platforms/zeebo.ico b/frontend/assets/platforms/zeebo.ico index 38fbe066d..ed7e93ef6 100644 Binary files a/frontend/assets/platforms/zeebo.ico and b/frontend/assets/platforms/zeebo.ico differ diff --git a/frontend/assets/platforms/zod.ico b/frontend/assets/platforms/zod.ico index 37fcede47..ca61d62e0 100644 Binary files a/frontend/assets/platforms/zod.ico and b/frontend/assets/platforms/zod.ico differ diff --git a/frontend/assets/platforms/zxs.ico b/frontend/assets/platforms/zxs.ico index 3db8ef885..4c3fb65b9 100644 Binary files a/frontend/assets/platforms/zxs.ico and b/frontend/assets/platforms/zxs.ico differ diff --git a/frontend/assets/webrcade/feed/2600-background.png b/frontend/assets/webrcade/feed/2600-background.png index 0eaca2945..b7819e955 100644 Binary files a/frontend/assets/webrcade/feed/2600-background.png and b/frontend/assets/webrcade/feed/2600-background.png differ diff --git a/frontend/assets/webrcade/feed/2600-thumb.png b/frontend/assets/webrcade/feed/2600-thumb.png index 54bb8a4e7..27a933f1f 100644 Binary files a/frontend/assets/webrcade/feed/2600-thumb.png and b/frontend/assets/webrcade/feed/2600-thumb.png differ diff --git a/frontend/assets/webrcade/feed/3do-thumb.png b/frontend/assets/webrcade/feed/3do-thumb.png index 285fea63a..4d0e96805 100644 Binary files a/frontend/assets/webrcade/feed/3do-thumb.png and b/frontend/assets/webrcade/feed/3do-thumb.png differ diff --git a/frontend/assets/webrcade/feed/5200-thumb.png b/frontend/assets/webrcade/feed/5200-thumb.png index 993448d19..3318e106f 100644 Binary files a/frontend/assets/webrcade/feed/5200-thumb.png and b/frontend/assets/webrcade/feed/5200-thumb.png differ diff --git a/frontend/assets/webrcade/feed/7800-background.png b/frontend/assets/webrcade/feed/7800-background.png index 2deefc6b5..7c59a9ec3 100644 Binary files a/frontend/assets/webrcade/feed/7800-background.png and b/frontend/assets/webrcade/feed/7800-background.png differ diff --git a/frontend/assets/webrcade/feed/7800-thumb.png b/frontend/assets/webrcade/feed/7800-thumb.png index b4cb54244..74ee1da03 100644 Binary files a/frontend/assets/webrcade/feed/7800-thumb.png and b/frontend/assets/webrcade/feed/7800-thumb.png differ diff --git a/frontend/assets/webrcade/feed/arcade-capcom-thumb.png b/frontend/assets/webrcade/feed/arcade-capcom-thumb.png index b41a4899e..b344f6d41 100644 Binary files a/frontend/assets/webrcade/feed/arcade-capcom-thumb.png and b/frontend/assets/webrcade/feed/arcade-capcom-thumb.png differ diff --git a/frontend/assets/webrcade/feed/arcade-konami-thumb.png b/frontend/assets/webrcade/feed/arcade-konami-thumb.png index 2ffdf9f93..5fe91a3bb 100644 Binary files a/frontend/assets/webrcade/feed/arcade-konami-thumb.png and b/frontend/assets/webrcade/feed/arcade-konami-thumb.png differ diff --git a/frontend/assets/webrcade/feed/arcade-thum.png b/frontend/assets/webrcade/feed/arcade-thum.png index bb9531786..a85f651ba 100644 Binary files a/frontend/assets/webrcade/feed/arcade-thum.png and b/frontend/assets/webrcade/feed/arcade-thum.png differ diff --git a/frontend/assets/webrcade/feed/arcade-thumb.png b/frontend/assets/webrcade/feed/arcade-thumb.png index 9c7b52ea1..73fb30e41 100644 Binary files a/frontend/assets/webrcade/feed/arcade-thumb.png and b/frontend/assets/webrcade/feed/arcade-thumb.png differ diff --git a/frontend/assets/webrcade/feed/atari2600-background.png b/frontend/assets/webrcade/feed/atari2600-background.png index 0eaca2945..b7819e955 100644 Binary files a/frontend/assets/webrcade/feed/atari2600-background.png and b/frontend/assets/webrcade/feed/atari2600-background.png differ diff --git a/frontend/assets/webrcade/feed/atari2600-thumb.png b/frontend/assets/webrcade/feed/atari2600-thumb.png index 54bb8a4e7..27a933f1f 100644 Binary files a/frontend/assets/webrcade/feed/atari2600-thumb.png and b/frontend/assets/webrcade/feed/atari2600-thumb.png differ diff --git a/frontend/assets/webrcade/feed/atari5200-thumb.png b/frontend/assets/webrcade/feed/atari5200-thumb.png index 993448d19..3318e106f 100644 Binary files a/frontend/assets/webrcade/feed/atari5200-thumb.png and b/frontend/assets/webrcade/feed/atari5200-thumb.png differ diff --git a/frontend/assets/webrcade/feed/atari7800-background.png b/frontend/assets/webrcade/feed/atari7800-background.png index 2deefc6b5..7c59a9ec3 100644 Binary files a/frontend/assets/webrcade/feed/atari7800-background.png and b/frontend/assets/webrcade/feed/atari7800-background.png differ diff --git a/frontend/assets/webrcade/feed/atari7800-thumb.png b/frontend/assets/webrcade/feed/atari7800-thumb.png index b4cb54244..74ee1da03 100644 Binary files a/frontend/assets/webrcade/feed/atari7800-thumb.png and b/frontend/assets/webrcade/feed/atari7800-thumb.png differ diff --git a/frontend/assets/webrcade/feed/coleco-thumb.png b/frontend/assets/webrcade/feed/coleco-thumb.png index e3e3a2e8a..7f32c7997 100644 Binary files a/frontend/assets/webrcade/feed/coleco-thumb.png and b/frontend/assets/webrcade/feed/coleco-thumb.png differ diff --git a/frontend/assets/webrcade/feed/colecovision-thumb.png b/frontend/assets/webrcade/feed/colecovision-thumb.png index e3e3a2e8a..7f32c7997 100644 Binary files a/frontend/assets/webrcade/feed/colecovision-thumb.png and b/frontend/assets/webrcade/feed/colecovision-thumb.png differ diff --git a/frontend/assets/webrcade/feed/default-background.png b/frontend/assets/webrcade/feed/default-background.png index b1b3fd47b..364ad95a4 100644 Binary files a/frontend/assets/webrcade/feed/default-background.png and b/frontend/assets/webrcade/feed/default-background.png differ diff --git a/frontend/assets/webrcade/feed/default-thumb.png b/frontend/assets/webrcade/feed/default-thumb.png index 3ead9edbe..5af5c795f 100644 Binary files a/frontend/assets/webrcade/feed/default-thumb.png and b/frontend/assets/webrcade/feed/default-thumb.png differ diff --git a/frontend/assets/webrcade/feed/doom-background.png b/frontend/assets/webrcade/feed/doom-background.png index c736fdd84..3396676d1 100644 Binary files a/frontend/assets/webrcade/feed/doom-background.png and b/frontend/assets/webrcade/feed/doom-background.png differ diff --git a/frontend/assets/webrcade/feed/doom-thumb.png b/frontend/assets/webrcade/feed/doom-thumb.png index 00d212b33..7b6f19098 100644 Binary files a/frontend/assets/webrcade/feed/doom-thumb.png and b/frontend/assets/webrcade/feed/doom-thumb.png differ diff --git a/frontend/assets/webrcade/feed/gamegear-background.png b/frontend/assets/webrcade/feed/gamegear-background.png index e8c497d79..90b7807ca 100644 Binary files a/frontend/assets/webrcade/feed/gamegear-background.png and b/frontend/assets/webrcade/feed/gamegear-background.png differ diff --git a/frontend/assets/webrcade/feed/gamegear-thumb.png b/frontend/assets/webrcade/feed/gamegear-thumb.png index 57fece36e..ecbb38e98 100644 Binary files a/frontend/assets/webrcade/feed/gamegear-thumb.png and b/frontend/assets/webrcade/feed/gamegear-thumb.png differ diff --git a/frontend/assets/webrcade/feed/gb-background.png b/frontend/assets/webrcade/feed/gb-background.png index 234042ca1..100fab558 100644 Binary files a/frontend/assets/webrcade/feed/gb-background.png and b/frontend/assets/webrcade/feed/gb-background.png differ diff --git a/frontend/assets/webrcade/feed/gb-thumb.png b/frontend/assets/webrcade/feed/gb-thumb.png index 669e7d013..47ff60479 100644 Binary files a/frontend/assets/webrcade/feed/gb-thumb.png and b/frontend/assets/webrcade/feed/gb-thumb.png differ diff --git a/frontend/assets/webrcade/feed/gba-background.png b/frontend/assets/webrcade/feed/gba-background.png index fee1534d0..c5222e83c 100644 Binary files a/frontend/assets/webrcade/feed/gba-background.png and b/frontend/assets/webrcade/feed/gba-background.png differ diff --git a/frontend/assets/webrcade/feed/gba-thumb.png b/frontend/assets/webrcade/feed/gba-thumb.png index cd09d9e48..df32fd4b6 100644 Binary files a/frontend/assets/webrcade/feed/gba-thumb.png and b/frontend/assets/webrcade/feed/gba-thumb.png differ diff --git a/frontend/assets/webrcade/feed/gbc-background.png b/frontend/assets/webrcade/feed/gbc-background.png index c12d0996c..0da0d49d0 100644 Binary files a/frontend/assets/webrcade/feed/gbc-background.png and b/frontend/assets/webrcade/feed/gbc-background.png differ diff --git a/frontend/assets/webrcade/feed/gbc-thumb.png b/frontend/assets/webrcade/feed/gbc-thumb.png index 236f17f7a..1f552e146 100644 Binary files a/frontend/assets/webrcade/feed/gbc-thumb.png and b/frontend/assets/webrcade/feed/gbc-thumb.png differ diff --git a/frontend/assets/webrcade/feed/genesis-slash-megadrive-thumb.png b/frontend/assets/webrcade/feed/genesis-slash-megadrive-thumb.png index 43b3e27b4..3ee823dae 100644 Binary files a/frontend/assets/webrcade/feed/genesis-slash-megadrive-thumb.png and b/frontend/assets/webrcade/feed/genesis-slash-megadrive-thumb.png differ diff --git a/frontend/assets/webrcade/feed/genesis-thumb.png b/frontend/assets/webrcade/feed/genesis-thumb.png index 43b3e27b4..3ee823dae 100644 Binary files a/frontend/assets/webrcade/feed/genesis-thumb.png and b/frontend/assets/webrcade/feed/genesis-thumb.png differ diff --git a/frontend/assets/webrcade/feed/lynx-thumb.png b/frontend/assets/webrcade/feed/lynx-thumb.png index 9cae4a1f5..e14ebd0bc 100644 Binary files a/frontend/assets/webrcade/feed/lynx-thumb.png and b/frontend/assets/webrcade/feed/lynx-thumb.png differ diff --git a/frontend/assets/webrcade/feed/mastersystem-background.png b/frontend/assets/webrcade/feed/mastersystem-background.png index 55e640c1a..2cc7401ab 100644 Binary files a/frontend/assets/webrcade/feed/mastersystem-background.png and b/frontend/assets/webrcade/feed/mastersystem-background.png differ diff --git a/frontend/assets/webrcade/feed/mastersystem-thumb.png b/frontend/assets/webrcade/feed/mastersystem-thumb.png index 8fd4f7989..6181c2c4c 100644 Binary files a/frontend/assets/webrcade/feed/mastersystem-thumb.png and b/frontend/assets/webrcade/feed/mastersystem-thumb.png differ diff --git a/frontend/assets/webrcade/feed/n64-thumb.png b/frontend/assets/webrcade/feed/n64-thumb.png index c87fc163f..3e95c464a 100644 Binary files a/frontend/assets/webrcade/feed/n64-thumb.png and b/frontend/assets/webrcade/feed/n64-thumb.png differ diff --git a/frontend/assets/webrcade/feed/neo-geo-cd-thumb.png b/frontend/assets/webrcade/feed/neo-geo-cd-thumb.png index 903487ffa..a78d81d7d 100644 Binary files a/frontend/assets/webrcade/feed/neo-geo-cd-thumb.png and b/frontend/assets/webrcade/feed/neo-geo-cd-thumb.png differ diff --git a/frontend/assets/webrcade/feed/neo-geo-pocket-color-thumb.png b/frontend/assets/webrcade/feed/neo-geo-pocket-color-thumb.png index 8ec995971..91ab5c752 100644 Binary files a/frontend/assets/webrcade/feed/neo-geo-pocket-color-thumb.png and b/frontend/assets/webrcade/feed/neo-geo-pocket-color-thumb.png differ diff --git a/frontend/assets/webrcade/feed/neo-geo-pocket-thumb.png b/frontend/assets/webrcade/feed/neo-geo-pocket-thumb.png index a6ed5ada5..c0df9475f 100644 Binary files a/frontend/assets/webrcade/feed/neo-geo-pocket-thumb.png and b/frontend/assets/webrcade/feed/neo-geo-pocket-thumb.png differ diff --git a/frontend/assets/webrcade/feed/neogeo-thumb.png b/frontend/assets/webrcade/feed/neogeo-thumb.png index 5675bcebb..3ed18b34a 100644 Binary files a/frontend/assets/webrcade/feed/neogeo-thumb.png and b/frontend/assets/webrcade/feed/neogeo-thumb.png differ diff --git a/frontend/assets/webrcade/feed/neogeoaes-thumb.png b/frontend/assets/webrcade/feed/neogeoaes-thumb.png index 5675bcebb..3ed18b34a 100644 Binary files a/frontend/assets/webrcade/feed/neogeoaes-thumb.png and b/frontend/assets/webrcade/feed/neogeoaes-thumb.png differ diff --git a/frontend/assets/webrcade/feed/neogeocd-thumb.png b/frontend/assets/webrcade/feed/neogeocd-thumb.png index 903487ffa..a78d81d7d 100644 Binary files a/frontend/assets/webrcade/feed/neogeocd-thumb.png and b/frontend/assets/webrcade/feed/neogeocd-thumb.png differ diff --git a/frontend/assets/webrcade/feed/neogeomvs-thumb.png b/frontend/assets/webrcade/feed/neogeomvs-thumb.png index 5675bcebb..3ed18b34a 100644 Binary files a/frontend/assets/webrcade/feed/neogeomvs-thumb.png and b/frontend/assets/webrcade/feed/neogeomvs-thumb.png differ diff --git a/frontend/assets/webrcade/feed/nes-background.png b/frontend/assets/webrcade/feed/nes-background.png index cf7a8f1e2..3e7f1def3 100644 Binary files a/frontend/assets/webrcade/feed/nes-background.png and b/frontend/assets/webrcade/feed/nes-background.png differ diff --git a/frontend/assets/webrcade/feed/nes-thumb.png b/frontend/assets/webrcade/feed/nes-thumb.png index a5b1e88f4..b26b5a675 100644 Binary files a/frontend/assets/webrcade/feed/nes-thumb.png and b/frontend/assets/webrcade/feed/nes-thumb.png differ diff --git a/frontend/assets/webrcade/feed/ngc-thumb.png b/frontend/assets/webrcade/feed/ngc-thumb.png index 8ec995971..91ab5c752 100644 Binary files a/frontend/assets/webrcade/feed/ngc-thumb.png and b/frontend/assets/webrcade/feed/ngc-thumb.png differ diff --git a/frontend/assets/webrcade/feed/ngp-thumb.png b/frontend/assets/webrcade/feed/ngp-thumb.png index a6ed5ada5..c0df9475f 100644 Binary files a/frontend/assets/webrcade/feed/ngp-thumb.png and b/frontend/assets/webrcade/feed/ngp-thumb.png differ diff --git a/frontend/assets/webrcade/feed/pce-thumb.png b/frontend/assets/webrcade/feed/pce-thumb.png index 71490f4cf..61242c9a1 100644 Binary files a/frontend/assets/webrcade/feed/pce-thumb.png and b/frontend/assets/webrcade/feed/pce-thumb.png differ diff --git a/frontend/assets/webrcade/feed/pcecd-thumb.png b/frontend/assets/webrcade/feed/pcecd-thumb.png index cb1fc97d8..879d50d8f 100644 Binary files a/frontend/assets/webrcade/feed/pcecd-thumb.png and b/frontend/assets/webrcade/feed/pcecd-thumb.png differ diff --git a/frontend/assets/webrcade/feed/pcecd.png b/frontend/assets/webrcade/feed/pcecd.png index cb1fc97d8..879d50d8f 100644 Binary files a/frontend/assets/webrcade/feed/pcecd.png and b/frontend/assets/webrcade/feed/pcecd.png differ diff --git a/frontend/assets/webrcade/feed/ps-thumb.png b/frontend/assets/webrcade/feed/ps-thumb.png index a66c72964..5cd30ebaa 100644 Binary files a/frontend/assets/webrcade/feed/ps-thumb.png and b/frontend/assets/webrcade/feed/ps-thumb.png differ diff --git a/frontend/assets/webrcade/feed/psx-thumb.png b/frontend/assets/webrcade/feed/psx-thumb.png index a66c72964..5cd30ebaa 100644 Binary files a/frontend/assets/webrcade/feed/psx-thumb.png and b/frontend/assets/webrcade/feed/psx-thumb.png differ diff --git a/frontend/assets/webrcade/feed/quake-thumb.png b/frontend/assets/webrcade/feed/quake-thumb.png index 4ee34d8c1..59132f0e5 100644 Binary files a/frontend/assets/webrcade/feed/quake-thumb.png and b/frontend/assets/webrcade/feed/quake-thumb.png differ diff --git a/frontend/assets/webrcade/feed/scummvm-thumb.png b/frontend/assets/webrcade/feed/scummvm-thumb.png index 467480dff..da3bf4ba2 100644 Binary files a/frontend/assets/webrcade/feed/scummvm-thumb.png and b/frontend/assets/webrcade/feed/scummvm-thumb.png differ diff --git a/frontend/assets/webrcade/feed/segacd-thumb.png b/frontend/assets/webrcade/feed/segacd-thumb.png index 3e68ab2e6..a04ef6983 100644 Binary files a/frontend/assets/webrcade/feed/segacd-thumb.png and b/frontend/assets/webrcade/feed/segacd-thumb.png differ diff --git a/frontend/assets/webrcade/feed/sg1000-thumb.png b/frontend/assets/webrcade/feed/sg1000-thumb.png index 9f41d84c9..5eb8b0758 100644 Binary files a/frontend/assets/webrcade/feed/sg1000-thumb.png and b/frontend/assets/webrcade/feed/sg1000-thumb.png differ diff --git a/frontend/assets/webrcade/feed/sgx-thumb.png b/frontend/assets/webrcade/feed/sgx-thumb.png index 111c146a3..e115d5242 100644 Binary files a/frontend/assets/webrcade/feed/sgx-thumb.png and b/frontend/assets/webrcade/feed/sgx-thumb.png differ diff --git a/frontend/assets/webrcade/feed/sms-background.png b/frontend/assets/webrcade/feed/sms-background.png index 55e640c1a..2cc7401ab 100644 Binary files a/frontend/assets/webrcade/feed/sms-background.png and b/frontend/assets/webrcade/feed/sms-background.png differ diff --git a/frontend/assets/webrcade/feed/sms-thumb.png b/frontend/assets/webrcade/feed/sms-thumb.png index 8fd4f7989..6181c2c4c 100644 Binary files a/frontend/assets/webrcade/feed/sms-thumb.png and b/frontend/assets/webrcade/feed/sms-thumb.png differ diff --git a/frontend/assets/webrcade/feed/snes-background.png b/frontend/assets/webrcade/feed/snes-background.png index c92c7744d..1e1ab9be5 100644 Binary files a/frontend/assets/webrcade/feed/snes-background.png and b/frontend/assets/webrcade/feed/snes-background.png differ diff --git a/frontend/assets/webrcade/feed/snes-thumb.png b/frontend/assets/webrcade/feed/snes-thumb.png index 53426a362..1beafe20f 100644 Binary files a/frontend/assets/webrcade/feed/snes-thumb.png and b/frontend/assets/webrcade/feed/snes-thumb.png differ diff --git a/frontend/assets/webrcade/feed/supergrafx-thumb.png b/frontend/assets/webrcade/feed/supergrafx-thumb.png index 111c146a3..e115d5242 100644 Binary files a/frontend/assets/webrcade/feed/supergrafx-thumb.png and b/frontend/assets/webrcade/feed/supergrafx-thumb.png differ diff --git a/frontend/assets/webrcade/feed/turbografx-16-slash-pc-engine-cd-thumb.png b/frontend/assets/webrcade/feed/turbografx-16-slash-pc-engine-cd-thumb.png index cb1fc97d8..879d50d8f 100644 Binary files a/frontend/assets/webrcade/feed/turbografx-16-slash-pc-engine-cd-thumb.png and b/frontend/assets/webrcade/feed/turbografx-16-slash-pc-engine-cd-thumb.png differ diff --git a/frontend/assets/webrcade/feed/turbografx16--1.png b/frontend/assets/webrcade/feed/turbografx16--1.png index 71490f4cf..61242c9a1 100644 Binary files a/frontend/assets/webrcade/feed/turbografx16--1.png and b/frontend/assets/webrcade/feed/turbografx16--1.png differ diff --git a/frontend/assets/webrcade/feed/uncompressed/2600-background.png b/frontend/assets/webrcade/feed/uncompressed/2600-background.png deleted file mode 100644 index 86a14dc70..000000000 Binary files a/frontend/assets/webrcade/feed/uncompressed/2600-background.png and /dev/null differ diff --git a/frontend/assets/webrcade/feed/uncompressed/2600-thumb.png b/frontend/assets/webrcade/feed/uncompressed/2600-thumb.png deleted file mode 100644 index 69a7e9bc6..000000000 Binary files a/frontend/assets/webrcade/feed/uncompressed/2600-thumb.png and /dev/null differ diff --git a/frontend/assets/webrcade/feed/uncompressed/5200-thumb.png b/frontend/assets/webrcade/feed/uncompressed/5200-thumb.png deleted file mode 100644 index 5e7faa7cd..000000000 Binary files a/frontend/assets/webrcade/feed/uncompressed/5200-thumb.png and /dev/null differ diff --git a/frontend/assets/webrcade/feed/uncompressed/7800-background.png b/frontend/assets/webrcade/feed/uncompressed/7800-background.png deleted file mode 100644 index f79b7e73f..000000000 Binary files a/frontend/assets/webrcade/feed/uncompressed/7800-background.png and /dev/null differ diff --git a/frontend/assets/webrcade/feed/uncompressed/7800-thumb.png b/frontend/assets/webrcade/feed/uncompressed/7800-thumb.png deleted file mode 100644 index fb0173b28..000000000 Binary files a/frontend/assets/webrcade/feed/uncompressed/7800-thumb.png and /dev/null differ diff --git a/frontend/assets/webrcade/feed/uncompressed/arcade-capcom-thumb.png b/frontend/assets/webrcade/feed/uncompressed/arcade-capcom-thumb.png deleted file mode 100644 index 6751ca63f..000000000 Binary files a/frontend/assets/webrcade/feed/uncompressed/arcade-capcom-thumb.png and /dev/null differ diff --git a/frontend/assets/webrcade/feed/uncompressed/arcade-konami-thumb.png b/frontend/assets/webrcade/feed/uncompressed/arcade-konami-thumb.png deleted file mode 100644 index e781e2588..000000000 Binary files a/frontend/assets/webrcade/feed/uncompressed/arcade-konami-thumb.png and /dev/null differ diff --git a/frontend/assets/webrcade/feed/uncompressed/arcade-thumb.png b/frontend/assets/webrcade/feed/uncompressed/arcade-thumb.png deleted file mode 100644 index a5f158768..000000000 Binary files a/frontend/assets/webrcade/feed/uncompressed/arcade-thumb.png and /dev/null differ diff --git a/frontend/assets/webrcade/feed/uncompressed/coleco-thumb.png b/frontend/assets/webrcade/feed/uncompressed/coleco-thumb.png deleted file mode 100644 index 387d6285d..000000000 Binary files a/frontend/assets/webrcade/feed/uncompressed/coleco-thumb.png and /dev/null differ diff --git a/frontend/assets/webrcade/feed/uncompressed/default-background.png b/frontend/assets/webrcade/feed/uncompressed/default-background.png deleted file mode 100644 index d91b7bb52..000000000 Binary files a/frontend/assets/webrcade/feed/uncompressed/default-background.png and /dev/null differ diff --git a/frontend/assets/webrcade/feed/uncompressed/default-thumb.png b/frontend/assets/webrcade/feed/uncompressed/default-thumb.png deleted file mode 100644 index 60d7f52a2..000000000 Binary files a/frontend/assets/webrcade/feed/uncompressed/default-thumb.png and /dev/null differ diff --git a/frontend/assets/webrcade/feed/uncompressed/doom-background.png b/frontend/assets/webrcade/feed/uncompressed/doom-background.png deleted file mode 100644 index 851f2336c..000000000 Binary files a/frontend/assets/webrcade/feed/uncompressed/doom-background.png and /dev/null differ diff --git a/frontend/assets/webrcade/feed/uncompressed/doom-thumb.png b/frontend/assets/webrcade/feed/uncompressed/doom-thumb.png deleted file mode 100644 index 2b7dc211f..000000000 Binary files a/frontend/assets/webrcade/feed/uncompressed/doom-thumb.png and /dev/null differ diff --git a/frontend/assets/webrcade/feed/uncompressed/gamegear-background.png b/frontend/assets/webrcade/feed/uncompressed/gamegear-background.png deleted file mode 100644 index ec995efb4..000000000 Binary files a/frontend/assets/webrcade/feed/uncompressed/gamegear-background.png and /dev/null differ diff --git a/frontend/assets/webrcade/feed/uncompressed/gamegear-thumb.png b/frontend/assets/webrcade/feed/uncompressed/gamegear-thumb.png deleted file mode 100644 index b22bc6f73..000000000 Binary files a/frontend/assets/webrcade/feed/uncompressed/gamegear-thumb.png and /dev/null differ diff --git a/frontend/assets/webrcade/feed/uncompressed/gb-background.png b/frontend/assets/webrcade/feed/uncompressed/gb-background.png deleted file mode 100644 index 3e921f50d..000000000 Binary files a/frontend/assets/webrcade/feed/uncompressed/gb-background.png and /dev/null differ diff --git a/frontend/assets/webrcade/feed/uncompressed/gb-thumb.png b/frontend/assets/webrcade/feed/uncompressed/gb-thumb.png deleted file mode 100644 index 85db70f00..000000000 Binary files a/frontend/assets/webrcade/feed/uncompressed/gb-thumb.png and /dev/null differ diff --git a/frontend/assets/webrcade/feed/uncompressed/gba-background.png b/frontend/assets/webrcade/feed/uncompressed/gba-background.png deleted file mode 100644 index c7fc8cb76..000000000 Binary files a/frontend/assets/webrcade/feed/uncompressed/gba-background.png and /dev/null differ diff --git a/frontend/assets/webrcade/feed/uncompressed/gba-thumb.png b/frontend/assets/webrcade/feed/uncompressed/gba-thumb.png deleted file mode 100644 index 6580a29c6..000000000 Binary files a/frontend/assets/webrcade/feed/uncompressed/gba-thumb.png and /dev/null differ diff --git a/frontend/assets/webrcade/feed/uncompressed/gbc-background.png b/frontend/assets/webrcade/feed/uncompressed/gbc-background.png deleted file mode 100644 index e2f0863f9..000000000 Binary files a/frontend/assets/webrcade/feed/uncompressed/gbc-background.png and /dev/null differ diff --git a/frontend/assets/webrcade/feed/uncompressed/gbc-thumb.png b/frontend/assets/webrcade/feed/uncompressed/gbc-thumb.png deleted file mode 100644 index 56b8b7113..000000000 Binary files a/frontend/assets/webrcade/feed/uncompressed/gbc-thumb.png and /dev/null differ diff --git a/frontend/assets/webrcade/feed/uncompressed/genesis-background.png b/frontend/assets/webrcade/feed/uncompressed/genesis-background.png deleted file mode 100644 index 78e8d1c84..000000000 Binary files a/frontend/assets/webrcade/feed/uncompressed/genesis-background.png and /dev/null differ diff --git a/frontend/assets/webrcade/feed/uncompressed/genesis-thumb.png b/frontend/assets/webrcade/feed/uncompressed/genesis-thumb.png deleted file mode 100644 index 7a44068be..000000000 Binary files a/frontend/assets/webrcade/feed/uncompressed/genesis-thumb.png and /dev/null differ diff --git a/frontend/assets/webrcade/feed/uncompressed/lynx-thumb.png b/frontend/assets/webrcade/feed/uncompressed/lynx-thumb.png deleted file mode 100644 index ed6610963..000000000 Binary files a/frontend/assets/webrcade/feed/uncompressed/lynx-thumb.png and /dev/null differ diff --git a/frontend/assets/webrcade/feed/uncompressed/mastersystem-background.png b/frontend/assets/webrcade/feed/uncompressed/mastersystem-background.png deleted file mode 100644 index 184daf67d..000000000 Binary files a/frontend/assets/webrcade/feed/uncompressed/mastersystem-background.png and /dev/null differ diff --git a/frontend/assets/webrcade/feed/uncompressed/mastersystem-thumb.png b/frontend/assets/webrcade/feed/uncompressed/mastersystem-thumb.png deleted file mode 100644 index 57ba3ef00..000000000 Binary files a/frontend/assets/webrcade/feed/uncompressed/mastersystem-thumb.png and /dev/null differ diff --git a/frontend/assets/webrcade/feed/uncompressed/n64-thumb.png b/frontend/assets/webrcade/feed/uncompressed/n64-thumb.png deleted file mode 100644 index 5c341d9e9..000000000 Binary files a/frontend/assets/webrcade/feed/uncompressed/n64-thumb.png and /dev/null differ diff --git a/frontend/assets/webrcade/feed/uncompressed/neogeo-thumb.png b/frontend/assets/webrcade/feed/uncompressed/neogeo-thumb.png deleted file mode 100644 index 02e532658..000000000 Binary files a/frontend/assets/webrcade/feed/uncompressed/neogeo-thumb.png and /dev/null differ diff --git a/frontend/assets/webrcade/feed/uncompressed/neogeocd-thumb.png b/frontend/assets/webrcade/feed/uncompressed/neogeocd-thumb.png deleted file mode 100644 index d39c7c5ed..000000000 Binary files a/frontend/assets/webrcade/feed/uncompressed/neogeocd-thumb.png and /dev/null differ diff --git a/frontend/assets/webrcade/feed/uncompressed/nes-background.png b/frontend/assets/webrcade/feed/uncompressed/nes-background.png deleted file mode 100644 index 21d0a175e..000000000 Binary files a/frontend/assets/webrcade/feed/uncompressed/nes-background.png and /dev/null differ diff --git a/frontend/assets/webrcade/feed/uncompressed/nes-thumb.png b/frontend/assets/webrcade/feed/uncompressed/nes-thumb.png deleted file mode 100644 index 443545c72..000000000 Binary files a/frontend/assets/webrcade/feed/uncompressed/nes-thumb.png and /dev/null differ diff --git a/frontend/assets/webrcade/feed/uncompressed/ngc-thumb.png b/frontend/assets/webrcade/feed/uncompressed/ngc-thumb.png deleted file mode 100644 index eb63561b9..000000000 Binary files a/frontend/assets/webrcade/feed/uncompressed/ngc-thumb.png and /dev/null differ diff --git a/frontend/assets/webrcade/feed/uncompressed/ngp-thumb.png b/frontend/assets/webrcade/feed/uncompressed/ngp-thumb.png deleted file mode 100644 index 8e40161ba..000000000 Binary files a/frontend/assets/webrcade/feed/uncompressed/ngp-thumb.png and /dev/null differ diff --git a/frontend/assets/webrcade/feed/uncompressed/pce-thumb.png b/frontend/assets/webrcade/feed/uncompressed/pce-thumb.png deleted file mode 100644 index 469bf09bb..000000000 Binary files a/frontend/assets/webrcade/feed/uncompressed/pce-thumb.png and /dev/null differ diff --git a/frontend/assets/webrcade/feed/uncompressed/pcecd-thumb.png b/frontend/assets/webrcade/feed/uncompressed/pcecd-thumb.png deleted file mode 100644 index 533f5bca5..000000000 Binary files a/frontend/assets/webrcade/feed/uncompressed/pcecd-thumb.png and /dev/null differ diff --git a/frontend/assets/webrcade/feed/uncompressed/pcfx-thumb.png b/frontend/assets/webrcade/feed/uncompressed/pcfx-thumb.png deleted file mode 100644 index a78e10991..000000000 Binary files a/frontend/assets/webrcade/feed/uncompressed/pcfx-thumb.png and /dev/null differ diff --git a/frontend/assets/webrcade/feed/uncompressed/psx-thumb.png b/frontend/assets/webrcade/feed/uncompressed/psx-thumb.png deleted file mode 100644 index bf50619df..000000000 Binary files a/frontend/assets/webrcade/feed/uncompressed/psx-thumb.png and /dev/null differ diff --git a/frontend/assets/webrcade/feed/uncompressed/quake-thumb.png b/frontend/assets/webrcade/feed/uncompressed/quake-thumb.png deleted file mode 100644 index 3f9bd45b0..000000000 Binary files a/frontend/assets/webrcade/feed/uncompressed/quake-thumb.png and /dev/null differ diff --git a/frontend/assets/webrcade/feed/uncompressed/scummvm-thumb.png b/frontend/assets/webrcade/feed/uncompressed/scummvm-thumb.png deleted file mode 100644 index 94d327ae7..000000000 Binary files a/frontend/assets/webrcade/feed/uncompressed/scummvm-thumb.png and /dev/null differ diff --git a/frontend/assets/webrcade/feed/uncompressed/segacd-thumb.png b/frontend/assets/webrcade/feed/uncompressed/segacd-thumb.png deleted file mode 100644 index 8da1048a7..000000000 Binary files a/frontend/assets/webrcade/feed/uncompressed/segacd-thumb.png and /dev/null differ diff --git a/frontend/assets/webrcade/feed/uncompressed/sg1000-thumb.png b/frontend/assets/webrcade/feed/uncompressed/sg1000-thumb.png deleted file mode 100644 index f0b6f4be3..000000000 Binary files a/frontend/assets/webrcade/feed/uncompressed/sg1000-thumb.png and /dev/null differ diff --git a/frontend/assets/webrcade/feed/uncompressed/sgx-thumb.png b/frontend/assets/webrcade/feed/uncompressed/sgx-thumb.png deleted file mode 100644 index bcc639324..000000000 Binary files a/frontend/assets/webrcade/feed/uncompressed/sgx-thumb.png and /dev/null differ diff --git a/frontend/assets/webrcade/feed/uncompressed/snes-background.png b/frontend/assets/webrcade/feed/uncompressed/snes-background.png deleted file mode 100644 index 8ef721c97..000000000 Binary files a/frontend/assets/webrcade/feed/uncompressed/snes-background.png and /dev/null differ diff --git a/frontend/assets/webrcade/feed/uncompressed/snes-thumb.png b/frontend/assets/webrcade/feed/uncompressed/snes-thumb.png deleted file mode 100644 index 7339db2a6..000000000 Binary files a/frontend/assets/webrcade/feed/uncompressed/snes-thumb.png and /dev/null differ diff --git a/frontend/assets/webrcade/feed/uncompressed/vb-thumb.png b/frontend/assets/webrcade/feed/uncompressed/vb-thumb.png deleted file mode 100644 index d83a6a4d8..000000000 Binary files a/frontend/assets/webrcade/feed/uncompressed/vb-thumb.png and /dev/null differ diff --git a/frontend/assets/webrcade/feed/uncompressed/ws-thumb.png b/frontend/assets/webrcade/feed/uncompressed/ws-thumb.png deleted file mode 100644 index 31c9c139e..000000000 Binary files a/frontend/assets/webrcade/feed/uncompressed/ws-thumb.png and /dev/null differ diff --git a/frontend/assets/webrcade/feed/uncompressed/wsc-thumb.png b/frontend/assets/webrcade/feed/uncompressed/wsc-thumb.png deleted file mode 100644 index db49dba57..000000000 Binary files a/frontend/assets/webrcade/feed/uncompressed/wsc-thumb.png and /dev/null differ diff --git a/frontend/assets/webrcade/feed/vb-thumb.png b/frontend/assets/webrcade/feed/vb-thumb.png index 3efbfb659..537563822 100644 Binary files a/frontend/assets/webrcade/feed/vb-thumb.png and b/frontend/assets/webrcade/feed/vb-thumb.png differ diff --git a/frontend/assets/webrcade/feed/virtualboy-thumb.png b/frontend/assets/webrcade/feed/virtualboy-thumb.png index 3efbfb659..537563822 100644 Binary files a/frontend/assets/webrcade/feed/virtualboy-thumb.png and b/frontend/assets/webrcade/feed/virtualboy-thumb.png differ diff --git a/frontend/assets/webrcade/feed/wonderswan-color-thumb.png b/frontend/assets/webrcade/feed/wonderswan-color-thumb.png index 761f47bfd..e60dc098a 100644 Binary files a/frontend/assets/webrcade/feed/wonderswan-color-thumb.png and b/frontend/assets/webrcade/feed/wonderswan-color-thumb.png differ diff --git a/frontend/assets/webrcade/feed/wonderswan-thumb.png b/frontend/assets/webrcade/feed/wonderswan-thumb.png index 761f47bfd..e60dc098a 100644 Binary files a/frontend/assets/webrcade/feed/wonderswan-thumb.png and b/frontend/assets/webrcade/feed/wonderswan-thumb.png differ diff --git a/frontend/assets/webrcade/feed/ws-thumb.png b/frontend/assets/webrcade/feed/ws-thumb.png index 761f47bfd..e60dc098a 100644 Binary files a/frontend/assets/webrcade/feed/ws-thumb.png and b/frontend/assets/webrcade/feed/ws-thumb.png differ diff --git a/frontend/assets/webrcade/feed/wsc-thumb.png b/frontend/assets/webrcade/feed/wsc-thumb.png index 289017fc9..38e748c40 100644 Binary files a/frontend/assets/webrcade/feed/wsc-thumb.png and b/frontend/assets/webrcade/feed/wsc-thumb.png differ diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 4146fc6c8..e62ec4641 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -7,15 +7,12 @@ "": { "name": "romm", "version": "0.0.1", - "hasInstallScript": true, - "license": "GPL-3.0-only", + "license": "AGPL-3.0-only", "dependencies": { "@mdi/font": "7.0.96", - "@ruffle-rs/ruffle": "^0.1.0-nightly.2024.7.29", "axios": "^1.7.4", "core-js": "^3.37.1", "cronstrue": "^2.50.0", - "emulatorjs": "github:emulatorjs/emulatorjs#v4.1.1", "file-saver": "^2.0.5", "js-cookie": "^3.0.5", "jszip": "^3.10.1", @@ -24,12 +21,14 @@ "mitt": "^3.0.1", "nanoid": "^5.0.7", "pinia": "^2.1.7", + "qrcode": "^1.5.4", "roboto-fontface": "^0.10.0", "semver": "^7.6.2", "socket.io-client": "^4.7.5", "vue": "^3.4.27", + "vue-i18n": "^10.0.5", "vue-router": "^4.3.2", - "vuetify": "^3.6.5", + "vuetify": "^3.7.4", "webfontloader": "^1.6.28" }, "devDependencies": { @@ -39,6 +38,7 @@ "@types/js-cookie": "^3.0.6", "@types/lodash": "^4.17.1", "@types/node": "^20.12.12", + "@types/qrcode": "^1.5.5", "@types/semver": "^7.5.8", "@types/webfontloader": "^1.6.38", "@vitejs/plugin-vue": "^3.2.0", @@ -52,11 +52,10 @@ "tslib": "^2.6.2", "typescript": "^5.4.5", "typescript-eslint": "^7.11.0", - "vite": "^3.2.10", + "vite": "^3.2.11", "vite-plugin-pwa": "^0.14.7", - "vite-plugin-static-copy": "0.17.1", "vite-plugin-vuetify": "^1.0.2", - "vue-tsc": "^1.8.27" + "vue-tsc": "^2.1.10" }, "engines": { "node": "18" @@ -434,17 +433,17 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.24.1", - "dev": true, - "license": "MIT", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz", + "integrity": "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.24.5", - "dev": true, - "license": "MIT", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz", + "integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==", "engines": { "node": ">=6.9.0" } @@ -562,8 +561,12 @@ } }, "node_modules/@babel/parser": { - "version": "7.24.5", - "license": "MIT", + "version": "7.26.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.2.tgz", + "integrity": "sha512-DWMCZH9WA4Maitz2q21SRKHo9QXZxkDsbNZoVD62gusNtNBBqDg9i7uOhASfTfIGNzW+O+r7+jAlM8dwphcJKQ==", + "dependencies": { + "@babel/types": "^7.26.0" + }, "bin": { "parser": "bin/babel-parser.js" }, @@ -1763,13 +1766,12 @@ } }, "node_modules/@babel/types": { - "version": "7.24.5", - "dev": true, - "license": "MIT", + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.0.tgz", + "integrity": "sha512-Z/yiTPj+lDVnF7lWeKCIJzaIkI0vYO87dMpZ4bg4TDrFe4XXLFWL1TbXU27gBP3QccxV9mZICCrnjnYlJjXHOA==", "dependencies": { - "@babel/helper-string-parser": "^7.24.1", - "@babel/helper-validator-identifier": "^7.24.5", - "to-fast-properties": "^2.0.0" + "@babel/helper-string-parser": "^7.25.9", + "@babel/helper-validator-identifier": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -2214,6 +2216,47 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/@intlify/core-base": { + "version": "10.0.5", + "resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-10.0.5.tgz", + "integrity": "sha512-F3snDTQs0MdvnnyzTDTVkOYVAZOE/MHwRvF7mn7Jw1yuih4NrFYLNYIymGlLmq4HU2iIdzYsZ7f47bOcwY73XQ==", + "dependencies": { + "@intlify/message-compiler": "10.0.5", + "@intlify/shared": "10.0.5" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/kazupon" + } + }, + "node_modules/@intlify/message-compiler": { + "version": "10.0.5", + "resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-10.0.5.tgz", + "integrity": "sha512-6GT1BJ852gZ0gItNZN2krX5QAmea+cmdjMvsWohArAZ3GmHdnNANEcF9JjPXAMRtQ6Ux5E269ymamg/+WU6tQA==", + "dependencies": { + "@intlify/shared": "10.0.5", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/kazupon" + } + }, + "node_modules/@intlify/shared": { + "version": "10.0.5", + "resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-10.0.5.tgz", + "integrity": "sha512-bmsP4L2HqBF6i6uaMqJMcFBONVjKt+siGluRq4Ca4C0q7W2eMaVZr8iCgF9dKbcVXutftkC7D6z2SaSMmLiDyA==", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/kazupon" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.5", "devOptional": true, @@ -2583,11 +2626,6 @@ } } }, - "node_modules/@ruffle-rs/ruffle": { - "version": "0.1.0-nightly.2024.7.29", - "resolved": "https://registry.npmjs.org/@ruffle-rs/ruffle/-/ruffle-0.1.0-nightly.2024.7.29.tgz", - "integrity": "sha512-hChElD2KhZgwP8jkGik4Dl92c1HWAAgu81w+6VBZ0I6MFXJOCEiECjvoj1jrEgAs/pVdordLrAOBqw+v2UDhpA==" - }, "node_modules/@socket.io/component-emitter": { "version": "3.1.2", "license": "MIT" @@ -2679,6 +2717,15 @@ "undici-types": "~5.26.4" } }, + "node_modules/@types/qrcode": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.5.tgz", + "integrity": "sha512-CdfBi/e3Qk+3Z/fXYShipBT13OJ2fDO2Q2w5CIP5anLTLIndQG9z6P1cnm+8zCWSpm5dnxMFd/uREtb0EXuQzg==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/resolve": { "version": "1.17.1", "dev": true, @@ -2934,28 +2981,29 @@ } }, "node_modules/@volar/language-core": { - "version": "1.11.1", + "version": "2.4.10", + "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.10.tgz", + "integrity": "sha512-hG3Z13+nJmGaT+fnQzAkS0hjJRa2FCeqZt6Bd+oGNhUkQ+mTFsDETg5rqUTxyzIh5pSOGY7FHCWUS8G82AzLCA==", "dev": true, - "license": "MIT", "dependencies": { - "@volar/source-map": "1.11.1" + "@volar/source-map": "2.4.10" } }, "node_modules/@volar/source-map": { - "version": "1.11.1", - "dev": true, - "license": "MIT", - "dependencies": { - "muggle-string": "^0.3.1" - } + "version": "2.4.10", + "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.10.tgz", + "integrity": "sha512-OCV+b5ihV0RF3A7vEvNyHPi4G4kFa6ukPmyVocmqm5QzOd8r5yAtiNvaPEjl8dNvgC/lj4JPryeeHLdXd62rWA==", + "dev": true }, "node_modules/@volar/typescript": { - "version": "1.11.1", + "version": "2.4.10", + "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.10.tgz", + "integrity": "sha512-F8ZtBMhSXyYKuBfGpYwqA5rsONnOwAVvjyE7KPYJ7wgZqo2roASqNWUnianOomJX5u1cxeRooHV59N0PhvEOgw==", "dev": true, - "license": "MIT", "dependencies": { - "@volar/language-core": "1.11.1", - "path-browserify": "^1.0.1" + "@volar/language-core": "2.4.10", + "path-browserify": "^1.0.1", + "vscode-uri": "^3.0.8" } }, "node_modules/@vue/compiler-core": { @@ -3000,24 +3048,34 @@ "@vue/shared": "3.4.27" } }, + "node_modules/@vue/compiler-vue2": { + "version": "2.7.16", + "resolved": "https://registry.npmjs.org/@vue/compiler-vue2/-/compiler-vue2-2.7.16.tgz", + "integrity": "sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==", + "dev": true, + "dependencies": { + "de-indent": "^1.0.2", + "he": "^1.2.0" + } + }, "node_modules/@vue/devtools-api": { "version": "6.6.1", "license": "MIT" }, "node_modules/@vue/language-core": { - "version": "1.8.27", + "version": "2.1.10", + "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-2.1.10.tgz", + "integrity": "sha512-DAI289d0K3AB5TUG3xDp9OuQ71CnrujQwJrQnfuZDwo6eGNf0UoRlPuaVNO+Zrn65PC3j0oB2i7mNmVPggeGeQ==", "dev": true, - "license": "MIT", "dependencies": { - "@volar/language-core": "~1.11.1", - "@volar/source-map": "~1.11.1", - "@vue/compiler-dom": "^3.3.0", - "@vue/shared": "^3.3.0", - "computeds": "^0.0.1", + "@volar/language-core": "~2.4.8", + "@vue/compiler-dom": "^3.5.0", + "@vue/compiler-vue2": "^2.7.16", + "@vue/shared": "^3.5.0", + "alien-signals": "^0.2.0", "minimatch": "^9.0.3", - "muggle-string": "^0.3.1", - "path-browserify": "^1.0.1", - "vue-template-compiler": "^2.7.14" + "muggle-string": "^0.4.1", + "path-browserify": "^1.0.1" }, "peerDependencies": { "typescript": "*" @@ -3028,18 +3086,49 @@ } } }, + "node_modules/@vue/language-core/node_modules/@vue/compiler-core": { + "version": "3.5.13", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.13.tgz", + "integrity": "sha512-oOdAkwqUfW1WqpwSYJce06wvt6HljgY3fGeM9NcVA1HaYOij3mZG9Rkysn0OHuyUAGMbEbARIpsG+LPVlBJ5/Q==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.25.3", + "@vue/shared": "3.5.13", + "entities": "^4.5.0", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.0" + } + }, + "node_modules/@vue/language-core/node_modules/@vue/compiler-dom": { + "version": "3.5.13", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.13.tgz", + "integrity": "sha512-ZOJ46sMOKUjO3e94wPdCzQ6P1Lx/vhp2RSvfaab88Ajexs0AHeV0uasYhi99WPaogmBlRHNRuly8xV75cNTMDA==", + "dev": true, + "dependencies": { + "@vue/compiler-core": "3.5.13", + "@vue/shared": "3.5.13" + } + }, + "node_modules/@vue/language-core/node_modules/@vue/shared": { + "version": "3.5.13", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.13.tgz", + "integrity": "sha512-/hnE/qP5ZoGpol0a5mDi45bOd7t3tjYJBjsgCsivow7D48cJeV5l05RD82lPqi7gRiphZM37rnhW1l6ZoCNNnQ==", + "dev": true + }, "node_modules/@vue/language-core/node_modules/brace-expansion": { "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", "dev": true, - "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" } }, "node_modules/@vue/language-core/node_modules/minimatch": { - "version": "9.0.4", + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", "dev": true, - "license": "ISC", "dependencies": { "brace-expansion": "^2.0.1" }, @@ -3141,9 +3230,14 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/alien-signals": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-0.2.2.tgz", + "integrity": "sha512-cZIRkbERILsBOXTQmMrxc9hgpxglstn69zm+F1ARf4aPAzdAFYd6sBq87ErO0Fj3DV94tglcyHG5kQz9nDC/8A==", + "dev": true + }, "node_modules/ansi-regex": { "version": "5.0.1", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -3223,14 +3317,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/async": { - "version": "2.6.4", - "resolved": "https://registry.npmjs.org/async/-/async-2.6.4.tgz", - "integrity": "sha512-mzo5dfJYwAn29PeiJ0zvwTo04zj8HDJj0Mn8TD7sno7q12prdbnasKJHhkm2c1LgrhlJ0teaea8860oxi51mGA==", - "dependencies": { - "lodash": "^4.17.14" - } - }, "node_modules/asynckit": { "version": "0.4.0", "license": "MIT" @@ -3316,17 +3402,6 @@ "dev": true, "license": "MIT" }, - "node_modules/basic-auth": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz", - "integrity": "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==", - "dependencies": { - "safe-buffer": "5.1.2" - }, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/binary-extensions": { "version": "2.3.0", "devOptional": true, @@ -3413,6 +3488,7 @@ }, "node_modules/call-bind": { "version": "1.0.7", + "dev": true, "license": "MIT", "dependencies": { "es-define-property": "^1.0.0", @@ -3473,6 +3549,7 @@ }, "node_modules/chalk": { "version": "4.1.2", + "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^4.1.0", @@ -3519,6 +3596,16 @@ "node": ">= 6" } }, + "node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, "node_modules/codemirror": { "version": "6.0.1", "license": "MIT", @@ -3577,11 +3664,6 @@ "devOptional": true, "license": "MIT" }, - "node_modules/computeds": { - "version": "0.0.1", - "dev": true, - "license": "MIT" - }, "node_modules/concat-map": { "version": "0.0.1", "dev": true, @@ -3624,14 +3706,6 @@ "version": "1.0.3", "license": "MIT" }, - "node_modules/corser": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/corser/-/corser-2.0.1.tgz", - "integrity": "sha512-utCYNzRSQIZNPIcGZdQc92UVJYAhtGAteCFg0yRaFm8f0P+CPtyGyHXJcGXnffjCybUCEx3FQ2G7U3/o9eIkVQ==", - "engines": { - "node": ">= 0.4.0" - } - }, "node_modules/crelt": { "version": "1.0.6", "license": "MIT" @@ -3644,9 +3718,10 @@ } }, "node_modules/cross-spawn": { - "version": "7.0.3", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "dev": true, - "license": "MIT", "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", @@ -3733,8 +3808,9 @@ }, "node_modules/de-indent": { "version": "1.0.2", - "dev": true, - "license": "MIT" + "resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz", + "integrity": "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==", + "dev": true }, "node_modules/debug": { "version": "4.3.4", @@ -3751,6 +3827,14 @@ } } }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/deep-is": { "version": "0.1.4", "dev": true, @@ -3766,6 +3850,7 @@ }, "node_modules/define-data-property": { "version": "1.1.4", + "dev": true, "license": "MIT", "dependencies": { "es-define-property": "^1.0.0", @@ -3802,6 +3887,11 @@ "node": ">=0.4.0" } }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==" + }, "node_modules/dir-glob": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", @@ -3844,13 +3934,10 @@ "dev": true, "license": "ISC" }, - "node_modules/emulatorjs": { - "name": "@emulatorjs/emulatorjs", - "version": "4.0.12", - "resolved": "git+ssh://git@github.com/emulatorjs/emulatorjs.git#a7b59f19a3e9415c2a42121461da3df03b25d3b5", - "dependencies": { - "http-server": "^14.1.1" - } + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" }, "node_modules/engine.io-client": { "version": "6.5.4", @@ -3942,6 +4029,7 @@ }, "node_modules/es-define-property": { "version": "1.0.0", + "dev": true, "license": "MIT", "dependencies": { "get-intrinsic": "^1.2.4" @@ -3952,6 +4040,7 @@ }, "node_modules/es-errors": { "version": "1.3.0", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -4256,11 +4345,6 @@ "node": ">=0.10.0" } }, - "node_modules/eventemitter3": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", - "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==" - }, "node_modules/fast-deep-equal": { "version": "3.1.3", "dev": true, @@ -4484,6 +4568,7 @@ }, "node_modules/function-bind": { "version": "1.1.2", + "devOptional": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -4522,8 +4607,17 @@ "node": ">=6.9.0" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/get-intrinsic": { "version": "1.2.4", + "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -4639,6 +4733,7 @@ }, "node_modules/gopd": { "version": "1.0.1", + "dev": true, "license": "MIT", "dependencies": { "get-intrinsic": "^1.1.3" @@ -4687,6 +4782,7 @@ }, "node_modules/has-flag": { "version": "4.0.0", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -4694,6 +4790,7 @@ }, "node_modules/has-property-descriptors": { "version": "1.0.2", + "dev": true, "license": "MIT", "dependencies": { "es-define-property": "^1.0.0" @@ -4704,6 +4801,7 @@ }, "node_modules/has-proto": { "version": "1.0.3", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -4714,6 +4812,7 @@ }, "node_modules/has-symbols": { "version": "1.0.3", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -4738,6 +4837,7 @@ }, "node_modules/hasown": { "version": "2.0.2", + "devOptional": true, "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -4748,72 +4848,12 @@ }, "node_modules/he": { "version": "1.2.0", + "dev": true, "license": "MIT", "bin": { "he": "bin/he" } }, - "node_modules/html-encoding-sniffer": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz", - "integrity": "sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==", - "dependencies": { - "whatwg-encoding": "^2.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/http-proxy": { - "version": "1.18.1", - "resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz", - "integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==", - "dependencies": { - "eventemitter3": "^4.0.0", - "follow-redirects": "^1.0.0", - "requires-port": "^1.0.0" - }, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/http-server": { - "version": "14.1.1", - "resolved": "https://registry.npmjs.org/http-server/-/http-server-14.1.1.tgz", - "integrity": "sha512-+cbxadF40UXd9T01zUHgA+rlo2Bg1Srer4+B4NwIHdaGxAGGv59nYRnGGDJ9LBk7alpS0US+J+bLLdQOOkJq4A==", - "dependencies": { - "basic-auth": "^2.0.1", - "chalk": "^4.1.2", - "corser": "^2.0.1", - "he": "^1.2.0", - "html-encoding-sniffer": "^3.0.0", - "http-proxy": "^1.18.1", - "mime": "^1.6.0", - "minimist": "^1.2.6", - "opener": "^1.5.1", - "portfinder": "^1.0.28", - "secure-compare": "3.0.1", - "union": "~0.5.0", - "url-join": "^4.0.1" - }, - "bin": { - "http-server": "bin/http-server" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/idb": { "version": "7.1.1", "dev": true, @@ -4995,6 +5035,14 @@ "node": ">=0.10.0" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "engines": { + "node": ">=8" + } + }, "node_modules/is-glob": { "version": "4.0.3", "devOptional": true, @@ -5502,28 +5550,18 @@ } }, "node_modules/micromatch": { - "version": "4.0.5", + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", "dev": true, - "license": "MIT", "dependencies": { - "braces": "^3.0.2", + "braces": "^3.0.3", "picomatch": "^2.3.1" }, "engines": { "node": ">=8.6" } }, - "node_modules/mime": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", - "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", - "bin": { - "mime": "cli.js" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/mime-db": { "version": "1.52.0", "license": "MIT", @@ -5554,6 +5592,7 @@ }, "node_modules/minimist": { "version": "1.2.8", + "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -5563,35 +5602,26 @@ "version": "3.0.1", "license": "MIT" }, - "node_modules/mkdirp": { - "version": "0.5.6", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", - "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", - "dependencies": { - "minimist": "^1.2.6" - }, - "bin": { - "mkdirp": "bin/cmd.js" - } - }, "node_modules/ms": { "version": "2.1.2", "license": "MIT" }, "node_modules/muggle-string": { - "version": "0.3.1", - "dev": true, - "license": "MIT" + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz", + "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", + "dev": true }, "node_modules/nanoid": { - "version": "5.0.7", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.0.9.tgz", + "integrity": "sha512-Aooyr6MXU6HpvvWXKoVoXwKMs/KyVakWwg7xQfv5/S/RIgJMy0Ifa45H9qqYy7pTCszrHzP21Uk4PZq2HpEM8Q==", "funding": [ { "type": "github", "url": "https://github.com/sponsors/ai" } ], - "license": "MIT", "bin": { "nanoid": "bin/nanoid.js" }, @@ -5635,6 +5665,7 @@ }, "node_modules/object-inspect": { "version": "1.13.1", + "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -5688,14 +5719,6 @@ "openapi": "bin/index.js" } }, - "node_modules/opener": { - "version": "1.5.2", - "resolved": "https://registry.npmjs.org/opener/-/opener-1.5.2.tgz", - "integrity": "sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==", - "bin": { - "opener": "bin/opener-bin.js" - } - }, "node_modules/optionator": { "version": "0.9.4", "dev": true, @@ -5742,7 +5765,6 @@ }, "node_modules/p-try": { "version": "2.2.0", - "devOptional": true, "license": "MIT", "engines": { "node": ">=6" @@ -5765,12 +5787,12 @@ }, "node_modules/path-browserify": { "version": "1.0.1", - "dev": true, - "license": "MIT" + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true }, "node_modules/path-exists": { "version": "4.0.0", - "devOptional": true, "license": "MIT", "engines": { "node": ">=8" @@ -5928,25 +5950,12 @@ "node": ">=8" } }, - "node_modules/portfinder": { - "version": "1.0.32", - "resolved": "https://registry.npmjs.org/portfinder/-/portfinder-1.0.32.tgz", - "integrity": "sha512-on2ZJVVDXRADWE6jnQaX0ioEylzgBpQk8r55NE4wjXW1ZxO+BgDlY6DXwj20i0V8eB4SenDQ00WEaxfiIQPcxg==", - "dependencies": { - "async": "^2.6.4", - "debug": "^3.2.7", - "mkdirp": "^0.5.6" - }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", "engines": { - "node": ">= 0.12.0" - } - }, - "node_modules/portfinder/node_modules/debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", - "dependencies": { - "ms": "^2.1.1" + "node": ">=10.13.0" } }, "node_modules/possible-typed-array-names": { @@ -5996,14 +6005,15 @@ } }, "node_modules/postcss/node_modules/nanoid": { - "version": "3.3.7", + "version": "3.3.8", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz", + "integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==", "funding": [ { "type": "github", "url": "https://github.com/sponsors/ai" } ], - "license": "MIT", "bin": { "nanoid": "bin/nanoid.cjs" }, @@ -6052,18 +6062,20 @@ "node": ">=6" } }, - "node_modules/qs": { - "version": "6.13.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", - "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", "dependencies": { - "side-channel": "^1.0.6" + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" }, - "engines": { - "node": ">=0.6" + "bin": { + "qrcode": "bin/qrcode" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "engines": { + "node": ">=10.13.0" } }, "node_modules/queue-microtask": { @@ -6197,6 +6209,14 @@ "jsesc": "bin/jsesc" } }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/require-from-string": { "version": "2.0.2", "dev": true, @@ -6205,10 +6225,10 @@ "node": ">=0.10.0" } }, - "node_modules/requires-port": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", - "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==" + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==" }, "node_modules/resolve": { "version": "1.22.8", @@ -6265,9 +6285,10 @@ "license": "Apache-2.0" }, "node_modules/rollup": { - "version": "2.79.1", + "version": "2.79.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.79.2.tgz", + "integrity": "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==", "devOptional": true, - "license": "MIT", "bin": { "rollup": "dist/bin/rollup" }, @@ -6356,11 +6377,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" - }, "node_modules/sass": { "version": "1.77.1", "devOptional": true, @@ -6377,11 +6393,6 @@ "node": ">=14.0.0" } }, - "node_modules/secure-compare": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/secure-compare/-/secure-compare-3.0.1.tgz", - "integrity": "sha512-AckIIV90rPDcBcglUwXPF3kg0P0qmPsPXAj6BBEENQE1p5yA1xfmDJzfi1Tappj37Pv2mVbKpL3Z1T+Nn7k1Qw==" - }, "node_modules/semver": { "version": "7.6.2", "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.2.tgz", @@ -6401,8 +6412,14 @@ "randombytes": "^2.1.0" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==" + }, "node_modules/set-function-length": { "version": "1.2.2", + "dev": true, "license": "MIT", "dependencies": { "define-data-property": "^1.1.4", @@ -6455,6 +6472,7 @@ }, "node_modules/side-channel": { "version": "1.0.6", + "dev": true, "license": "MIT", "dependencies": { "call-bind": "^1.0.7", @@ -6538,6 +6556,19 @@ "safe-buffer": "~5.1.0" } }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/string.prototype.matchall": { "version": "4.0.11", "dev": true, @@ -6624,7 +6655,6 @@ }, "node_modules/strip-ansi": { "version": "6.0.1", - "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -6658,6 +6688,7 @@ }, "node_modules/supports-color": { "version": "7.2.0", + "dev": true, "license": "MIT", "dependencies": { "has-flag": "^4.0.0" @@ -6740,14 +6771,6 @@ "dev": true, "license": "MIT" }, - "node_modules/to-fast-properties": { - "version": "2.0.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -6989,17 +7012,6 @@ "node": ">=4" } }, - "node_modules/union": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/union/-/union-0.5.0.tgz", - "integrity": "sha512-N6uOhuW6zO95P3Mel2I2zMsbsanvvtgn6jVqJv4vbVcz/JN0OkL9suomjQGmWtxJQXOCqUJvquc1sMeNz/IwlA==", - "dependencies": { - "qs": "^6.4.0" - }, - "engines": { - "node": ">= 0.8.0" - } - }, "node_modules/unique-string": { "version": "2.0.0", "dev": true, @@ -7065,19 +7077,15 @@ "punycode": "^2.1.0" } }, - "node_modules/url-join": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/url-join/-/url-join-4.0.1.tgz", - "integrity": "sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA==" - }, "node_modules/util-deprecate": { "version": "1.0.2", "license": "MIT" }, "node_modules/vite": { - "version": "3.2.10", + "version": "3.2.11", + "resolved": "https://registry.npmjs.org/vite/-/vite-3.2.11.tgz", + "integrity": "sha512-K/jGKL/PgbIgKCiJo5QbASQhFiV02X9Jh+Qq0AKCRCRKZtOTVi4t6wh75FDpGf2N9rYOnzH87OEFQNaFy6pdxQ==", "devOptional": true, - "license": "MIT", "dependencies": { "esbuild": "^0.15.9", "postcss": "^8.4.18", @@ -7145,9 +7153,10 @@ } }, "node_modules/vite-plugin-pwa/node_modules/rollup": { - "version": "3.29.4", + "version": "3.29.5", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-3.29.5.tgz", + "integrity": "sha512-GVsDdsbJzzy4S/v3dqWPJ7EfvZJfCHiDqe80IyrF59LYuP+e6U1LJoUqeuqRbwAWoMNoXivMNeNAOf5E22VA1w==", "dev": true, - "license": "MIT", "bin": { "rollup": "dist/bin/rollup" }, @@ -7159,23 +7168,6 @@ "fsevents": "~2.3.2" } }, - "node_modules/vite-plugin-static-copy": { - "version": "0.17.1", - "dev": true, - "license": "MIT", - "dependencies": { - "chokidar": "^3.5.3", - "fast-glob": "^3.2.11", - "fs-extra": "^11.1.0", - "picocolors": "^1.0.0" - }, - "engines": { - "node": "^14.18.0 || >=16.0.0" - }, - "peerDependencies": { - "vite": "^3.0.0 || ^4.0.0 || ^5.0.0" - } - }, "node_modules/vite-plugin-vuetify": { "version": "1.0.2", "devOptional": true, @@ -7229,6 +7221,12 @@ "esbuild-windows-arm64": "0.15.18" } }, + "node_modules/vscode-uri": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.0.8.tgz", + "integrity": "sha512-AyFQ0EVmsOZOlAnxoFOGOq1SQDWAB7C6aqMGS23svWAllfOaxbuFvcT8D1i8z3Gyn8fraVeZNNmN6e9bxxXkKw==", + "dev": true + }, "node_modules/vue": { "version": "3.4.27", "license": "MIT", @@ -7271,6 +7269,25 @@ "eslint": ">=6.0.0" } }, + "node_modules/vue-i18n": { + "version": "10.0.5", + "resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-10.0.5.tgz", + "integrity": "sha512-9/gmDlCblz3i8ypu/afiIc/SUIfTTE1mr0mZhb9pk70xo2csHAM9mp2gdQ3KD2O0AM3Hz/5ypb+FycTj/lHlPQ==", + "dependencies": { + "@intlify/core-base": "10.0.5", + "@intlify/shared": "10.0.5", + "@vue/devtools-api": "^6.5.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/kazupon" + }, + "peerDependencies": { + "vue": "^3.0.0" + } + }, "node_modules/vue-router": { "version": "4.3.2", "license": "MIT", @@ -7284,34 +7301,27 @@ "vue": "^3.2.0" } }, - "node_modules/vue-template-compiler": { - "version": "2.7.16", - "dev": true, - "license": "MIT", - "dependencies": { - "de-indent": "^1.0.2", - "he": "^1.2.0" - } - }, "node_modules/vue-tsc": { - "version": "1.8.27", + "version": "2.1.10", + "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-2.1.10.tgz", + "integrity": "sha512-RBNSfaaRHcN5uqVqJSZh++Gy/YUzryuv9u1aFWhsammDJXNtUiJMNoJ747lZcQ68wUQFx6E73y4FY3D8E7FGMA==", "dev": true, - "license": "MIT", "dependencies": { - "@volar/typescript": "~1.11.1", - "@vue/language-core": "1.8.27", + "@volar/typescript": "~2.4.8", + "@vue/language-core": "2.1.10", "semver": "^7.5.4" }, "bin": { "vue-tsc": "bin/vue-tsc.js" }, "peerDependencies": { - "typescript": "*" + "typescript": ">=5.0.0" } }, "node_modules/vuetify": { - "version": "3.6.5", - "license": "MIT", + "version": "3.7.4", + "resolved": "https://registry.npmjs.org/vuetify/-/vuetify-3.7.4.tgz", + "integrity": "sha512-Y8UU5wUDQXC3oz2uumPb8IOdvB4XMCxtxnmqdOc+LihNuPlkSgxIwf92ndRzbOtJFKHsggFUxpyLqpQp+A+5kg==", "engines": { "node": "^12.20 || >=14.13" }, @@ -7323,7 +7333,6 @@ "typescript": ">=4.7", "vite-plugin-vuetify": ">=1.0.0", "vue": "^3.3.0", - "vue-i18n": "^9.0.0", "webpack-plugin-vuetify": ">=2.0.0" }, "peerDependenciesMeta": { @@ -7333,9 +7342,6 @@ "vite-plugin-vuetify": { "optional": true }, - "vue-i18n": { - "optional": true - }, "webpack-plugin-vuetify": { "optional": true } @@ -7354,17 +7360,6 @@ "dev": true, "license": "BSD-2-Clause" }, - "node_modules/whatwg-encoding": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz", - "integrity": "sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==", - "dependencies": { - "iconv-lite": "0.6.3" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/whatwg-url": { "version": "7.1.0", "dev": true, @@ -7404,6 +7399,11 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==" + }, "node_modules/which-typed-array": { "version": "1.1.15", "dev": true, @@ -7737,6 +7737,19 @@ "workbox-core": "6.6.0" } }, + "node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wrappy": { "version": "1.0.2", "dev": true, @@ -7794,11 +7807,105 @@ "version": "2.20.3", "license": "MIT" }, + "node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==" + }, "node_modules/yallist": { "version": "3.1.1", "dev": true, "license": "ISC" }, + "node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/yargs-parser/node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "engines": { + "node": ">=6" + } + }, + "node_modules/yargs/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yargs/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "dev": true, diff --git a/frontend/package.json b/frontend/package.json index c0f3e7c68..dc5633b93 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -4,7 +4,7 @@ "version": "0.0.1", "author": "Zurdi ", "description": "A beautiful, powerful, self-hosted rom manager", - "license": "GPL-3.0-only", + "license": "AGPL-3.0-only", "homepage": "https://github.com/rommapp/romm", "repository": { "type": "git", @@ -23,17 +23,14 @@ "build": "npm run typecheck && vite build", "preview": "vite preview", "lint": "eslint . --fix", - "postinstall": "cd node_modules/emulatorjs/data/minify/ && npm i && npm run build", "typecheck": "vue-tsc --noEmit", "generate": "openapi --input http://127.0.0.1:5000/openapi.json --output ./src/__generated__ --client axios --useOptions --useUnionTypes --exportServices false --exportSchemas false --exportCore false" }, "dependencies": { "@mdi/font": "7.0.96", - "@ruffle-rs/ruffle": "^0.1.0-nightly.2024.7.29", "axios": "^1.7.4", "core-js": "^3.37.1", "cronstrue": "^2.50.0", - "emulatorjs": "github:emulatorjs/emulatorjs#v4.1.1", "file-saver": "^2.0.5", "js-cookie": "^3.0.5", "jszip": "^3.10.1", @@ -42,12 +39,14 @@ "mitt": "^3.0.1", "nanoid": "^5.0.7", "pinia": "^2.1.7", + "qrcode": "^1.5.4", "roboto-fontface": "^0.10.0", "semver": "^7.6.2", "socket.io-client": "^4.7.5", "vue": "^3.4.27", + "vue-i18n": "^10.0.5", "vue-router": "^4.3.2", - "vuetify": "^3.6.5", + "vuetify": "^3.7.4", "webfontloader": "^1.6.28" }, "devDependencies": { @@ -57,6 +56,7 @@ "@types/js-cookie": "^3.0.6", "@types/lodash": "^4.17.1", "@types/node": "^20.12.12", + "@types/qrcode": "^1.5.5", "@types/semver": "^7.5.8", "@types/webfontloader": "^1.6.38", "@vitejs/plugin-vue": "^3.2.0", @@ -70,11 +70,10 @@ "tslib": "^2.6.2", "typescript": "^5.4.5", "typescript-eslint": "^7.11.0", - "vite": "^3.2.10", + "vite": "^3.2.11", "vite-plugin-pwa": "^0.14.7", - "vite-plugin-static-copy": "0.17.1", "vite-plugin-vuetify": "^1.0.2", - "vue-tsc": "^1.8.27" + "vue-tsc": "^2.1.10" }, "engines": { "node": "18" diff --git a/frontend/src/App.vue b/frontend/src/App.vue deleted file mode 100644 index 001e2c1ff..000000000 --- a/frontend/src/App.vue +++ /dev/null @@ -1,48 +0,0 @@ - - - diff --git a/frontend/src/RomM.vue b/frontend/src/RomM.vue new file mode 100644 index 000000000..ce9f39188 --- /dev/null +++ b/frontend/src/RomM.vue @@ -0,0 +1,25 @@ + + diff --git a/frontend/src/__generated__/index.ts b/frontend/src/__generated__/index.ts index fc118bb7d..a0827aeb4 100644 --- a/frontend/src/__generated__/index.ts +++ b/frontend/src/__generated__/index.ts @@ -17,6 +17,7 @@ export type { CollectionSchema } from './models/CollectionSchema'; export type { ConfigResponse } from './models/ConfigResponse'; export type { DetailedRomSchema } from './models/DetailedRomSchema'; export type { EmulationDict } from './models/EmulationDict'; +export type { FilesystemDict } from './models/FilesystemDict'; export type { FirmwareSchema } from './models/FirmwareSchema'; export type { FrontendDict } from './models/FrontendDict'; export type { HeartbeatResponse } from './models/HeartbeatResponse'; @@ -27,6 +28,7 @@ export type { IGDBRelatedGame } from './models/IGDBRelatedGame'; export type { MessageResponse } from './models/MessageResponse'; export type { MetadataSourcesDict } from './models/MetadataSourcesDict'; export type { MobyMetadataPlatform } from './models/MobyMetadataPlatform'; +export type { OIDCDict } from './models/OIDCDict'; export type { PlatformSchema } from './models/PlatformSchema'; export type { Role } from './models/Role'; export type { RomFile } from './models/RomFile'; @@ -43,6 +45,7 @@ export type { SearchRomSchema } from './models/SearchRomSchema'; export type { SimpleRomSchema } from './models/SimpleRomSchema'; export type { StateSchema } from './models/StateSchema'; export type { StatsReturn } from './models/StatsReturn'; +export type { SystemDict } from './models/SystemDict'; export type { TaskDict } from './models/TaskDict'; export type { TinfoilFeedFileSchema } from './models/TinfoilFeedFileSchema'; export type { TinfoilFeedSchema } from './models/TinfoilFeedSchema'; diff --git a/frontend/src/__generated__/models/ConfigResponse.ts b/frontend/src/__generated__/models/ConfigResponse.ts index 71f01543b..4b60f7097 100644 --- a/frontend/src/__generated__/models/ConfigResponse.ts +++ b/frontend/src/__generated__/models/ConfigResponse.ts @@ -12,8 +12,5 @@ export type ConfigResponse = { EXCLUDED_MULTI_PARTS_FILES: Array; PLATFORMS_BINDING: Record; PLATFORMS_VERSIONS: Record; - ROMS_FOLDER_NAME: string; - FIRMWARE_FOLDER_NAME: string; - HIGH_PRIO_STRUCTURE_PATH: string; }; diff --git a/frontend/src/__generated__/models/DetailedRomSchema.ts b/frontend/src/__generated__/models/DetailedRomSchema.ts index c7517e44f..82a6948c1 100644 --- a/frontend/src/__generated__/models/DetailedRomSchema.ts +++ b/frontend/src/__generated__/models/DetailedRomSchema.ts @@ -21,7 +21,10 @@ export type DetailedRomSchema = { moby_id: (number | null); platform_id: number; platform_slug: string; + platform_fs_slug: string; platform_name: string; + platform_custom_name: (string | null); + platform_display_name: string; file_name: string; file_name_no_tags: string; file_name_no_ext: string; diff --git a/frontend/src/__generated__/models/FilesystemDict.ts b/frontend/src/__generated__/models/FilesystemDict.ts new file mode 100644 index 000000000..937053614 --- /dev/null +++ b/frontend/src/__generated__/models/FilesystemDict.ts @@ -0,0 +1,9 @@ +/* generated using openapi-typescript-codegen -- do no edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ + +export type FilesystemDict = { + FS_PLATFORMS: Array; +}; + diff --git a/frontend/src/__generated__/models/HeartbeatResponse.ts b/frontend/src/__generated__/models/HeartbeatResponse.ts index 7597372bd..dadf2d256 100644 --- a/frontend/src/__generated__/models/HeartbeatResponse.ts +++ b/frontend/src/__generated__/models/HeartbeatResponse.ts @@ -4,20 +4,22 @@ /* eslint-disable */ import type { EmulationDict } from './EmulationDict'; +import type { FilesystemDict } from './FilesystemDict'; import type { FrontendDict } from './FrontendDict'; import type { MetadataSourcesDict } from './MetadataSourcesDict'; +import type { OIDCDict } from './OIDCDict'; import type { SchedulerDict } from './SchedulerDict'; +import type { SystemDict } from './SystemDict'; import type { WatcherDict } from './WatcherDict'; export type HeartbeatResponse = { - VERSION: string; - SHOW_SETUP_WIZARD: boolean; + SYSTEM: SystemDict; WATCHER: WatcherDict; SCHEDULER: SchedulerDict; - ANY_SOURCE_ENABLED: boolean; METADATA_SOURCES: MetadataSourcesDict; - FS_PLATFORMS: Array; + FILESYSTEM: FilesystemDict; EMULATION: EmulationDict; FRONTEND: FrontendDict; + OIDC: OIDCDict; }; diff --git a/frontend/src/__generated__/models/MetadataSourcesDict.ts b/frontend/src/__generated__/models/MetadataSourcesDict.ts index 69b957236..2888a84ed 100644 --- a/frontend/src/__generated__/models/MetadataSourcesDict.ts +++ b/frontend/src/__generated__/models/MetadataSourcesDict.ts @@ -4,6 +4,7 @@ /* eslint-disable */ export type MetadataSourcesDict = { + ANY_SOURCE_ENABLED: boolean; IGDB_API_ENABLED: boolean; MOBY_API_ENABLED: boolean; STEAMGRIDDB_ENABLED: boolean; diff --git a/frontend/src/__generated__/models/OIDCDict.ts b/frontend/src/__generated__/models/OIDCDict.ts new file mode 100644 index 000000000..965bba101 --- /dev/null +++ b/frontend/src/__generated__/models/OIDCDict.ts @@ -0,0 +1,10 @@ +/* generated using openapi-typescript-codegen -- do no edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ + +export type OIDCDict = { + ENABLED: boolean; + PROVIDER: string; +}; + diff --git a/frontend/src/__generated__/models/PlatformSchema.ts b/frontend/src/__generated__/models/PlatformSchema.ts index 822c036ad..e480df306 100644 --- a/frontend/src/__generated__/models/PlatformSchema.ts +++ b/frontend/src/__generated__/models/PlatformSchema.ts @@ -9,14 +9,23 @@ export type PlatformSchema = { id: number; slug: string; fs_slug: string; - name: string; rom_count: number; + name: string; + custom_name?: (string | null); igdb_id?: (number | null); sgdb_id?: (number | null); moby_id?: (number | null); + category?: (string | null); + generation?: (number | null); + family_name?: (string | null); + family_slug?: (string | null); + url?: (string | null); + url_logo?: (string | null); logo_path?: (string | null); firmware?: Array; + aspect_ratio?: string; created_at: string; updated_at: string; + readonly display_name: string; }; diff --git a/frontend/src/__generated__/models/RomSchema.ts b/frontend/src/__generated__/models/RomSchema.ts index a29ed3217..c6f09f7e7 100644 --- a/frontend/src/__generated__/models/RomSchema.ts +++ b/frontend/src/__generated__/models/RomSchema.ts @@ -14,7 +14,10 @@ export type RomSchema = { moby_id: (number | null); platform_id: number; platform_slug: string; + platform_fs_slug: string; platform_name: string; + platform_custom_name: (string | null); + platform_display_name: string; file_name: string; file_name_no_tags: string; file_name_no_ext: string; diff --git a/frontend/src/__generated__/models/RomUserSchema.ts b/frontend/src/__generated__/models/RomUserSchema.ts index bc226a6a2..cddeda339 100644 --- a/frontend/src/__generated__/models/RomUserSchema.ts +++ b/frontend/src/__generated__/models/RomUserSchema.ts @@ -11,6 +11,7 @@ export type RomUserSchema = { rom_id: number; created_at: string; updated_at: string; + last_played: (string | null); note_raw_markdown: string; note_is_public: boolean; is_main_sibling: boolean; diff --git a/frontend/src/__generated__/models/SearchRomSchema.ts b/frontend/src/__generated__/models/SearchRomSchema.ts index 8b5a102a3..efff5ede7 100644 --- a/frontend/src/__generated__/models/SearchRomSchema.ts +++ b/frontend/src/__generated__/models/SearchRomSchema.ts @@ -11,5 +11,6 @@ export type SearchRomSchema = { summary: string; igdb_url_cover?: string; moby_url_cover?: string; + platform_id: number; }; diff --git a/frontend/src/__generated__/models/SimpleRomSchema.ts b/frontend/src/__generated__/models/SimpleRomSchema.ts index 43ecf715b..1fd9787df 100644 --- a/frontend/src/__generated__/models/SimpleRomSchema.ts +++ b/frontend/src/__generated__/models/SimpleRomSchema.ts @@ -16,7 +16,10 @@ export type SimpleRomSchema = { moby_id: (number | null); platform_id: number; platform_slug: string; + platform_fs_slug: string; platform_name: string; + platform_custom_name: (string | null); + platform_display_name: string; file_name: string; file_name_no_tags: string; file_name_no_ext: string; diff --git a/frontend/src/__generated__/models/SystemDict.ts b/frontend/src/__generated__/models/SystemDict.ts new file mode 100644 index 000000000..c0c1e4938 --- /dev/null +++ b/frontend/src/__generated__/models/SystemDict.ts @@ -0,0 +1,10 @@ +/* generated using openapi-typescript-codegen -- do no edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ + +export type SystemDict = { + VERSION: string; + SHOW_SETUP_WIZARD: boolean; +}; + diff --git a/frontend/src/__generated__/models/UserSchema.ts b/frontend/src/__generated__/models/UserSchema.ts index 62ef604c9..37399730d 100644 --- a/frontend/src/__generated__/models/UserSchema.ts +++ b/frontend/src/__generated__/models/UserSchema.ts @@ -8,6 +8,7 @@ import type { Role } from './Role'; export type UserSchema = { id: number; username: string; + email: (string | null); enabled: boolean; role: Role; oauth_scopes: Array; diff --git a/frontend/src/components/Details/ActionBar.vue b/frontend/src/components/Details/ActionBar.vue index a0d166512..44945d762 100644 --- a/frontend/src/components/Details/ActionBar.vue +++ b/frontend/src/components/Details/ActionBar.vue @@ -1,17 +1,19 @@ diff --git a/frontend/src/components/Details/AdditionalContent.vue b/frontend/src/components/Details/AdditionalContent.vue index 24e6ab483..294e65875 100644 --- a/frontend/src/components/Details/AdditionalContent.vue +++ b/frontend/src/components/Details/AdditionalContent.vue @@ -11,27 +11,8 @@ const combined = computed(() => [ - diff --git a/frontend/src/components/Details/Info/FileInfo.vue b/frontend/src/components/Details/Info/FileInfo.vue index 551154f5f..72b0cd701 100644 --- a/frontend/src/components/Details/Info/FileInfo.vue +++ b/frontend/src/components/Details/Info/FileInfo.vue @@ -1,26 +1,25 @@