mirror of
https://github.com/Chevron7Locked/kima-hub.git
synced 2026-06-19 07:37:17 +00:00
Issue fixes: - #155: /api/browse/playlists/parse now handles YouTube/YouTube Music URLs - #156: stop passing album MBID to verifyArtistName (was calling MB /artist/{id} with an album MBID, always 404d); fix spotify trackCount stale value - #154: remove hardcoded port-3030 detection from getApiBaseUrl -- now returns relative URLs by default so any host:port mapping works - #25 (partial): fix spotify playlist trackCount to use tracks.length instead of stale playlist.tracks.total after pagination Dead code / quality: - Remove unused rootFolderPath param from processDownload + call sites - Remove unused req params in route handlers (prefix _req) - Remove dead push condition from integration.yml job gate - Remove dead baseUrl constructor param and private field from ApiService - Fix LibraryTabs hover effect: remove inline style={{ opacity: 0.1 }} that overrode Tailwind group-hover; change to group-hover:opacity-10 - Fix mobile tab centering in LibraryTabs (add justify-center) CI security: - Mask TEST_PASS before writing to GITHUB_ENV in all three workflow files - Add missing concurrency block to nightly.yml - Add username validation + remove credential echo in create-e2e-user.sh - Fix global.setup.ts error message to mention .env.test E2E: - Fix vibe test race condition: replace Promise.race + transient text with stable trackCount.or(noData) assertion - Fix security test flakiness: toBe(beforeCount) -> not.toBeGreaterThan for playlist count check (parallel tests can delete playlists concurrently) - Fix global.setup.ts error message to reference .env.test file Vibe map: - Increase cluster label size (13->15 / 10->12 px) and opacity (50->70 / 35->50) for slightly better readability
This commit is contained in:
@@ -13,7 +13,7 @@ concurrency:
|
||||
jobs:
|
||||
e2e:
|
||||
name: E2E Tests
|
||||
if: github.event_name == 'push' || github.event.label.name == 'run-e2e'
|
||||
if: github.event_name == 'workflow_dispatch' || github.event.label.name == 'run-e2e'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
|
||||
@@ -48,6 +48,7 @@ jobs:
|
||||
run: |
|
||||
TEST_USER="kima_e2e"
|
||||
TEST_PASS="$(openssl rand -hex 20)"
|
||||
echo "::add-mask::${TEST_PASS}"
|
||||
echo "KIMA_TEST_USERNAME=${TEST_USER}" >> "$GITHUB_ENV"
|
||||
echo "KIMA_TEST_PASSWORD=${TEST_PASS}" >> "$GITHUB_ENV"
|
||||
KIMA_CONTAINER=kima-e2e \
|
||||
|
||||
@@ -5,6 +5,10 @@ on:
|
||||
- cron: "0 3 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: nightly
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
full-e2e:
|
||||
name: Full E2E Suite
|
||||
@@ -44,6 +48,7 @@ jobs:
|
||||
# Generate random credentials for this run -- no hardcoded passwords in source.
|
||||
TEST_USER="kima_e2e"
|
||||
TEST_PASS="$(openssl rand -hex 20)"
|
||||
echo "::add-mask::${TEST_PASS}"
|
||||
echo "KIMA_TEST_USERNAME=${TEST_USER}" >> "$GITHUB_ENV"
|
||||
echo "KIMA_TEST_PASSWORD=${TEST_PASS}" >> "$GITHUB_ENV"
|
||||
KIMA_CONTAINER=kima-nightly \
|
||||
|
||||
@@ -65,6 +65,7 @@ jobs:
|
||||
run: |
|
||||
TEST_USER="kima_e2e"
|
||||
TEST_PASS="$(openssl rand -hex 20)"
|
||||
echo "::add-mask::${TEST_PASS}"
|
||||
echo "KIMA_TEST_USERNAME=${TEST_USER}" >> "$GITHUB_ENV"
|
||||
echo "KIMA_TEST_PASSWORD=${TEST_PASS}" >> "$GITHUB_ENV"
|
||||
KIMA_CONTAINER=kima-security \
|
||||
|
||||
@@ -137,7 +137,7 @@ router.get("/playlists/:id", async (req, res) => {
|
||||
* GET /api/browse/radios
|
||||
* Get all radio stations (mood/theme based mixes)
|
||||
*/
|
||||
router.get("/radios", async (req, res) => {
|
||||
router.get("/radios", async (_req, res) => {
|
||||
try {
|
||||
logger.debug("[Browse] Fetching radio stations...");
|
||||
const radios = await deezerService.getRadioStations();
|
||||
@@ -156,7 +156,7 @@ router.get("/radios", async (req, res) => {
|
||||
* GET /api/browse/radios/by-genre
|
||||
* Get radio stations organized by genre
|
||||
*/
|
||||
router.get("/radios/by-genre", async (req, res) => {
|
||||
router.get("/radios/by-genre", async (_req, res) => {
|
||||
try {
|
||||
logger.debug("[Browse] Fetching radios by genre...");
|
||||
const genresWithRadios = await deezerService.getRadiosByGenre();
|
||||
@@ -207,7 +207,7 @@ router.get("/radios/:id", async (req, res) => {
|
||||
* GET /api/browse/genres
|
||||
* Get all available genres
|
||||
*/
|
||||
router.get("/genres", async (req, res) => {
|
||||
router.get("/genres", async (_req, res) => {
|
||||
try {
|
||||
logger.debug("[Browse] Fetching genres...");
|
||||
const genres = await deezerService.getGenres();
|
||||
@@ -311,8 +311,31 @@ router.post("/playlists/parse", async (req, res) => {
|
||||
});
|
||||
}
|
||||
|
||||
// Try YouTube / YouTube Music -- extract list= query param as playlist ID
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
const hostname = parsed.hostname.toLowerCase();
|
||||
const isYouTube =
|
||||
hostname.includes("youtube.com") ||
|
||||
hostname.includes("youtu.be") ||
|
||||
hostname.includes("music.youtube.com");
|
||||
if (isYouTube) {
|
||||
const listId = parsed.searchParams.get("list");
|
||||
if (listId) {
|
||||
return res.json({
|
||||
source: "youtube",
|
||||
type: "playlist",
|
||||
id: listId,
|
||||
url,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Invalid URL -- fall through to error
|
||||
}
|
||||
|
||||
return res.status(400).json({
|
||||
error: "Invalid or unsupported URL. Please provide a Spotify or Deezer playlist URL."
|
||||
error: "Invalid or unsupported URL. Please provide a Spotify, Deezer, or YouTube playlist URL."
|
||||
});
|
||||
} catch (error) {
|
||||
safeError(res, "Parse URL", error);
|
||||
@@ -324,7 +347,7 @@ router.post("/playlists/parse", async (req, res) => {
|
||||
* Get a combined view of featured content (playlists, genres)
|
||||
* Note: Radio stations are now internal (library-based), not from Deezer
|
||||
*/
|
||||
router.get("/all", async (req, res) => {
|
||||
router.get("/all", async (_req, res) => {
|
||||
try {
|
||||
logger.debug("[Browse] Fetching browse content (playlists + genres)...");
|
||||
|
||||
|
||||
@@ -158,7 +158,7 @@ router.post("/", async (req, res) => {
|
||||
// Single album download - verify artist name before proceeding
|
||||
let verifiedArtistName = artistName;
|
||||
if (type === "album" && artistName) {
|
||||
const verification = await verifyArtistName(artistName, mbid);
|
||||
const verification = await verifyArtistName(artistName);
|
||||
if (verification.wasCorrected) {
|
||||
logger.debug(
|
||||
`[DOWNLOAD] Artist name verified: "${artistName}" → "${verification.verifiedName}" (source: ${verification.source})`
|
||||
@@ -241,7 +241,6 @@ router.post("/", async (req, res) => {
|
||||
type,
|
||||
mbid,
|
||||
subject,
|
||||
rootFolderPath,
|
||||
verifiedArtistName,
|
||||
albumTitle
|
||||
).catch((error) => {
|
||||
@@ -486,7 +485,6 @@ async function processArtistDownload(
|
||||
"album",
|
||||
albumMbid,
|
||||
albumSubject,
|
||||
rootFolderPath,
|
||||
artistName,
|
||||
albumTitle
|
||||
).catch((error) => {
|
||||
@@ -508,7 +506,6 @@ async function processDownload(
|
||||
type: string,
|
||||
mbid: string,
|
||||
subject: string,
|
||||
rootFolderPath: string,
|
||||
artistName?: string,
|
||||
albumTitle?: string
|
||||
) {
|
||||
@@ -576,7 +573,7 @@ router.delete("/clear-all", async (req, res) => {
|
||||
});
|
||||
|
||||
// POST /downloads/clear-lidarr-queue - Clear stuck/failed items from Lidarr's queue
|
||||
router.post("/clear-lidarr-queue", async (req, res) => {
|
||||
router.post("/clear-lidarr-queue", async (_req, res) => {
|
||||
try {
|
||||
const result = await simpleDownloadManager.clearLidarrQueue();
|
||||
res.json({
|
||||
|
||||
@@ -761,7 +761,7 @@ class SpotifyService {
|
||||
description: playlist.description,
|
||||
owner: playlist.owner?.display_name || "Unknown",
|
||||
imageUrl: playlist.images?.[0]?.url || null,
|
||||
trackCount: playlist.tracks?.total || tracks.length,
|
||||
trackCount: tracks.length,
|
||||
tracks,
|
||||
isPublic: playlist.public ?? true,
|
||||
};
|
||||
|
||||
@@ -20,7 +20,7 @@ export function LibraryTabs({ activeTab, onTabChange }: LibraryTabsProps) {
|
||||
<div className="absolute -inset-x-4 -inset-y-2 bg-[#0a0a0a]/60 backdrop-blur-xl rounded-2xl border border-white/5" />
|
||||
|
||||
{/* Tab buttons */}
|
||||
<div className="relative flex gap-2 p-2">
|
||||
<div className="relative flex justify-center gap-2 p-2">
|
||||
{tabs.map((tab, index) => {
|
||||
const isActive = activeTab === tab.id;
|
||||
const Icon = tab.icon;
|
||||
@@ -62,10 +62,9 @@ export function LibraryTabs({ activeTab, onTabChange }: LibraryTabsProps) {
|
||||
{!isActive && (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-0 opacity-0 group-hover:opacity-100 transition-opacity duration-300 bg-gradient-to-r",
|
||||
"absolute inset-0 opacity-0 group-hover:opacity-10 transition-opacity duration-300 bg-gradient-to-r",
|
||||
tab.gradient
|
||||
)}
|
||||
style={{ opacity: 0.1 }}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
|
||||
@@ -227,8 +227,8 @@ export function VibeMap({
|
||||
data: labels,
|
||||
getPosition: (d) => [d.x, d.y],
|
||||
getText: (d) => d.label,
|
||||
getSize: labelZoom < 7 ? 13 : 10,
|
||||
getColor: [255, 255, 255, labelZoom < 7 ? 50 : 35],
|
||||
getSize: labelZoom < 7 ? 15 : 12,
|
||||
getColor: [255, 255, 255, labelZoom < 7 ? 70 : 50],
|
||||
fontFamily: "Montserrat, system-ui, sans-serif",
|
||||
fontWeight: 500,
|
||||
getTextAnchor: "middle" as const,
|
||||
|
||||
+5
-27
@@ -68,40 +68,22 @@ export const getApiBaseUrl = () => {
|
||||
return process.env.BACKEND_URL || "http://127.0.0.1:3006";
|
||||
}
|
||||
|
||||
// Explicit env var takes precedence
|
||||
// Explicit env var: used for reverse proxies where the API is on a different origin
|
||||
if (process.env.NEXT_PUBLIC_API_URL) {
|
||||
return process.env.NEXT_PUBLIC_API_URL;
|
||||
}
|
||||
|
||||
// Docker all-in-one mode: Use relative URLs (Next.js rewrites will proxy)
|
||||
// This is detected by checking if we're on the same port as the frontend
|
||||
const frontendPort =
|
||||
window.location.port ||
|
||||
(window.location.protocol === "https:" ? "443" : "80");
|
||||
if (
|
||||
frontendPort === "3030" ||
|
||||
frontendPort === "443" ||
|
||||
frontendPort === "80"
|
||||
) {
|
||||
// Use relative paths - Next.js rewrites will proxy to backend
|
||||
// Default: relative URLs so Next.js rewrites proxy to the backend.
|
||||
// Works for any host port mapping (e.g. -p 9000:3030) because the browser
|
||||
// origin matches whatever port the user mapped -- no hardcoded port needed.
|
||||
return "";
|
||||
}
|
||||
|
||||
// Development mode: Backend on separate port
|
||||
const currentHost = window.location.hostname;
|
||||
const apiPort = "3006";
|
||||
return `${window.location.protocol}//${currentHost}:${apiPort}`;
|
||||
};
|
||||
|
||||
class ApiClient {
|
||||
private baseUrl: string;
|
||||
private token: string | null = null;
|
||||
private tokenInitialized: boolean = false;
|
||||
|
||||
constructor(baseUrl?: string) {
|
||||
// Don't set baseUrl in constructor - determine it dynamically on each request
|
||||
this.baseUrl = baseUrl || "";
|
||||
|
||||
constructor() {
|
||||
// Try to load token synchronously
|
||||
if (typeof window !== "undefined") {
|
||||
this.token = localStorage.getItem(AUTH_TOKEN_KEY);
|
||||
@@ -172,11 +154,7 @@ class ApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
// Get the base URL dynamically to support switching between localhost and IP
|
||||
private getBaseUrl(): string {
|
||||
if (this.baseUrl) {
|
||||
return this.baseUrl;
|
||||
}
|
||||
return getApiBaseUrl();
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ async function globalSetup(): Promise<void> {
|
||||
if (!username || !password) {
|
||||
throw new Error(
|
||||
"E2E test user credentials not set.\n" +
|
||||
"Set KIMA_TEST_USERNAME and KIMA_TEST_PASSWORD before running E2E tests.\n" +
|
||||
"Set KIMA_TEST_USERNAME and KIMA_TEST_PASSWORD in .env.test or export them before running E2E tests.\n" +
|
||||
"To create a test user, run: bash scripts/create-e2e-user.sh"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -295,13 +295,14 @@ test.describe("Security", () => {
|
||||
});
|
||||
expect([400, 422]).toContain(createRes.status());
|
||||
|
||||
// Count must be unchanged
|
||||
// Count must not have increased -- parallel tests may delete playlists
|
||||
// concurrently, so we only assert the invalid request didn't create anything.
|
||||
const afterRes = await page.request.get("/api/playlists", {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
const after = await afterRes.json() as unknown[];
|
||||
const afterCount = Array.isArray(after) ? after.length : 0;
|
||||
expect(afterCount).toBe(beforeCount);
|
||||
expect(afterCount).not.toBeGreaterThan(beforeCount);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -56,21 +56,16 @@ test.describe("Vibe", () => {
|
||||
|
||||
// ---- Page load ----------------------------------------------------------
|
||||
|
||||
test("vibe page renders canvas or no-data state", async ({ page }) => {
|
||||
test("vibe page renders map or no-data state", async ({ page }) => {
|
||||
await page.goto("/vibe", { waitUntil: "domcontentloaded" });
|
||||
|
||||
// Either a canvas (map rendered) or the no-data placeholder must appear
|
||||
const canvas = page.locator("canvas");
|
||||
const noData = page.locator("text=/No tracks with vibe|Computing music map/i");
|
||||
// Wait for loading to settle: either the track count appears (map loaded with data)
|
||||
// or the no-data placeholder appears (library has no vibe embeddings yet).
|
||||
// "Computing music map" is a transient loading state -- do not assert on it.
|
||||
const trackCount = page.locator("text=/ tracks$/");
|
||||
const noData = page.locator("text=/No tracks with vibe/i");
|
||||
|
||||
await Promise.race([
|
||||
canvas.waitFor({ timeout: 35_000 }),
|
||||
noData.waitFor({ timeout: 35_000 }),
|
||||
]);
|
||||
|
||||
const hasCanvas = (await canvas.count()) > 0;
|
||||
const hasNoData = (await noData.count()) > 0;
|
||||
expect(hasCanvas || hasNoData).toBe(true);
|
||||
await expect(trackCount.or(noData)).toBeVisible({ timeout: 35_000 });
|
||||
});
|
||||
|
||||
test("toolbar buttons are present when map loads", async ({ page }) => {
|
||||
|
||||
@@ -17,6 +17,12 @@ CONTAINER="${KIMA_CONTAINER:-kima-test}"
|
||||
TEST_USER="${KIMA_TEST_USERNAME:-kima_e2e}"
|
||||
TEST_PASS="${KIMA_TEST_PASSWORD:-$(openssl rand -hex 20)}"
|
||||
|
||||
# Validate username to prevent SQL injection via the heredoc
|
||||
if [[ ! "${TEST_USER}" =~ ^[a-zA-Z0-9_]{3,32}$ ]]; then
|
||||
echo "[e2e setup] ERROR: KIMA_TEST_USERNAME must be 3-32 alphanumeric/underscore characters" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[e2e setup] Creating test user '${TEST_USER}' in container '${CONTAINER}'..."
|
||||
|
||||
# Generate bcrypt hash inside the container where bcrypt is installed.
|
||||
@@ -48,7 +54,3 @@ ENDSQL
|
||||
'
|
||||
|
||||
echo "[e2e setup] Test user '${TEST_USER}' ready."
|
||||
echo ""
|
||||
echo "Set these env vars before running Playwright:"
|
||||
echo " export KIMA_TEST_USERNAME=${TEST_USER}"
|
||||
echo " export KIMA_TEST_PASSWORD=${TEST_PASS}"
|
||||
|
||||
Reference in New Issue
Block a user