diff --git a/.cursor/rules/api-conventions.mdc b/.cursor/rules/api-conventions.mdc new file mode 100644 index 0000000..0f76edf --- /dev/null +++ b/.cursor/rules/api-conventions.mdc @@ -0,0 +1,32 @@ +--- +description: REST API запросы — создавать функции в furumiApi.ts (только furumi-node-player/client) +globs: furumi-node-player/client/**/*.{ts,tsx} +alwaysApply: false +--- + +# REST API в furumi-node-player + +**Область действия:** правило применяется только к проекту `furumi-node-player/client/`. В остальных частях репозитория можно использовать другой подход. + +Чтобы выполнить REST API запрос, нужно создать функцию, которая принимает необходимые параметры и вызывает `furumiApi`. Все такие функции должны находиться в файле `furumi-node-player/client/src/furumiApi.ts`. + +## Правила + +1. **Не вызывать `furumiApi` напрямую** из компонентов или других модулей. +2. **Добавлять новую функцию** в `furumiApi.ts` для каждого эндпоинта. +3. Функция принимает нужные параметры и возвращает `Promise` с данными (или `null` при ошибке). + +## Пример + +```typescript +// furumiApi.ts — добавлять сюда +export async function getSomething(id: string) { + const res = await furumiApi.get(`/something/${id}`).catch(() => null) + return res?.data ?? null +} +``` + +```typescript +// FurumiPlayer.tsx — использовать функцию, не furumiApi напрямую +const data = await getSomething(id) +``` diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 08a10e2..100a9a8 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -53,6 +53,7 @@ services: FURUMI_PLAYER_DATABASE_URL: "postgres://${POSTGRES_USER:-furumi}:${POSTGRES_PASSWORD:-furumi}@db:5432/${POSTGRES_DB:-furumi}" FURUMI_PLAYER_STORAGE_DIR: "/storage" FURUMI_PLAYER_BIND: "0.0.0.0:8085" + FURUMI_PLAYER_API_KEY: "node-player-api-key" volumes: - ./storage:/storage restart: always diff --git a/furumi-node-player/client/.env.example b/furumi-node-player/client/.env.example new file mode 100644 index 0000000..2312cf7 --- /dev/null +++ b/furumi-node-player/client/.env.example @@ -0,0 +1,2 @@ +VITE_API_BASE_URL=http://localhost:8085 +VITE_API_KEY= \ No newline at end of file diff --git a/furumi-node-player/client/package-lock.json b/furumi-node-player/client/package-lock.json index e47f6c3..6286599 100644 --- a/furumi-node-player/client/package-lock.json +++ b/furumi-node-player/client/package-lock.json @@ -8,8 +8,11 @@ "name": "client", "version": "0.0.0", "dependencies": { + "@reduxjs/toolkit": "^2.11.2", + "axios": "^1.7.9", "react": "^19.2.4", - "react-dom": "^19.2.4" + "react-dom": "^19.2.4", + "react-redux": "^9.2.0" }, "devDependencies": { "@eslint/js": "^9.39.4", @@ -586,6 +589,32 @@ "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/@reduxjs/toolkit": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", + "integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.0.0-rc.10", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.10.tgz", @@ -848,6 +877,18 @@ "dev": true, "license": "MIT" }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, "node_modules/@tybys/wasm-util": { "version": "0.10.1", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", @@ -887,7 +928,7 @@ "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -903,6 +944,12 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.57.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.1.tgz", @@ -1287,6 +1334,23 @@ "dev": true, "license": "Python-2.0" }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.13.6", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz", + "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^1.1.0" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -1352,6 +1416,19 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -1420,6 +1497,18 @@ "dev": true, "license": "MIT" }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -1453,7 +1542,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/debug": { @@ -1481,6 +1570,15 @@ "dev": true, "license": "MIT" }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -1491,6 +1589,20 @@ "node": ">=8" } }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.321", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.321.tgz", @@ -1498,6 +1610,51 @@ "dev": true, "license": "ISC" }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -1795,6 +1952,42 @@ "dev": true, "license": "ISC" }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1810,6 +2003,15 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -1820,6 +2022,43 @@ "node": ">=6.9.0" } }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -1846,6 +2085,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -1856,6 +2107,45 @@ "node": ">=8" } }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/hermes-estree": { "version": "0.25.1", "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", @@ -1883,6 +2173,16 @@ "node": ">= 4" } }, + "node_modules/immer": { + "version": "11.1.4", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz", + "integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -2325,6 +2625,36 @@ "yallist": "^3.0.2" } }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/minimatch": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", @@ -2520,6 +2850,12 @@ "node": ">= 0.8.0" } }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -2551,6 +2887,50 @@ "react": "^19.2.4" } }, + "node_modules/react-redux": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", + "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, + "node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "license": "MIT" + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -2814,6 +3194,15 @@ "punycode": "^2.1.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/vite": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.1.tgz", diff --git a/furumi-node-player/client/package.json b/furumi-node-player/client/package.json index c8a5d57..9d6d481 100644 --- a/furumi-node-player/client/package.json +++ b/furumi-node-player/client/package.json @@ -10,8 +10,11 @@ "preview": "vite preview" }, "dependencies": { + "@reduxjs/toolkit": "^2.11.2", + "axios": "^1.7.9", "react": "^19.2.4", - "react-dom": "^19.2.4" + "react-dom": "^19.2.4", + "react-redux": "^9.2.0" }, "devDependencies": { "@eslint/js": "^9.39.4", diff --git a/furumi-node-player/client/src/App.tsx b/furumi-node-player/client/src/App.tsx index a0245dd..51a08a4 100644 --- a/furumi-node-player/client/src/App.tsx +++ b/furumi-node-player/client/src/App.tsx @@ -61,12 +61,11 @@ function App() { const loginUrl = `${apiBase}/api/login` const logoutUrl = `${apiBase}/api/logout` - const playerApiRoot = `${apiBase}/api` return ( <> {!loading && (user || runWithoutAuth) ? ( - + ) : (
diff --git a/furumi-node-player/client/src/FurumiPlayer.tsx b/furumi-node-player/client/src/FurumiPlayer.tsx index e6218a9..96c41d2 100644 --- a/furumi-node-player/client/src/FurumiPlayer.tsx +++ b/furumi-node-player/client/src/FurumiPlayer.tsx @@ -1,24 +1,34 @@ import { useEffect, useRef, useState, type MouseEvent as ReactMouseEvent } from 'react' import './furumi-player.css' -import { createFurumiApiClient } from './furumiApi' -import { SearchDropdown } from './components/SearchDropdown' -import { Breadcrumbs } from './components/Breadcrumbs' -import { LibraryList } from './components/LibraryList' -import { QueueList, type QueueItem } from './components/QueueList' -import { NowPlaying } from './components/NowPlaying' +import { + API_ROOT, + searchTracks, + preloadStream, +} from './furumiApi' +import { useAppDispatch, useAppSelector } from './store' +import { fetchArtists } from './store/slices/artistsSlice' +import { fetchArtistAlbums } from './store/slices/albumsSlice' +import { fetchArtistTracks } from './store/slices/artistTracksSlice' +import { fetchAlbumTracks } from './store/slices/albumTracksSlice' +import { fetchTrackDetail } from './store/slices/trackDetailSlice' +import { fmt } from './utils' +import { Header } from './components/Header' +import { MainPanel, type Crumb } from './components/MainPanel' +import { PlayerBar } from './components/PlayerBar' +import type { QueueItem } from './components/QueueList' +import type { Track } from './types' -type FurumiPlayerProps = { - apiRoot: string -} - -type Crumb = { label: string; action?: () => void } - -export function FurumiPlayer({ apiRoot }: FurumiPlayerProps) { - const [breadcrumbs, setBreadcrumbs] = useState void }>>( +export function FurumiPlayer() { + const dispatch = useAppDispatch() + const artistsLoading = useAppSelector((s) => s.artists.loading) + const artistsError = useAppSelector((s) => s.artists.error) + const albumsLoading = useAppSelector((s) => s.albums.loading) + const albumsError = useAppSelector((s) => s.albums.error) + const albumTracksLoading = useAppSelector((s) => s.albumTracks.loading) + const albumTracksError = useAppSelector((s) => s.albumTracks.error) + const [breadcrumbs, setBreadcrumbs] = useState( [], ) - const [libraryLoading, setLibraryLoading] = useState(false) - const [libraryError, setLibraryError] = useState(null) const [libraryItems, setLibraryItems] = useState< Array<{ key: string @@ -42,6 +52,7 @@ export function FurumiPlayer({ apiRoot }: FurumiPlayerProps) { const [queueOrderView, setQueueOrderView] = useState([]) const [queuePlayingOrigIdxView, setQueuePlayingOrigIdxView] = useState(-1) const [queueScrollSignal, setQueueScrollSignal] = useState(0) + const [queue, setQueue] = useState([]) const queueActionsRef = useRef<{ playIndex: (i: number) => void @@ -49,12 +60,14 @@ export function FurumiPlayer({ apiRoot }: FurumiPlayerProps) { moveQueueItem: (fromPos: number, toPos: number) => void } | null>(null) + const audioRef = useRef(null) + useEffect(() => { // --- Original player script adapted for React environment --- - const audio = document.getElementById('audioEl') as HTMLAudioElement - if (!audio) return + const audioEl = audioRef.current + if (!audioEl) return + const audio = audioEl - let queue: QueueItem[] = [] let queueIndex = -1 let shuffle = false let repeatAll = true @@ -106,32 +119,24 @@ export function FurumiPlayer({ apiRoot }: FurumiPlayerProps) { nextTrack() }) - // --- API helper --- - const API = apiRoot - const api = createFurumiApiClient(API) - // --- Library navigation --- async function showArtists() { setBreadcrumb([{ label: 'Artists', action: showArtists }]) - setLibraryLoading(true) - setLibraryError(null) - const artists = await api('/artists') - if (!artists) { - setLibraryLoading(false) - setLibraryError('Error') - return + try { + const artists = await dispatch(fetchArtists()).unwrap() + setLibraryItems( + artists.map((a) => ({ + key: `artist:${a.slug}`, + className: 'file-item dir', + icon: '👤', + name: a.name, + detail: `${a.album_count} albums`, + onClick: () => void showArtistAlbums(a.slug, a.name), + })), + ) + } catch { + // Error is stored in artists.error } - setLibraryLoading(false) - setLibraryItems( - (artists as any[]).map((a) => ({ - key: `artist:${a.slug}`, - className: 'file-item dir', - icon: '👤', - name: a.name, - detail: `${a.album_count} albums`, - onClick: () => void showArtistAlbums(a.slug, a.name), - })), - ) } async function showArtistAlbums(artistSlug: string, artistName: string) { @@ -139,42 +144,38 @@ export function FurumiPlayer({ apiRoot }: FurumiPlayerProps) { { label: 'Artists', action: showArtists }, { label: artistName, action: () => showArtistAlbums(artistSlug, artistName) }, ]) - setLibraryLoading(true) - setLibraryError(null) - const albums = await api('/artists/' + artistSlug + '/albums') - if (!albums) { - setLibraryLoading(false) - setLibraryError('Error') - return - } - setLibraryLoading(false) - const allTracksItem = { - key: `artist-all:${artistSlug}`, - className: 'file-item', - icon: '▶', - name: 'Play all tracks', - nameClassName: 'name', - onClick: () => void playAllArtistTracks(artistSlug), - } - const albumItems = (albums as any[]).map((a) => { - const year = a.year ? ` (${a.year})` : '' - return { - key: `album:${a.slug}`, - className: 'file-item dir', - icon: '💿', - name: `${a.name}${year}`, - detail: `${a.track_count} tracks`, - onClick: () => void showAlbumTracks(a.slug, a.name, artistSlug, artistName), - button: { - title: 'Add album to queue', - onClick: (ev: ReactMouseEvent) => { - ev.stopPropagation() - void addAlbumToQueue(a.slug) - }, - }, + try { + const { albums } = await dispatch(fetchArtistAlbums(artistSlug)).unwrap() + const allTracksItem = { + key: `artist-all:${artistSlug}`, + className: 'file-item', + icon: '▶', + name: 'Play all tracks', + nameClassName: 'name', + onClick: () => void playAllArtistTracks(artistSlug), } - }) - setLibraryItems([allTracksItem, ...albumItems]) + const albumItems = albums.map((a) => { + const year = a.year ? ` (${a.year})` : '' + return { + key: `album:${a.slug}`, + className: 'file-item dir', + icon: '💿', + name: `${a.name}${year}`, + detail: `${a.track_count} tracks`, + onClick: () => void showAlbumTracks(a.slug, a.name, artistSlug, artistName), + button: { + title: 'Add album to queue', + onClick: (ev: ReactMouseEvent) => { + ev.stopPropagation() + void addAlbumToQueue(a.slug) + }, + }, + } + }) + setLibraryItems([allTracksItem, ...albumItems]) + } catch { + // Error is stored in albums.error + } } async function showAlbumTracks( @@ -188,15 +189,9 @@ export function FurumiPlayer({ apiRoot }: FurumiPlayerProps) { { label: artistName, action: () => showArtistAlbums(artistSlug, artistName) }, { label: albumName }, ]) - setLibraryLoading(true) - setLibraryError(null) - const tracks = await api('/albums/' + albumSlug) - if (!tracks) { - setLibraryLoading(false) - setLibraryError('Error') - return - } - setLibraryLoading(false) + const result = await dispatch(fetchAlbumTracks(albumSlug)) + if (result.meta.requestStatus === 'rejected') return + const { tracks } = result.payload as { albumSlug: string; tracks: Track[] } const playAlbumItem = { key: `album-play:${albumSlug}`, className: 'file-item', @@ -206,7 +201,7 @@ export function FurumiPlayer({ apiRoot }: FurumiPlayerProps) { void addAlbumToQueue(albumSlug, true) }, } - const trackItems = (tracks as any[]).map((t) => { + const trackItems = tracks.map((t) => { const num = t.track_number ? `${t.track_number}. ` : '' const dur = t.duration_secs ? fmt(t.duration_secs) : '' return { @@ -252,7 +247,7 @@ export function FurumiPlayer({ apiRoot }: FurumiPlayerProps) { if (playNow) playIndex(existing) return } - queue.push(track) + setQueue((q) => [...q, track]); updateQueueModel() if (playNow || (queueIndex === -1 && queue.length === 1)) { playIndex(queue.length - 1) @@ -260,19 +255,24 @@ export function FurumiPlayer({ apiRoot }: FurumiPlayerProps) { } async function addAlbumToQueue(albumSlug: string, playFirst?: boolean) { - const tracks = await api('/albums/' + albumSlug) - if (!tracks || !(tracks as any[]).length) return - const list = tracks as any[] + const result = await dispatch(fetchAlbumTracks(albumSlug)) + if (result.meta.requestStatus === 'rejected') return + const { tracks } = result.payload as { albumSlug: string; tracks: Track[] } + if (!tracks || !tracks.length) return + const list = tracks let firstIdx = queue.length list.forEach((t) => { if (queue.find((q) => q.slug === t.slug)) return - queue.push({ - slug: t.slug, - title: t.title, - artist: t.artist_name, - album_slug: albumSlug, - duration: t.duration_secs, - }) + setQueue((q) => [ + ...q, + { + slug: t.slug, + title: t.title, + artist: t.artist_name, + album_slug: t.album_slug, + duration: t.duration_secs, + }, + ]) }) updateQueueModel() if (playFirst || queueIndex === -1) playIndex(firstIdx) @@ -280,19 +280,19 @@ export function FurumiPlayer({ apiRoot }: FurumiPlayerProps) { } async function playAllArtistTracks(artistSlug: string) { - const tracks = await api('/artists/' + artistSlug + '/tracks') - if (!tracks || !(tracks as any[]).length) return - const list = tracks as any[] + const result = await dispatch(fetchArtistTracks(artistSlug)) + if (result.meta.requestStatus === 'rejected') return + const { tracks } = result.payload as { artistSlug: string; tracks: Track[] } + if (!tracks || !tracks.length) return + const list = tracks clearQueue() - list.forEach((t) => { - queue.push({ - slug: t.slug, - title: t.title, - artist: t.artist_name, - album_slug: t.album_slug, - duration: t.duration_secs, - }) - }) + setQueue(list.map((t) => ({ + slug: t.slug, + title: t.title, + artist: t.artist_name, + album_slug: t.album_slug, + duration: t.duration_secs, + }))) updateQueueModel() playIndex(0) showToast(`Added ${list.length} tracks`) @@ -302,7 +302,7 @@ export function FurumiPlayer({ apiRoot }: FurumiPlayerProps) { if (i < 0 || i >= queue.length) return queueIndex = i const track = queue[i] - audio.src = `${API}/stream/${track.slug}` + audio.src = `${API_ROOT}/stream/${track.slug}` void audio.play().catch(() => {}) updateNowPlaying(track) updateQueueModel() @@ -320,7 +320,7 @@ export function FurumiPlayer({ apiRoot }: FurumiPlayerProps) { document.title = `${track.title} — Furumi` - const coverUrl = `${API}/tracks/${track.slug}/cover` + const coverUrl = `${API_ROOT}/tracks/${track.slug}/cover` if ('mediaSession' in navigator) { // eslint-disable-next-line @typescript-eslint/ban-ts-comment navigator.mediaSession.metadata = new window.MediaMetadata({ @@ -355,7 +355,7 @@ export function FurumiPlayer({ apiRoot }: FurumiPlayerProps) { function updateQueueModel() { const order = currentOrder() - setQueueItemsView(queue.slice()) + setQueueItemsView(queue) setQueueOrderView(order.slice()) setQueuePlayingOrigIdxView(queueIndex) } @@ -369,7 +369,10 @@ export function FurumiPlayer({ apiRoot }: FurumiPlayerProps) { } else if (queueIndex > idx) { queueIndex-- } - queue.splice(idx, 1) + + // queue.splice(idx, 1) + setQueue((q) => q.filter((_, i) => i !== idx)); + if (shuffle) { const si = shuffleOrder.indexOf(idx) if (si !== -1) shuffleOrder.splice(si, 1) @@ -402,7 +405,7 @@ export function FurumiPlayer({ apiRoot }: FurumiPlayerProps) { } function clearQueue() { - queue = [] + setQueue([]); queueIndex = -1 shuffleOrder = [] audio.pause() @@ -495,7 +498,7 @@ export function FurumiPlayer({ apiRoot }: FurumiPlayerProps) { return } searchTimer = window.setTimeout(async () => { - const results = await api('/search?q=' + encodeURIComponent(q)) + const results = await searchTracks(q) if (!results || !(results as any[]).length) { closeSearch() return @@ -519,27 +522,12 @@ export function FurumiPlayer({ apiRoot }: FurumiPlayerProps) { { slug, title: '', artist: '', album_slug: null, duration: null }, true, ) - void api('/stream/' + slug).catch(() => null) + void preloadStream(slug) } } searchSelectRef.current = onSearchSelect // --- Helpers --- - function fmt(secs: number) { - if (!secs || Number.isNaN(secs)) return '0:00' - const s = Math.floor(secs) - const m = Math.floor(s / 60) - const h = Math.floor(m / 60) - if (h > 0) { - return `${h}:${pad(m % 60)}:${pad(s % 60)}` - } - return `${m}:${pad(s % 60)}` - } - - function pad(n: number) { - return String(n).padStart(2, '0') - } - function showToast(msg: string) { const t = document.getElementById('toast') if (!t) return @@ -625,18 +613,20 @@ export function FurumiPlayer({ apiRoot }: FurumiPlayerProps) { const url = new URL(window.location.href) const urlSlug = url.searchParams.get('t') if (urlSlug) { - const info = await api('/tracks/' + urlSlug) - if (info) { + try { + const { detail } = await dispatch(fetchTrackDetail(urlSlug)).unwrap() addTrackToQueue( { - slug: (info as any).slug, - title: (info as any).title, - artist: (info as any).artist_name, - album_slug: (info as any).album_slug, - duration: (info as any).duration_secs, + slug: detail.slug, + title: detail.title, + artist: detail.artist_name, + album_slug: detail.album_slug, + duration: detail.duration_secs, }, true, ) + } catch { + // fetchTrackDetail rejected — track not found or error } } void showArtists() @@ -647,120 +637,52 @@ export function FurumiPlayer({ apiRoot }: FurumiPlayerProps) { queueActionsRef.current = null audio.pause() } - }, [apiRoot]) + }, []) + + const libraryLoading = + breadcrumbs.length === 1 + ? artistsLoading + : breadcrumbs.length === 2 + ? albumsLoading + : albumTracksLoading + + const libraryError = + breadcrumbs.length === 1 + ? artistsError + : breadcrumbs.length === 2 + ? albumsError + : albumTracksError return (
-
-
- - - - - - - Furumi - v -
-
-
- - searchSelectRef.current(type, slug)} - /> -
-
-
+
searchSelectRef.current(type, slug)} + /> -
-
- + queueActionsRef.current?.playIndex(origIdx)} + onQueueRemove={(origIdx) => + queueActionsRef.current?.removeFromQueue(origIdx) + } + onQueueMove={(fromPos, toPos) => + queueActionsRef.current?.moveQueueItem(fromPos, toPos) + } + /> -
-
- Queue -
- - - -
-
-
- queueActionsRef.current?.playIndex(origIdx)} - onRemove={(origIdx) => - queueActionsRef.current?.removeFromQueue(origIdx) - } - onMove={(fromPos, toPos) => - queueActionsRef.current?.moveQueueItem(fromPos, toPos) - } - /> -
-
-
- -
- -
-
- - - -
-
- - 0:00 - -
-
-
- - 0:00 - -
-
-
- - 🔊 - - -
-
+
-
) } diff --git a/furumi-node-player/client/src/components/Header.tsx b/furumi-node-player/client/src/components/Header.tsx new file mode 100644 index 0000000..1cd5708 --- /dev/null +++ b/furumi-node-player/client/src/components/Header.tsx @@ -0,0 +1,45 @@ +import { SearchDropdown } from './SearchDropdown' + +type SearchResultItem = { + result_type: string + slug: string + name: string + detail?: string +} + +type HeaderProps = { + searchOpen: boolean + searchResults: SearchResultItem[] + onSearchSelect: (type: string, slug: string) => void +} + +export function Header({ + searchOpen, + searchResults, + onSearchSelect, +}: HeaderProps) { + return ( +
+
+ + + + + + + Furumi + v +
+
+
+ + +
+
+
+ ) +} diff --git a/furumi-node-player/client/src/components/MainPanel.tsx b/furumi-node-player/client/src/components/MainPanel.tsx new file mode 100644 index 0000000..7601279 --- /dev/null +++ b/furumi-node-player/client/src/components/MainPanel.tsx @@ -0,0 +1,86 @@ +import type { MouseEvent as ReactMouseEvent } from 'react' +import { Breadcrumbs } from './Breadcrumbs' +import { LibraryList } from './LibraryList' +import { QueueList, type QueueItem } from './QueueList' + +export type Crumb = { label: string; action?: () => void } + +export type LibraryListItem = { + key: string + className: string + icon: string + name: string + detail?: string + nameClassName?: string + onClick: () => void + button?: { title: string; onClick: (ev: ReactMouseEvent) => void } +} + +type MainPanelProps = { + breadcrumbs: Crumb[] + libraryLoading: boolean + libraryError: string | null + libraryItems: LibraryListItem[] + queueItemsView: QueueItem[] + queueOrderView: number[] + queuePlayingOrigIdxView: number + queueScrollSignal: number + onQueuePlay: (origIdx: number) => void + onQueueRemove: (origIdx: number) => void + onQueueMove: (fromPos: number, toPos: number) => void +} + +export function MainPanel({ + breadcrumbs, + libraryLoading, + libraryError, + libraryItems, + queueItemsView, + queueOrderView, + queuePlayingOrigIdxView, + queueScrollSignal, + onQueuePlay, + onQueueRemove, + onQueueMove, +}: MainPanelProps) { + return ( +
+
+ + +
+
+ Queue +
+ + + +
+
+
+ +
+
+
+ ) +} diff --git a/furumi-node-player/client/src/components/NowPlaying.tsx b/furumi-node-player/client/src/components/NowPlaying.tsx index 175c1b1..c3275bc 100644 --- a/furumi-node-player/client/src/components/NowPlaying.tsx +++ b/furumi-node-player/client/src/components/NowPlaying.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from 'react' +import { API_ROOT } from '../furumiApi' import type { QueueItem } from './QueueList' function Cover({ src }: { src: string }) { @@ -12,7 +13,7 @@ function Cover({ src }: { src: string }) { return setErrored(true)} /> } -export function NowPlaying({ apiRoot, track }: { apiRoot: string; track: QueueItem | null }) { +export function NowPlaying({ track }: { track: QueueItem | null }) { if (!track) { return (
@@ -31,7 +32,7 @@ export function NowPlaying({ apiRoot, track }: { apiRoot: string; track: QueueIt ) } - const coverUrl = `${apiRoot}/tracks/${track.slug}/cover` + const coverUrl = `${API_ROOT}/tracks/${track.slug}/cover` return (
diff --git a/furumi-node-player/client/src/components/PlayerBar.tsx b/furumi-node-player/client/src/components/PlayerBar.tsx new file mode 100644 index 0000000..48fcdff --- /dev/null +++ b/furumi-node-player/client/src/components/PlayerBar.tsx @@ -0,0 +1,47 @@ +import { NowPlaying } from './NowPlaying' +import type { QueueItem } from './QueueList' + +export function PlayerBar({ track }: { track: QueueItem | null }) { + return ( +
+ +
+
+ + + +
+
+ + 0:00 + +
+
+
+ + 0:00 + +
+
+
+ + 🔊 + + +
+
+ ) +} diff --git a/furumi-node-player/client/src/components/QueueList.tsx b/furumi-node-player/client/src/components/QueueList.tsx index f1529fe..c371f09 100644 --- a/furumi-node-player/client/src/components/QueueList.tsx +++ b/furumi-node-player/client/src/components/QueueList.tsx @@ -1,4 +1,5 @@ import { useEffect, useRef, useState } from 'react' +import { API_ROOT } from '../furumiApi' export type QueueItem = { slug: string @@ -9,7 +10,6 @@ export type QueueItem = { } type QueueListProps = { - apiRoot: string queue: QueueItem[] order: number[] playingOrigIdx: number @@ -43,7 +43,6 @@ function Cover({ src }: { src: string }) { } export function QueueList({ - apiRoot, queue, order, playingOrigIdx, @@ -78,7 +77,7 @@ export function QueueList({ if (!t) return null const isPlaying = origIdx === playingOrigIdx - const coverSrc = t.album_slug ? `${apiRoot}/tracks/${t.slug}/cover` : '' + const coverSrc = t.album_slug ? `${API_ROOT}/tracks/${t.slug}/cover` : '' const dur = t.duration ? fmt(t.duration) : '' const isDragging = draggingPos === pos const isDragOver = dragOverPos === pos diff --git a/furumi-node-player/client/src/furumiApi.ts b/furumi-node-player/client/src/furumiApi.ts index 412b656..e7518ec 100644 --- a/furumi-node-player/client/src/furumiApi.ts +++ b/furumi-node-player/client/src/furumiApi.ts @@ -1,12 +1,49 @@ -export type FurumiApiClient = (path: string) => Promise +import axios from 'axios' +import type { Album, Artist, SearchResult, Track, TrackDetail } from './types' -export function createFurumiApiClient(apiRoot: string): FurumiApiClient { - const API = apiRoot +const API_BASE = import.meta.env.VITE_API_BASE_URL ?? '' +export const API_ROOT = `${API_BASE}/api` - return async function api(path: string) { - const r = await fetch(API + path) - if (!r.ok) return null - return r.json() - } +const API_KEY = import.meta.env.VITE_API_KEY + +export const furumiApi = axios.create({ + baseURL: API_ROOT, + headers: API_KEY ? { 'x-api-key': API_KEY } : {}, +}) + +export async function getArtists(): Promise { + const res = await furumiApi.get('/artists').catch(() => null) + return res?.data ?? null +} + +export async function getArtistAlbums(artistSlug: string): Promise { + const res = await furumiApi.get(`/artists/${artistSlug}/albums`).catch(() => null) + return res?.data ?? null +} + +export async function getAlbumTracks(albumSlug: string): Promise { + const res = await furumiApi.get(`/albums/${albumSlug}`).catch(() => null) + return res?.data ?? null +} + +export async function getArtistTracks(artistSlug: string): Promise { + const res = await furumiApi.get(`/artists/${artistSlug}/tracks`).catch(() => null) + return res?.data ?? null +} + +export async function searchTracks(query: string): Promise { + const res = await furumiApi + .get(`/search?q=${encodeURIComponent(query)}`) + .catch(() => null) + return res?.data ?? null +} + +export async function getTrackInfo(trackSlug: string): Promise { + const res = await furumiApi.get(`/tracks/${trackSlug}`).catch(() => null) + return res?.data ?? null +} + +export async function preloadStream(trackSlug: string) { + await furumiApi.get(`/stream/${trackSlug}`).catch(() => null) } diff --git a/furumi-node-player/client/src/index.css b/furumi-node-player/client/src/index.css index 8214183..ebf9a85 100644 --- a/furumi-node-player/client/src/index.css +++ b/furumi-node-player/client/src/index.css @@ -1,3 +1,8 @@ +html, +body { + height: 100%; +} + body { margin: 0; font-family: Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; @@ -5,6 +10,10 @@ body { background-color: #f3f6fb; } +#root { + height: 100%; +} + * { box-sizing: border-box; } diff --git a/furumi-node-player/client/src/main.tsx b/furumi-node-player/client/src/main.tsx index bef5202..9d4c1bf 100644 --- a/furumi-node-player/client/src/main.tsx +++ b/furumi-node-player/client/src/main.tsx @@ -1,10 +1,14 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' +import { Provider } from 'react-redux' +import { store } from './store' import './index.css' import App from './App.tsx' createRoot(document.getElementById('root')!).render( - + + + , ) diff --git a/furumi-node-player/client/src/store/index.ts b/furumi-node-player/client/src/store/index.ts new file mode 100644 index 0000000..49b5321 --- /dev/null +++ b/furumi-node-player/client/src/store/index.ts @@ -0,0 +1,23 @@ +import { configureStore } from '@reduxjs/toolkit' +import { useDispatch, useSelector, type TypedUseSelectorHook } from 'react-redux' +import artistsReducer from './slices/artistsSlice' +import albumsReducer from './slices/albumsSlice' +import albumTracksReducer from './slices/albumTracksSlice' +import artistTracksReducer from './slices/artistTracksSlice' +import trackDetailReducer from './slices/trackDetailSlice' + +export const store = configureStore({ + reducer: { + artists: artistsReducer, + albums: albumsReducer, + albumTracks: albumTracksReducer, + artistTracks: artistTracksReducer, + trackDetail: trackDetailReducer, + }, +}) + +export type RootState = ReturnType +export type AppDispatch = typeof store.dispatch + +export const useAppDispatch = useDispatch.withTypes() +export const useAppSelector: TypedUseSelectorHook = useSelector diff --git a/furumi-node-player/client/src/store/slices/albumTracksSlice.ts b/furumi-node-player/client/src/store/slices/albumTracksSlice.ts new file mode 100644 index 0000000..8ede869 --- /dev/null +++ b/furumi-node-player/client/src/store/slices/albumTracksSlice.ts @@ -0,0 +1,54 @@ +import { createSlice, createAsyncThunk } from '@reduxjs/toolkit' +import type { Track } from '../../types' +import { getAlbumTracks } from '../../furumiApi' + +export const fetchAlbumTracks = createAsyncThunk( + 'albumTracks/fetch', + async (albumSlug: string, { rejectWithValue }) => { + const data = await getAlbumTracks(albumSlug) + if (data === null) return rejectWithValue('Failed to fetch album tracks') + return { albumSlug, tracks: data } + }, +) + +interface AlbumTracksState { + byAlbum: Record + loading: boolean + error: string | null +} + +const initialState: AlbumTracksState = { + byAlbum: {}, + loading: false, + error: null, +} + +const albumTracksSlice = createSlice({ + name: 'albumTracks', + initialState, + reducers: { + clearAlbumTracks(state) { + state.byAlbum = {} + state.error = null + }, + }, + extraReducers: (builder) => { + builder + .addCase(fetchAlbumTracks.pending, (state) => { + state.loading = true + state.error = null + }) + .addCase(fetchAlbumTracks.fulfilled, (state, action) => { + state.loading = false + state.byAlbum[action.payload.albumSlug] = action.payload.tracks + state.error = null + }) + .addCase(fetchAlbumTracks.rejected, (state, action) => { + state.loading = false + state.error = action.payload as string ?? 'Unknown error' + }) + }, +}) + +export const { clearAlbumTracks } = albumTracksSlice.actions +export default albumTracksSlice.reducer diff --git a/furumi-node-player/client/src/store/slices/albumsSlice.ts b/furumi-node-player/client/src/store/slices/albumsSlice.ts new file mode 100644 index 0000000..386c3f5 --- /dev/null +++ b/furumi-node-player/client/src/store/slices/albumsSlice.ts @@ -0,0 +1,54 @@ +import { createSlice, createAsyncThunk } from '@reduxjs/toolkit' +import type { Album } from '../../types' +import { getArtistAlbums } from '../../furumiApi' + +export const fetchArtistAlbums = createAsyncThunk( + 'albums/fetchByArtist', + async (artistSlug: string, { rejectWithValue }) => { + const data = await getArtistAlbums(artistSlug) + if (data === null) return rejectWithValue('Failed to fetch albums') + return { artistSlug, albums: data } + }, +) + +interface AlbumsState { + byArtist: Record + loading: boolean + error: string | null +} + +const initialState: AlbumsState = { + byArtist: {}, + loading: false, + error: null, +} + +const albumsSlice = createSlice({ + name: 'albums', + initialState, + reducers: { + clearAlbums(state) { + state.byArtist = {} + state.error = null + }, + }, + extraReducers: (builder) => { + builder + .addCase(fetchArtistAlbums.pending, (state) => { + state.loading = true + state.error = null + }) + .addCase(fetchArtistAlbums.fulfilled, (state, action) => { + state.loading = false + state.byArtist[action.payload.artistSlug] = action.payload.albums + state.error = null + }) + .addCase(fetchArtistAlbums.rejected, (state, action) => { + state.loading = false + state.error = action.payload as string ?? 'Unknown error' + }) + }, +}) + +export const { clearAlbums } = albumsSlice.actions +export default albumsSlice.reducer diff --git a/furumi-node-player/client/src/store/slices/artistTracksSlice.ts b/furumi-node-player/client/src/store/slices/artistTracksSlice.ts new file mode 100644 index 0000000..e3e7592 --- /dev/null +++ b/furumi-node-player/client/src/store/slices/artistTracksSlice.ts @@ -0,0 +1,54 @@ +import { createSlice, createAsyncThunk } from '@reduxjs/toolkit' +import type { Track } from '../../types' +import { getArtistTracks } from '../../furumiApi' + +export const fetchArtistTracks = createAsyncThunk( + 'artistTracks/fetch', + async (artistSlug: string, { rejectWithValue }) => { + const data = await getArtistTracks(artistSlug) + if (data === null) return rejectWithValue('Failed to fetch artist tracks') + return { artistSlug, tracks: data } + }, +) + +interface ArtistTracksState { + byArtist: Record + loading: boolean + error: string | null +} + +const initialState: ArtistTracksState = { + byArtist: {}, + loading: false, + error: null, +} + +const artistTracksSlice = createSlice({ + name: 'artistTracks', + initialState, + reducers: { + clearArtistTracks(state) { + state.byArtist = {} + state.error = null + }, + }, + extraReducers: (builder) => { + builder + .addCase(fetchArtistTracks.pending, (state) => { + state.loading = true + state.error = null + }) + .addCase(fetchArtistTracks.fulfilled, (state, action) => { + state.loading = false + state.byArtist[action.payload.artistSlug] = action.payload.tracks + state.error = null + }) + .addCase(fetchArtistTracks.rejected, (state, action) => { + state.loading = false + state.error = action.payload as string ?? 'Unknown error' + }) + }, +}) + +export const { clearArtistTracks } = artistTracksSlice.actions +export default artistTracksSlice.reducer diff --git a/furumi-node-player/client/src/store/slices/artistsSlice.ts b/furumi-node-player/client/src/store/slices/artistsSlice.ts new file mode 100644 index 0000000..ecef76a --- /dev/null +++ b/furumi-node-player/client/src/store/slices/artistsSlice.ts @@ -0,0 +1,54 @@ +import { createSlice, createAsyncThunk } from '@reduxjs/toolkit' +import type { Artist } from '../../types' +import { getArtists } from '../../furumiApi' + +export const fetchArtists = createAsyncThunk( + 'artists/fetch', + async (_, { rejectWithValue }) => { + const data = await getArtists() + if (data === null) return rejectWithValue('Failed to fetch artists') + return data + }, +) + +interface ArtistsState { + items: Artist[] + loading: boolean + error: string | null +} + +const initialState: ArtistsState = { + items: [], + loading: false, + error: null, +} + +const artistsSlice = createSlice({ + name: 'artists', + initialState, + reducers: { + clearArtists(state) { + state.items = [] + state.error = null + }, + }, + extraReducers: (builder) => { + builder + .addCase(fetchArtists.pending, (state) => { + state.loading = true + state.error = null + }) + .addCase(fetchArtists.fulfilled, (state, action) => { + state.loading = false + state.items = action.payload + state.error = null + }) + .addCase(fetchArtists.rejected, (state, action) => { + state.loading = false + state.error = action.payload as string ?? 'Unknown error' + }) + }, +}) + +export const { clearArtists } = artistsSlice.actions +export default artistsSlice.reducer diff --git a/furumi-node-player/client/src/store/slices/trackDetailSlice.ts b/furumi-node-player/client/src/store/slices/trackDetailSlice.ts new file mode 100644 index 0000000..60a852f --- /dev/null +++ b/furumi-node-player/client/src/store/slices/trackDetailSlice.ts @@ -0,0 +1,57 @@ +import { createSlice, createAsyncThunk } from '@reduxjs/toolkit' +import type { TrackDetail } from '../../types' +import { getTrackInfo } from '../../furumiApi' + +export const fetchTrackDetail = createAsyncThunk( + 'trackDetail/fetch', + async (trackSlug: string, { rejectWithValue }) => { + const data = await getTrackInfo(trackSlug) + if (data === null) return rejectWithValue('Failed to fetch track detail') + return { trackSlug, detail: data } + }, +) + +interface TrackDetailState { + bySlug: Record + loading: boolean + error: string | null +} + +const initialState: TrackDetailState = { + bySlug: {}, + loading: false, + error: null, +} + +const trackDetailSlice = createSlice({ + name: 'trackDetail', + initialState, + reducers: { + clearTrackDetail(state) { + state.bySlug = {} + state.error = null + }, + removeTrackDetail(state, action: { payload: string }) { + delete state.bySlug[action.payload] + }, + }, + extraReducers: (builder) => { + builder + .addCase(fetchTrackDetail.pending, (state) => { + state.loading = true + state.error = null + }) + .addCase(fetchTrackDetail.fulfilled, (state, action) => { + state.loading = false + state.bySlug[action.payload.trackSlug] = action.payload.detail + state.error = null + }) + .addCase(fetchTrackDetail.rejected, (state, action) => { + state.loading = false + state.error = action.payload as string ?? 'Unknown error' + }) + }, +}) + +export const { clearTrackDetail, removeTrackDetail } = trackDetailSlice.actions +export default trackDetailSlice.reducer diff --git a/furumi-node-player/client/src/types.ts b/furumi-node-player/client/src/types.ts new file mode 100644 index 0000000..654adc6 --- /dev/null +++ b/furumi-node-player/client/src/types.ts @@ -0,0 +1,42 @@ +// API entity types (see PLAYER-API.md) + +export interface Artist { + slug: string + name: string + album_count: number + track_count: number +} + +export interface Album { + slug: string + name: string + year: number | null + track_count: number + has_cover: boolean +} + +export interface Track { + slug: string + title: string + track_number: number | null + duration_secs: number + artist_name: string + album_name: string | null + album_slug: string | null + genre: string | null +} + +export interface TrackDetail extends Track { + storage_path: string + artist_slug: string + album_year: number | null +} + +export type SearchResultType = 'artist' | 'album' | 'track' + +export interface SearchResult { + result_type: SearchResultType + slug: string + name: string + detail: string | null +} diff --git a/furumi-node-player/client/src/utils.ts b/furumi-node-player/client/src/utils.ts new file mode 100644 index 0000000..6e9ce67 --- /dev/null +++ b/furumi-node-player/client/src/utils.ts @@ -0,0 +1,14 @@ +function pad(n: number) { + return String(n).padStart(2, '0') +} + +export function fmt(secs: number) { + if (!secs || Number.isNaN(secs)) return '0:00' + const s = Math.floor(secs) + const m = Math.floor(s / 60) + const h = Math.floor(m / 60) + if (h > 0) { + return `${h}:${pad(m % 60)}:${pad(s % 60)}` + } + return `${m}:${pad(s % 60)}` +} diff --git a/furumi-web-player/Cargo.toml b/furumi-web-player/Cargo.toml index 03088db..822300c 100644 --- a/furumi-web-player/Cargo.toml +++ b/furumi-web-player/Cargo.toml @@ -25,3 +25,4 @@ base64 = "0.22" rand = "0.8" urlencoding = "2.1.3" rustls = { version = "0.23", features = ["ring"] } +tower-http = { version = "0.6", features = ["cors"] } diff --git a/furumi-web-player/src/main.rs b/furumi-web-player/src/main.rs index 66696dc..b8a8592 100644 --- a/furumi-web-player/src/main.rs +++ b/furumi-web-player/src/main.rs @@ -39,6 +39,10 @@ struct Args { /// OIDC Session Secret (32+ chars, for HMAC). Random if not provided. #[arg(long, env = "FURUMI_PLAYER_OIDC_SESSION_SECRET")] oidc_session_secret: Option, + + /// API key for x-api-key header auth (alternative to OIDC session) + #[arg(long, env = "FURUMI_PLAYER_API_KEY")] + api_key: Option, } #[tokio::main] @@ -90,10 +94,15 @@ async fn main() -> Result<(), Box> { std::process::exit(1); }); + if args.api_key.is_some() { + tracing::info!("x-api-key auth: enabled"); + } + let state = Arc::new(web::AppState { pool, storage_dir: Arc::new(args.storage_dir), oidc: oidc_state, + api_key: args.api_key, }); tracing::info!("Web player: http://{}", bind_addr); diff --git a/furumi-web-player/src/web/auth.rs b/furumi-web-player/src/web/auth.rs index c2f626c..33f8184 100644 --- a/furumi-web-player/src/web/auth.rs +++ b/furumi-web-player/src/web/auth.rs @@ -5,6 +5,8 @@ use axum::{ middleware::Next, response::{Html, IntoResponse, Redirect, Response}, }; + +const X_API_KEY: &str = "x-api-key"; use openidconnect::{ core::{CoreClient, CoreProviderMetadata, CoreResponseType}, reqwest::async_http_client, @@ -92,37 +94,51 @@ fn verify_sso_cookie(secret: &[u8], cookie_val: &str) -> Option { } } -/// Auth middleware: requires valid SSO session cookie. +/// Auth middleware: requires valid SSO session cookie or x-api-key header. pub async fn require_auth( State(state): State>, req: Request, next: Next, ) -> Response { - let oidc = match &state.oidc { - Some(o) => o, - None => return next.run(req).await, // No OIDC configured = no auth - }; - - let cookies = req - .headers() - .get(header::COOKIE) - .and_then(|v| v.to_str().ok()) - .unwrap_or(""); - - for c in cookies.split(';') { - let c = c.trim(); - if let Some(val) = c.strip_prefix(&format!("{}=", SESSION_COOKIE)) { - if verify_sso_cookie(&oidc.session_secret, val).is_some() { + // 1. Check x-api-key header (if configured) + if let Some(ref expected) = state.api_key { + if let Some(val) = req + .headers() + .get(X_API_KEY) + .and_then(|v| v.to_str().ok()) + { + if val == expected { return next.run(req).await; } } } + // 2. Check SSO session cookie (if OIDC configured) + if let Some(ref oidc) = state.oidc { + let cookies = req + .headers() + .get(header::COOKIE) + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + + for c in cookies.split(';') { + let c = c.trim(); + if let Some(val) = c.strip_prefix(&format!("{}=", SESSION_COOKIE)) { + if verify_sso_cookie(&oidc.session_secret, val).is_some() { + return next.run(req).await; + } + } + } + } + let uri = req.uri().to_string(); if uri.starts_with("/api/") { (StatusCode::UNAUTHORIZED, "Unauthorized").into_response() - } else { + } else if state.oidc.is_some() { Redirect::to("/login").into_response() + } else { + // Only API key configured — no web login available + (StatusCode::UNAUTHORIZED, "Unauthorized").into_response() } } diff --git a/furumi-web-player/src/web/mod.rs b/furumi-web-player/src/web/mod.rs index e53ff2c..355b4bb 100644 --- a/furumi-web-player/src/web/mod.rs +++ b/furumi-web-player/src/web/mod.rs @@ -3,9 +3,12 @@ pub mod auth; use std::sync::Arc; use std::path::PathBuf; +use std::time::Duration; use axum::{Router, routing::get, middleware}; +use axum::http::{header, Method}; use sqlx::PgPool; +use tower_http::cors::{Any, CorsLayer}; #[derive(Clone)] pub struct AppState { @@ -13,6 +16,7 @@ pub struct AppState { #[allow(dead_code)] pub storage_dir: Arc, pub oidc: Option>, + pub api_key: Option, } pub fn build_router(state: Arc) -> Router { @@ -32,21 +36,28 @@ pub fn build_router(state: Arc) -> Router { .route("/", get(player_html)) .nest("/api", library); - let has_oidc = state.oidc.is_some(); + let requires_auth = state.oidc.is_some(); - let app = if has_oidc { + let app = if requires_auth { authed .route_layer(middleware::from_fn_with_state(state.clone(), auth::require_auth)) } else { authed }; + let cors = CorsLayer::new() + .allow_origin(Any) + .allow_methods([Method::GET, Method::OPTIONS, Method::HEAD]) + .allow_headers([header::ACCEPT, header::CONTENT_TYPE, header::HeaderName::from_static("x-api-key")]) + .max_age(Duration::from_secs(600)); + Router::new() .route("/login", get(auth::login_page)) .route("/logout", get(auth::logout)) .route("/auth/login", get(auth::oidc_login)) .route("/auth/callback", get(auth::oidc_callback)) .merge(app) + .layer(cors) .with_state(state) }