Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,10 @@ SPOTIFY_REFRESH_TOKEN=""
# ============================================================
# YouTube Music
# ============================================================
YTM_AUTH_USER=""
# Browser profile index used by YouTube Music (usually 0).
YTM_AUTH_USER="0"
# Full Cookie request header copied from an authenticated music.youtube.com request.
# Keep this server-side and replace it when the diagnostic page reports unauthorized.
YTM_COOKIE=""

# ============================================================
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,7 @@ const spotifyMocks = vi.hoisted(() => ({
}))

const ytmusicMocks = vi.hoisted(() => ({
getYTMusicTracks: vi.fn(),
hasYTMusicCredentials: vi.fn()
getYTMusicTracks: vi.fn()
}))

vi.mock('@/server/github', () => githubMocks)
Expand All @@ -31,7 +30,7 @@ describe('getCombinedActivity', () => {
githubMocks.getCachedGitHubContributions.mockReset()
spotifyMocks.getSpotifyTracks.mockReset()
ytmusicMocks.getYTMusicTracks.mockReset()
ytmusicMocks.hasYTMusicCredentials.mockReturnValue(false)
ytmusicMocks.getYTMusicTracks.mockResolvedValue([])
})

it('merges both contribution years, preserves activity, and includes spotify tracks', async () => {
Expand Down Expand Up @@ -82,6 +81,7 @@ describe('getCombinedActivity', () => {
).toHaveBeenNthCalledWith(2, 2025)
expect(githubMocks.getCachedGitHubActivity).toHaveBeenCalledWith(5)
expect(spotifyMocks.getSpotifyTracks).toHaveBeenCalledWith(5)
expect(ytmusicMocks.getYTMusicTracks).not.toHaveBeenCalled()

expect(result.totalContributions).toBe(17)
expect(result.recentActivity).toEqual(recentActivity)
Expand All @@ -96,7 +96,7 @@ describe('getCombinedActivity', () => {
)
})

it('returns partial data cleanly when spotify has no tracks', async () => {
it('uses YouTube Music only when Spotify has no tracks', async () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-03-30T06:15:00.000Z'))

Expand All @@ -118,12 +118,23 @@ describe('getCombinedActivity', () => {
createGitHubActivity()
])
spotifyMocks.getSpotifyTracks.mockResolvedValue([])
const ytmTracks = [
{
...createSpotifyTrack({
id: 'ytm-1',
name: 'YouTube fallback'
}),
played_at_estimated: true
}
]
ytmusicMocks.getYTMusicTracks.mockResolvedValue(ytmTracks)

const { getCombinedActivity } =
await import('@/app/api/activity/combined/combine')
const result = await getCombinedActivity(3, 5)

expect(result.spotifyTracks).toEqual([])
expect(ytmusicMocks.getYTMusicTracks).toHaveBeenCalledWith(5)
expect(result.spotifyTracks).toEqual(ytmTracks)
expect(result.recentActivity).toHaveLength(1)
expect(result.contributions).toEqual([
{ date: '2026-01-01', contributionCount: 2 }
Expand Down
File renamed without changes.
46 changes: 46 additions & 0 deletions __tests__/app/api/ytmusic/recent/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'

const trackMocks = vi.hoisted(() => ({
getYTMusicResult: vi.fn()
}))

vi.mock('@/server/ytmusic/tracks', () => trackMocks)

describe('YouTube Music recent route', () => {
beforeEach(() => {
vi.resetModules()
trackMocks.getYTMusicResult.mockReset()
})

it('passes bounded limits and forced refresh to the service', async () => {
trackMocks.getYTMusicResult.mockResolvedValue({
status: 'ok',
tracks: []
})
const { GET } = await import('@/app/api/ytmusic/recent/route')
const response = await GET(
new Request(
'http://localhost/api/ytmusic/recent?limit=999&refresh=1'
)
)

expect(response.status).toBe(200)
expect(trackMocks.getYTMusicResult).toHaveBeenCalledWith(50, {
forceRefresh: true
})
})

it.each([
['unauthorized', 401],
['unconfigured', 503],
['error', 502]
])('maps %s service results to HTTP %i', async (status, httpStatus) => {
trackMocks.getYTMusicResult.mockResolvedValue({ status, tracks: [] })
const { GET } = await import('@/app/api/ytmusic/recent/route')
const response = await GET(
new Request('http://localhost/api/ytmusic/recent')
)

expect(response.status).toBe(httpStatus)
})
})
153 changes: 153 additions & 0 deletions __tests__/server/ytmusic/parser.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import { describe, expect, it } from 'vitest'
import {
parseInnertubeTracks,
stabilizeTrackTimestamps,
YTMusicUnauthorizedError
} from '@/server/ytmusic/parser'

function createHistoryResponse(title = 'Today') {
return {
contents: {
singleColumnBrowseResultsRenderer: {
tabs: [
{
tabRenderer: {
content: {
sectionListRenderer: {
contents: [
{
musicShelfRenderer: {
title: {
runs: [{ text: title }]
},
contents: [
createTrackRenderer()
]
}
}
]
}
}
}
}
]
}
}
}
}

function createTrackRenderer() {
return {
musicResponsiveListItemRenderer: {
overlay: {
musicItemThumbnailOverlayRenderer: {
content: {
musicPlayButtonRenderer: {
playNavigationEndpoint: {
watchEndpoint: { videoId: 'video-1' }
}
}
}
}
},
flexColumns: [
{
musicResponsiveListItemFlexColumnRenderer: {
text: { runs: [{ text: 'Test song' }] }
}
},
{
musicResponsiveListItemFlexColumnRenderer: {
text: {
runs: [
{ text: 'Test artist' },
{ text: ' • ' },
{ text: '1.2K views' }
]
}
}
},
{
musicResponsiveListItemFlexColumnRenderer: {
text: { runs: [{ text: 'Test album' }] }
}
}
],
thumbnail: {
musicThumbnailRenderer: {
thumbnail: {
thumbnails: [
{
url: 'https://i.ytimg.com/vi/video-1/default.jpg'
}
]
}
}
}
}
}
}

describe('YouTube Music response parser', () => {
it('normalizes history tracks and marks inferred timestamps', () => {
const tracks = parseInnertubeTracks(
createHistoryResponse(),
10,
new Date('2026-08-01T18:00:00.000Z')
)

expect(tracks).toEqual([
{
id: 'video-1',
name: 'Test song',
artist: 'Test artist',
album: 'Test album',
url: 'https://music.youtube.com/watch?v=video-1',
image: 'https://i.ytimg.com/vi/video-1/default.jpg',
played_at: '2026-08-01T18:00:00.000Z',
played_at_estimated: true,
played_at_label: 'Today'
}
])
})

it('turns sign-in payloads into an authentication error', () => {
const response = createHistoryResponse() as any
response.contents.singleColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents =
[
{
itemSectionRenderer: {
contents: [
{
messageRenderer: {
text: {
runs: [
{
text: 'Sign in to view your history'
}
]
}
}
}
]
}
}
]

expect(() => parseInnertubeTracks(response, 10)).toThrow(
YTMusicUnauthorizedError
)
})

it('preserves valid timestamps from the persistent cache', () => {
const [fresh] = parseInnertubeTracks(createHistoryResponse(), 1)
const cached = {
...fresh,
played_at: '2026-07-31T12:00:00.000Z'
}

expect(stabilizeTrackTimestamps([fresh], [cached])[0].played_at).toBe(
'2026-07-31T12:00:00.000Z'
)
})
})
118 changes: 118 additions & 0 deletions __tests__/server/ytmusic/tracks.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'

const authMocks = vi.hoisted(() => ({
fetchInnertube: vi.fn(),
hasYTMusicCredentials: vi.fn()
}))

const cacheMocks = vi.hoisted(() => ({
readYTMusicCache: vi.fn(),
writeYTMusicCache: vi.fn()
}))

vi.mock('@/server/ytmusic/auth', () => authMocks)
vi.mock('@/server/ytmusic/cache', () => cacheMocks)

const cachedTrack = {
id: 'cached-1',
name: 'Saved song',
artist: 'Saved artist',
album: '',
url: 'https://music.youtube.com/watch?v=cached-1',
image: '',
played_at: '2026-08-01T10:00:00.000Z',
played_at_estimated: true
}

describe('YouTube Music retrieval', () => {
beforeEach(() => {
vi.resetModules()
authMocks.fetchInnertube.mockReset()
authMocks.hasYTMusicCredentials.mockReset()
cacheMocks.readYTMusicCache.mockReset()
cacheMocks.writeYTMusicCache.mockReset()
cacheMocks.readYTMusicCache.mockResolvedValue(null)
cacheMocks.writeYTMusicCache.mockResolvedValue(
new Date('2026-08-01T12:00:00.000Z')
)
})

it('serves stale saved tracks when live credentials are absent', async () => {
authMocks.hasYTMusicCredentials.mockReturnValue(false)
cacheMocks.readYTMusicCache.mockResolvedValue({
tracks: [cachedTrack],
updatedAt: new Date('2026-07-31T12:00:00.000Z')
})

const { getYTMusicResult } = await import('@/server/ytmusic/tracks')
const result = await getYTMusicResult(10)

expect(result.status).toBe('stale')
expect(result.source).toBe('database')
expect(result.tracks).toEqual([cachedTrack])
expect(authMocks.fetchInnertube).not.toHaveBeenCalled()
})

it('does not call YouTube while the persistent cache is fresh', async () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-08-01T12:00:20.000Z'))
authMocks.hasYTMusicCredentials.mockReturnValue(true)
cacheMocks.readYTMusicCache.mockResolvedValue({
tracks: [cachedTrack],
updatedAt: new Date('2026-08-01T12:00:00.000Z')
})

const { getYTMusicResult } = await import('@/server/ytmusic/tracks')
const result = await getYTMusicResult(10)

expect(result.status).toBe('ok')
expect(result.source).toBe('database')
expect(result.isStale).toBe(false)
expect(authMocks.fetchInnertube).not.toHaveBeenCalled()
})

it('reports rejected sessions instead of returning a false empty result', async () => {
authMocks.hasYTMusicCredentials.mockReturnValue(true)
authMocks.fetchInnertube.mockResolvedValue({
contents: {
singleColumnBrowseResultsRenderer: {
tabs: [
{
tabRenderer: {
content: {
sectionListRenderer: {
contents: [
{
itemSectionRenderer: {
contents: [
{
messageRenderer: {
text: {
runs: [
{
text: 'Sign in to view your history'
}
]
}
}
}
]
}
}
]
}
}
}
}
]
}
}
})

const { getYTMusicResult } = await import('@/server/ytmusic/tracks')
const result = await getYTMusicResult(10, { forceRefresh: true })

expect(result.status).toBe('unauthorized')
expect(result.tracks).toEqual([])
})
})
Loading
Loading