diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index d9b0ac4..6aa5ec0 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -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 \ diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 9e2c254..78b11b2 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -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 \ diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 306c65a..8bfb94b 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -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 \ diff --git a/backend/src/routes/browse.ts b/backend/src/routes/browse.ts index 8d4b60f..296e887 100644 --- a/backend/src/routes/browse.ts +++ b/backend/src/routes/browse.ts @@ -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) => { }); } - return res.status(400).json({ - error: "Invalid or unsupported URL. Please provide a Spotify or Deezer playlist URL." + // 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, 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)..."); diff --git a/backend/src/routes/downloads.ts b/backend/src/routes/downloads.ts index 4f68826..3312486 100644 --- a/backend/src/routes/downloads.ts +++ b/backend/src/routes/downloads.ts @@ -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({ diff --git a/backend/src/services/spotify.ts b/backend/src/services/spotify.ts index 0afaec0..63839de 100644 --- a/backend/src/services/spotify.ts +++ b/backend/src/services/spotify.ts @@ -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, }; diff --git a/frontend/features/library/components/LibraryTabs.tsx b/frontend/features/library/components/LibraryTabs.tsx index 4d24b7f..9e7230c 100644 --- a/frontend/features/library/components/LibraryTabs.tsx +++ b/frontend/features/library/components/LibraryTabs.tsx @@ -20,7 +20,7 @@ export function LibraryTabs({ activeTab, onTabChange }: LibraryTabsProps) {
{/* Tab buttons */} -