diff --git a/.env.example b/.env.example index c216b27c..93746e3d 100644 --- a/.env.example +++ b/.env.example @@ -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="" # ============================================================ diff --git a/__tests__/rss-route.test.ts b/__tests__/app/(marketing)/rss/route.test.ts similarity index 100% rename from __tests__/rss-route.test.ts rename to __tests__/app/(marketing)/rss/route.test.ts diff --git a/__tests__/activity-combined.test.ts b/__tests__/app/api/activity/combined/combine.test.ts similarity index 87% rename from __tests__/activity-combined.test.ts rename to __tests__/app/api/activity/combined/combine.test.ts index a3dc90d7..159310c7 100644 --- a/__tests__/activity-combined.test.ts +++ b/__tests__/app/api/activity/combined/combine.test.ts @@ -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) @@ -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 () => { @@ -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) @@ -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')) @@ -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 } diff --git a/__tests__/activity-combined-route.test.ts b/__tests__/app/api/activity/combined/route.test.ts similarity index 100% rename from __tests__/activity-combined-route.test.ts rename to __tests__/app/api/activity/combined/route.test.ts diff --git a/__tests__/github-contributions-route.test.ts b/__tests__/app/api/github/contributions/route.test.ts similarity index 100% rename from __tests__/github-contributions-route.test.ts rename to __tests__/app/api/github/contributions/route.test.ts diff --git a/__tests__/github-events-route.test.ts b/__tests__/app/api/github/events/route.test.ts similarity index 100% rename from __tests__/github-events-route.test.ts rename to __tests__/app/api/github/events/route.test.ts diff --git a/__tests__/spotify-auth-url.test.ts b/__tests__/app/api/spotify/auth-url/route.test.ts similarity index 100% rename from __tests__/spotify-auth-url.test.ts rename to __tests__/app/api/spotify/auth-url/route.test.ts diff --git a/__tests__/spotify-callback.test.ts b/__tests__/app/api/spotify/callback/route.test.ts similarity index 100% rename from __tests__/spotify-callback.test.ts rename to __tests__/app/api/spotify/callback/route.test.ts diff --git a/__tests__/spotify-dev-token.test.ts b/__tests__/app/api/spotify/dev-token/route.test.ts similarity index 100% rename from __tests__/spotify-dev-token.test.ts rename to __tests__/app/api/spotify/dev-token/route.test.ts diff --git a/__tests__/spotify-now-playing.test.ts b/__tests__/app/api/spotify/now-playing/route.test.ts similarity index 100% rename from __tests__/spotify-now-playing.test.ts rename to __tests__/app/api/spotify/now-playing/route.test.ts diff --git a/__tests__/spotify-recent.test.ts b/__tests__/app/api/spotify/recent/route.test.ts similarity index 100% rename from __tests__/spotify-recent.test.ts rename to __tests__/app/api/spotify/recent/route.test.ts diff --git a/__tests__/sync-route.test.ts b/__tests__/app/api/sync/route.test.ts similarity index 100% rename from __tests__/sync-route.test.ts rename to __tests__/app/api/sync/route.test.ts diff --git a/__tests__/app/api/ytmusic/recent/route.test.ts b/__tests__/app/api/ytmusic/recent/route.test.ts new file mode 100644 index 00000000..f51c2650 --- /dev/null +++ b/__tests__/app/api/ytmusic/recent/route.test.ts @@ -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) + }) +}) diff --git a/__tests__/activity-section-client.test.ts b/__tests__/components/landing/activity/activity-section-client.test.ts similarity index 100% rename from __tests__/activity-section-client.test.ts rename to __tests__/components/landing/activity/activity-section-client.test.ts diff --git a/__tests__/project-preview.test.tsx b/__tests__/components/projects/components/project-preview.test.tsx similarity index 100% rename from __tests__/project-preview.test.tsx rename to __tests__/components/projects/components/project-preview.test.tsx diff --git a/__tests__/projects-github.test.ts b/__tests__/components/projects/server/github.test.ts similarity index 100% rename from __tests__/projects-github.test.ts rename to __tests__/components/projects/server/github.test.ts diff --git a/__tests__/app-chrome-loading.test.ts b/__tests__/components/providers/providers.test.ts similarity index 100% rename from __tests__/app-chrome-loading.test.ts rename to __tests__/components/providers/providers.test.ts diff --git a/__tests__/diff-checker.test.ts b/__tests__/features/miscellaneous/diff-checker/utils/diff.test.ts similarity index 100% rename from __tests__/diff-checker.test.ts rename to __tests__/features/miscellaneous/diff-checker/utils/diff.test.ts diff --git a/__tests__/find-replace-search.test.ts b/__tests__/features/miscellaneous/find-replace/utils/search.test.ts similarity index 100% rename from __tests__/find-replace-search.test.ts rename to __tests__/features/miscellaneous/find-replace/utils/search.test.ts diff --git a/__tests__/find-replace-text-transforms.test.ts b/__tests__/features/miscellaneous/find-replace/utils/text-transforms.test.ts similarity index 100% rename from __tests__/find-replace-text-transforms.test.ts rename to __tests__/features/miscellaneous/find-replace/utils/text-transforms.test.ts diff --git a/__tests__/json-tool.test.ts b/__tests__/features/miscellaneous/json-tool/utils/json-tool.test.ts similarity index 100% rename from __tests__/json-tool.test.ts rename to __tests__/features/miscellaneous/json-tool/utils/json-tool.test.ts diff --git a/__tests__/link-extractor.test.ts b/__tests__/features/miscellaneous/link-extractor/utils/link-extractor.test.ts similarity index 100% rename from __tests__/link-extractor.test.ts rename to __tests__/features/miscellaneous/link-extractor/utils/link-extractor.test.ts diff --git a/__tests__/svg-converter.test.ts b/__tests__/features/miscellaneous/svg-converter/utilities/svg-converter.test.ts similarity index 100% rename from __tests__/svg-converter.test.ts rename to __tests__/features/miscellaneous/svg-converter/utilities/svg-converter.test.ts diff --git a/__tests__/github-activity-selection.test.ts b/__tests__/server/github/service.test.ts similarity index 100% rename from __tests__/github-activity-selection.test.ts rename to __tests__/server/github/service.test.ts diff --git a/__tests__/spotify-tracks.test.ts b/__tests__/server/spotify/tracks.test.ts similarity index 100% rename from __tests__/spotify-tracks.test.ts rename to __tests__/server/spotify/tracks.test.ts diff --git a/__tests__/server/ytmusic/parser.test.ts b/__tests__/server/ytmusic/parser.test.ts new file mode 100644 index 00000000..95d3aacf --- /dev/null +++ b/__tests__/server/ytmusic/parser.test.ts @@ -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' + ) + }) +}) diff --git a/__tests__/server/ytmusic/tracks.test.ts b/__tests__/server/ytmusic/tracks.test.ts new file mode 100644 index 00000000..a3b31ad6 --- /dev/null +++ b/__tests__/server/ytmusic/tracks.test.ts @@ -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([]) + }) +}) diff --git a/next.config.ts b/next.config.ts index 03d7a68c..c787ad51 100644 --- a/next.config.ts +++ b/next.config.ts @@ -40,6 +40,12 @@ const nextConfig: NextConfig = { hostname: 'yt3.googleusercontent.com', port: '', pathname: '**' + }, + { + protocol: 'https', + hostname: 'i.ytimg.com', + port: '', + pathname: '**' } ], formats: ['image/webp', 'image/avif'], diff --git a/src/app/(marketing)/blog/posts/yappin/stop-using-arrow-fnc-and-HUGE-REVEAL.md b/src/app/(marketing)/blog/posts/yappin/stop-using-arrow-fnc-and-HUGE-REVEAL.md index cae0709c..046ec4df 100644 --- a/src/app/(marketing)/blog/posts/yappin/stop-using-arrow-fnc-and-HUGE-REVEAL.md +++ b/src/app/(marketing)/blog/posts/yappin/stop-using-arrow-fnc-and-HUGE-REVEAL.md @@ -24,12 +24,14 @@ Watch this: ```typescript export const SomeView = () => { - return () => {} -export function SomeView() { - return () => {} -f` -You see which has fewer characters!? Crazy right? + return () => {} +} -On top of that hosting rocks. Please stop returning your arrow functions. +export function SomeView() { + return () => {} +} +``` +You see which has fewer characters!? Crazy right? +On top of that hoisting rocks. Please stop returning your arrow functions. diff --git a/src/app/(marketing)/packages/[slug]/page.tsx b/src/app/(marketing)/packages/[slug]/page.tsx index 385807f0..54b457d4 100644 --- a/src/app/(marketing)/packages/[slug]/page.tsx +++ b/src/app/(marketing)/packages/[slug]/page.tsx @@ -119,15 +119,6 @@ async function PackagePageContent({ params }: Props) { url: baseUrl }, keywords: pkg.keywords.join(', ') - }, - { - '@type': 'FAQPage', - '@id': `${pageUrl}#faq`, - mainEntity: pkg.faqs.map(faq => ({ - '@type': 'Question', - name: faq.question, - acceptedAnswer: { '@type': 'Answer', text: faq.answer } - })) } ] } @@ -223,12 +214,6 @@ async function PackagePageContent({ params }: Props) { > API examples - - FAQ - @@ -403,32 +388,6 @@ async function PackagePageContent({ params }: Props) { )} - -
- FAQ -

- Before adding it to your stack. -

-
- {pkg.faqs.map(faq => ( -
-

- {faq.question} -

-

- {faq.answer} -

-
- ))} -
-
) } diff --git a/src/app/(marketing)/ytmusic/page.tsx b/src/app/(marketing)/ytmusic/page.tsx new file mode 100644 index 00000000..db9618fc --- /dev/null +++ b/src/app/(marketing)/ytmusic/page.tsx @@ -0,0 +1,41 @@ +import type { Metadata } from 'next' +import { Suspense } from 'react' +import { connection } from 'next/server' +import { createPageMetadata } from '@/core/metadata/base' +import { YTMusicDiagnostics } from '@/features/ytmusic/components/ytmusic-diagnostics' +import { getYTMusicResult } from '@/server/ytmusic' + +export const metadata: Metadata = createPageMetadata({ + title: 'YouTube Music signal check', + description: + 'Live diagnostics for the YouTube Music listening-history integration.', + canonical: '/ytmusic', + noIndex: true +}) + +export default function YTMusicPage() { + return ( + }> + + + ) +} + +async function LiveDiagnostics() { + await connection() + const initialResult = await getYTMusicResult(20) + return +} + +function DiagnosticsSkeleton() { + return ( +
+
+
+ {Array.from({ length: 4 }, (_, index) => ( +
+ ))} +
+
+ ) +} diff --git a/src/app/(tools)/tools/[slug]/page.tsx b/src/app/(tools)/tools/[slug]/page.tsx index 671611c0..f280ce6c 100644 --- a/src/app/(tools)/tools/[slug]/page.tsx +++ b/src/app/(tools)/tools/[slug]/page.tsx @@ -7,7 +7,6 @@ import { createPageMetadata } from '@/core/metadata/base' import { baseUrl } from '@/core/config/site' import { BreadcrumbStructuredData, - FaqStructuredData, ToolStructuredData } from '@/components/seo/structured-data' import { @@ -69,7 +68,6 @@ async function ToolPage({ params }: Props) { { name: tool.name, url: `/tools/${tool.slug}` } ]} /> - {seo ? : null}
-
-
-
-
+
+
+
+
+
+
+
+
+ +
) } diff --git a/src/app/api/activity/combined/combine.ts b/src/app/api/activity/combined/combine.ts index f29a01f0..90915c9c 100644 --- a/src/app/api/activity/combined/combine.ts +++ b/src/app/api/activity/combined/combine.ts @@ -3,7 +3,7 @@ import { getCachedGitHubContributions } from '@/server/github' import { getSpotifyTracks } from '@/server/spotify' -import { getYTMusicTracks, hasYTMusicCredentials } from '@/server/ytmusic' +import { getYTMusicTracks } from '@/server/ytmusic' import type { CombinedActivityResponse } from './types' export async function getCombinedActivity( @@ -17,17 +17,17 @@ export async function getCombinedActivity( currentYearContributions, previousYearContributions, recentActivity, - spotifyTracks, - ytmTracks + spotifyTracks ] = await Promise.all([ getCachedGitHubContributions(currentYear), getCachedGitHubContributions(previousYear), getCachedGitHubActivity(activityLimit), - getSpotifyTracks(tracksLimit), - hasYTMusicCredentials() - ? getYTMusicTracks(tracksLimit) - : Promise.resolve([] as any[]) + getSpotifyTracks(tracksLimit) ]) + const tracks = + spotifyTracks.length > 0 + ? spotifyTracks + : await getYTMusicTracks(tracksLimit) const contributionsMap: Record< string, @@ -47,8 +47,6 @@ export async function getCombinedActivity( } } - const tracks = spotifyTracks.length > 0 ? spotifyTracks : ytmTracks - return { contributions: Object.values(contributionsMap), totalContributions: diff --git a/src/app/api/ytmusic/recent/route.ts b/src/app/api/ytmusic/recent/route.ts index 69866847..da6652dd 100644 --- a/src/app/api/ytmusic/recent/route.ts +++ b/src/app/api/ytmusic/recent/route.ts @@ -1,9 +1,8 @@ import { NextResponse } from 'next/server' -import { getYTMusicTracks } from '@/server/ytmusic/tracks' -import { hasYTMusicCredentials } from '@/server/ytmusic/auth' +import { unstable_rethrow } from 'next/navigation' +import { getYTMusicResult } from '@/server/ytmusic/tracks' import { parseBoundedIntParam } from '@/shared/lib/request-params' - export async function GET(request: Request) { try { const { searchParams } = new URL(request.url) @@ -12,21 +11,39 @@ export async function GET(request: Request) { min: 1, max: 50 }) + const forceRefresh = searchParams.get('refresh') === '1' + const result = await getYTMusicResult(limit, { forceRefresh }) + const status = getHttpStatus(result.status) - if (!hasYTMusicCredentials()) { - return NextResponse.json( - { error: 'No YouTube Music cookie configured', tracks: [] }, - { status: 200 } - ) - } - - const tracks = await getYTMusicTracks(limit) - return NextResponse.json({ tracks }) + return NextResponse.json(result, { + status, + headers: { 'Cache-Control': 'private, no-store' } + }) } catch (error) { + unstable_rethrow(error) console.error('[YTM API] Error:', error) return NextResponse.json( - { error: 'Failed to fetch tracks', tracks: [] }, - { status: 500 } + { + status: 'error', + source: 'none', + tracks: [], + message: 'The YouTube Music endpoint failed unexpectedly.', + credentialsConfigured: false, + isStale: false, + fetchedAt: new Date().toISOString(), + cacheUpdatedAt: null + }, + { + status: 500, + headers: { 'Cache-Control': 'private, no-store' } + } ) } } + +function getHttpStatus(status: string): number { + if (status === 'unauthorized') return 401 + if (status === 'unconfigured') return 503 + if (status === 'error') return 502 + return 200 +} diff --git a/src/components/landing/activity/contribution-graph.tsx b/src/components/landing/activity/contribution-graph.tsx index 1bac6818..a0c35a0f 100644 --- a/src/components/landing/activity/contribution-graph.tsx +++ b/src/components/landing/activity/contribution-graph.tsx @@ -415,6 +415,50 @@ export function ActivityContributionGraph({ return activityData.reduce((sum, day) => sum + day.githubCount, 0) }, [activityData]) + const cellRefs = useRef>(new Map()) + const [focusedCellIndex, setFocusedCellIndex] = useState(null) + + const todayCellIndex = useMemo(() => { + const todayStr = new Date().toISOString().split('T')[0] + for (let weekIndex = 0; weekIndex < weeks.length; weekIndex++) { + const dayIndex = weeks[weekIndex].findIndex( + day => day.date === todayStr + ) + if (dayIndex !== -1) return weekIndex * 7 + dayIndex + } + return 0 + }, [weeks]) + + const activeCellIndex = focusedCellIndex ?? todayCellIndex + + function handleGridKeyDown(event: React.KeyboardEvent) { + const totalCells = totalWeeks * 7 + const moveByKey: Record = { + ArrowRight: 7, + ArrowLeft: -7, + ArrowDown: 1, + ArrowUp: -1 + } + + let nextIndex: number + if (event.key === 'Home') { + nextIndex = 0 + } else if (event.key === 'End') { + nextIndex = totalCells - 1 + } else if (event.key in moveByKey) { + nextIndex = Math.min( + totalCells - 1, + Math.max(0, activeCellIndex + moveByKey[event.key]) + ) + } else { + return + } + + event.preventDefault() + setFocusedCellIndex(nextIndex) + cellRefs.current.get(nextIndex)?.focus() + } + const handleDayClick = ( day: ActivityDay, event: React.MouseEvent @@ -490,6 +534,7 @@ export function ActivityContributionGraph({ style={{ gridTemplateColumns: `repeat(${totalWeeks}, 1fr)` }} + onKeyDown={handleGridKeyDown} > {weeks.map((week, weekIndex) => (
{week.map((day, dayIndex) => { const hasData = !!day.date - const delayMs = - cellDelays[weekIndex * 7 + dayIndex] ?? - 0 + const flatIndex = weekIndex * 7 + dayIndex + const delayMs = cellDelays[flatIndex] ?? 0 const isToday = day.date === new Date().toISOString().split('T')[0] @@ -509,6 +553,26 @@ export function ActivityContributionGraph({
) diff --git a/src/components/layout/breadcrumbs.tsx b/src/components/layout/breadcrumbs.tsx index 5449a06c..2e9a9e75 100644 --- a/src/components/layout/breadcrumbs.tsx +++ b/src/components/layout/breadcrumbs.tsx @@ -57,14 +57,42 @@ function generateBreadcrumbs(pathname: string): BreadcrumbItem[] { return breadcrumbs } -function BreadcrumbsContent({ params }: BreadcrumbProps) { - const pathname = usePathname() +function CrumbLinkWithLang({ href, label }: { href: string; label: string }) { const searchParams = useSearchParams() - const breadcrumbs = generateBreadcrumbs(pathname) - const langParam = searchParams.get('lang') const linkParams = langParam ? `?lang=${langParam}` : '' + return ( + + {label} + + ) +} + +function CrumbLink({ href, label }: { href: string; label: string }) { + return ( + + {label} + + } + > + + + ) +} + +function BreadcrumbsContent({ params }: BreadcrumbProps) { + const pathname = usePathname() + const breadcrumbs = generateBreadcrumbs(pathname) + if (pathname === '/' || breadcrumbs.length === 0) { return null } @@ -94,12 +122,10 @@ function BreadcrumbsContent({ params }: BreadcrumbProps) { {crumb.label.toLowerCase()} ) : ( - - {crumb.label.toLowerCase()} - + )} @@ -110,9 +136,5 @@ function BreadcrumbsContent({ params }: BreadcrumbProps) { } export function Breadcrumbs(props: BreadcrumbProps) { - return ( - - - - ) + return } diff --git a/src/features/miscellaneous/components/icons/animated-icons.css b/src/features/miscellaneous/components/icons/animated-icons.css index 403e9c67..f9173ca3 100644 --- a/src/features/miscellaneous/components/icons/animated-icons.css +++ b/src/features/miscellaneous/components/icons/animated-icons.css @@ -1,3 +1,7 @@ +.ai-icon { + overflow: visible; +} + .ai-icon * { transform-box: fill-box; transform-origin: center; diff --git a/src/features/miscellaneous/components/media-dropzone.tsx b/src/features/miscellaneous/components/media-dropzone.tsx index 30d423f9..2eb685b7 100644 --- a/src/features/miscellaneous/components/media-dropzone.tsx +++ b/src/features/miscellaneous/components/media-dropzone.tsx @@ -64,7 +64,7 @@ export function MediaDropzone({ {file.name} - {bytesToHuman(file.size)} — drop or click to replace + {bytesToHuman(file.size)} - drop or click to replace ) : ( diff --git a/src/features/miscellaneous/components/media-trim-panel.tsx b/src/features/miscellaneous/components/media-trim-panel.tsx index f8ea3f6e..0ed58c64 100644 --- a/src/features/miscellaneous/components/media-trim-panel.tsx +++ b/src/features/miscellaneous/components/media-trim-panel.tsx @@ -56,8 +56,10 @@ export function MediaTrimPanel({ } video.currentTime = trim.start - void video.play() - setPreviewing(true) + void video.play().then( + () => setPreviewing(true), + () => setPreviewing(false) + ) } return ( @@ -84,6 +86,7 @@ export function MediaTrimPanel({ ref={videoRef} src={fileUrl} playsInline + preload="metadata" controls className="max-h-80 w-full bg-black/40" onLoadedMetadata={event => @@ -150,7 +153,7 @@ export function MediaTrimPanel({ ? 'The browser cannot preview this codec, so trimming is unavailable. Converting still processes the whole clip.' : isFullSelection ? 'Full clip selected. The whole video will be processed.' - : `Keeps ${formatRange(trim.start, trim.end)} — ${formatSeconds(selectedLength)} (${((selectedLength / duration) * 100).toFixed(0)}% of source).`} + : `Keeps ${formatRange(trim.start, trim.end)} - ${formatSeconds(selectedLength)} (${((selectedLength / duration) * 100).toFixed(0)}% of source).`}

diff --git a/src/features/miscellaneous/components/tool-quick-nav.tsx b/src/features/miscellaneous/components/tool-quick-nav.tsx index ffa15c6d..b6c947d6 100644 --- a/src/features/miscellaneous/components/tool-quick-nav.tsx +++ b/src/features/miscellaneous/components/tool-quick-nav.tsx @@ -35,7 +35,7 @@ export function ToolQuickNav({ currentSlug }: Props) { href={`/tools/${tool.slug}`} aria-current={isCurrent ? 'page' : undefined} className={cn( - 'inline-flex items-center gap-1.5 rounded-sm border px-2 py-1 text-xs transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring', + 'ai-trigger inline-flex items-center gap-1.5 overflow-visible rounded-sm border px-2 py-1 text-xs transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring', isCurrent ? 'border-foreground/50 bg-foreground/10 text-foreground' : 'border-border/50 text-muted-foreground hover:border-border hover:text-foreground' diff --git a/src/features/miscellaneous/components/tool-renderer.tsx b/src/features/miscellaneous/components/tool-renderer.tsx index bd7848d4..6f23d90d 100644 --- a/src/features/miscellaneous/components/tool-renderer.tsx +++ b/src/features/miscellaneous/components/tool-renderer.tsx @@ -1,43 +1,67 @@ 'use client' -import { useEffect } from 'react' import nextDynamic from 'next/dynamic' -import type { ComponentType } from 'react' +import type { ComponentType, ReactNode } from 'react' import type { TToolSlug } from '../constants/tools' -import { useRecentTools } from '../hooks/use-tool-usage' - -function ToolSkeleton() { - return ( -
-
-
-
-
- ) -} +import { + CoordinateMarkerSkeleton, + DiffCheckerSkeleton, + FindReplaceSkeleton, + GifToVideoSkeleton, + HemelsbreedSkeleton, + JsonToolSkeleton, + LinkExtractorSkeleton, + MyLocationSkeleton, + SendableVideoSkeleton, + SvgConverterSkeleton, + VideoToGifSkeleton +} from './tool-skeletons' type TLoader = () => Promise<{ default: ComponentType }> -function lazyTool(loader: TLoader, ssr = false) { - return nextDynamic(loader, { ssr, loading: ToolSkeleton }) +function lazyTool(loader: TLoader, skeleton: () => ReactNode, ssr = false) { + return nextDynamic(loader, { ssr, loading: skeleton }) } const TOOL_COMPONENTS: Record = { - 'find-replace': lazyTool(() => import('../find-replace')), - 'diff-checker': lazyTool(() => import('../diff-checker'), true), - 'link-extractor': lazyTool(() => import('../link-extractor'), true), - 'json-tool': lazyTool(() => import('../json-tool'), true), - 'svg-converter': lazyTool(() => import('../svg-converter'), true), - hemelsbreed: lazyTool(() => import('../hemelsbreed')), - 'coordinate-marker': lazyTool(() => import('../coordinate-marker')), - 'my-location': lazyTool(() => import('../my-location')), - 'sendable-video': lazyTool(() => import('../sendable-video')), - 'gif-to-video': lazyTool(() => import('../gif-to-video')), - 'video-to-gif': lazyTool(() => import('../video-to-gif')) + 'find-replace': lazyTool( + () => import('../find-replace'), + FindReplaceSkeleton + ), + 'diff-checker': lazyTool( + () => import('../diff-checker'), + DiffCheckerSkeleton, + true + ), + 'link-extractor': lazyTool( + () => import('../link-extractor'), + LinkExtractorSkeleton, + true + ), + 'json-tool': lazyTool(() => import('../json-tool'), JsonToolSkeleton, true), + 'svg-converter': lazyTool( + () => import('../svg-converter'), + SvgConverterSkeleton, + true + ), + hemelsbreed: lazyTool(() => import('../hemelsbreed'), HemelsbreedSkeleton), + 'coordinate-marker': lazyTool( + () => import('../coordinate-marker'), + CoordinateMarkerSkeleton + ), + 'my-location': lazyTool(() => import('../my-location'), MyLocationSkeleton), + 'sendable-video': lazyTool( + () => import('../sendable-video'), + SendableVideoSkeleton + ), + 'gif-to-video': lazyTool( + () => import('../gif-to-video'), + GifToVideoSkeleton + ), + 'video-to-gif': lazyTool( + () => import('../video-to-gif'), + VideoToGifSkeleton + ) } type Props = { @@ -45,12 +69,6 @@ type Props = { } export function ToolRenderer({ slug }: Props) { - const { markUsed } = useRecentTools() - - useEffect(() => { - markUsed(slug) - }, [slug, markUsed]) - const Tool = TOOL_COMPONENTS[slug] if (!Tool) return null diff --git a/src/features/miscellaneous/components/tool-seo-content.tsx b/src/features/miscellaneous/components/tool-seo-content.tsx index 905be45d..f9ec2604 100644 --- a/src/features/miscellaneous/components/tool-seo-content.tsx +++ b/src/features/miscellaneous/components/tool-seo-content.tsx @@ -71,24 +71,6 @@ export function ToolSeoContent({ name, content }: Props) { {content.formats}

- -
-

- Frequently asked questions -

-
- {content.faqs.map(faq => ( -
- - {faq.question} - -

- {faq.answer} -

-
- ))} -
-
) } diff --git a/src/features/miscellaneous/components/tool-skeletons.tsx b/src/features/miscellaneous/components/tool-skeletons.tsx new file mode 100644 index 00000000..12e65d79 --- /dev/null +++ b/src/features/miscellaneous/components/tool-skeletons.tsx @@ -0,0 +1,383 @@ +import { cn } from '@/shared/lib/cn' + +type TBlockProps = { + className?: string +} + +function Block({ className }: TBlockProps) { + return
+} + +function PanelHeader({ className }: TBlockProps) { + return ( +
+ + +
+ ) +} + +function PanelFooter() { + return ( +
+ +
+ ) +} + +export function FindReplaceSkeleton() { + return ( +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+
+
+ + + +
+
+
+ + + +
+
+ + +
+ ) +} + +export function DiffCheckerSkeleton() { + return ( +
+
+ + +
+
+
+ + + +
+
+ + + +
+
+
+
+ + +
+
+ +
+
+
+ ) +} + +export function LinkExtractorSkeleton() { + return ( +
+
+ + + +
+
+
+
+
+ + +
+ +
+ +
+
+
+
+ + +
+
+ +
+ +
+ +
+
+
+
+ ) +} + +export function JsonToolSkeleton() { + return ( +
+
+ +
+
+
+
+ + +
+ +
+
+
+ + +
+ +
+
+
+ ) +} + +export function SvgConverterSkeleton() { + return ( +
+
+
+ + +
+ +
+
+
+
+
+ + +
+ +
+
+
+ + +
+ + +
+ +
+
+
+
+
+ +
+
+
+ + +
+ + +
+ + + + +
+
+
+
+
+
+ + +
+
+ +
+ +
+
+
+
+ ) +} + +export function HemelsbreedSkeleton() { + return ( +
+
+ +
+ + +
+ + +
+ +
+ ) +} + +export function CoordinateMarkerSkeleton() { + return ( +
+
+ + + +
+ +
+ ) +} + +export function MyLocationSkeleton() { + return ( +
+ +
+ +
+
+ ) +} + +function DropzoneSkeleton() { + return ( +
+ + + +
+ ) +} + +export function SendableVideoSkeleton() { + return ( +
+
+ +
+
+ + + +
+
+ ) +} + +export function GifToVideoSkeleton() { + return ( +
+ +
+ + + +
+
+ ) +} + +export function VideoToGifSkeleton() { + return ( +
+ +
+ +
+
+ + +
+
+ ) +} diff --git a/src/features/miscellaneous/components/tools-hub.tsx b/src/features/miscellaneous/components/tools-hub.tsx index 47e725ed..bce3247f 100644 --- a/src/features/miscellaneous/components/tools-hub.tsx +++ b/src/features/miscellaneous/components/tools-hub.tsx @@ -1,21 +1,19 @@ 'use client' -import { useDeferredValue, useMemo, useRef, useState } from 'react' +import { useMemo, useRef, useState } from 'react' import type { ReactNode } from 'react' -import { Search, Star, Clock } from 'lucide-react' +import { Search, Star } from 'lucide-react' import { useShortcutMap } from '@remcostoeten/use-shortcut/react' import { Input } from '@/components/ui/input' import { Section } from '@/components/ui/section' import { cn } from '@/shared/lib/cn' import { - getToolBySlug, getToolCountsByCategory, searchTools, TOOL_CATEGORIES, TOOL_CATEGORY_LABELS, TOOLS } from '../constants/tools' -import { useRecentTools } from '../hooks/use-tool-usage' import type { TToolCategory, TToolDefinition } from '../types' import { ToolCard } from './tool-card' @@ -45,17 +43,22 @@ function CategoryFilters({ onChange: (category: TCategoryFilter) => void }) { const counts = getToolCountsByCategory() - const options: { value: TCategoryFilter; label: string; count: number }[] = [ - { value: 'all', label: 'All', count: TOOLS.length }, - ...TOOL_CATEGORIES.map(category => ({ - value: category, - label: TOOL_CATEGORY_LABELS[category], - count: counts[category] - })) - ] + const options: { value: TCategoryFilter; label: string; count: number }[] = + [ + { value: 'all', label: 'All', count: TOOLS.length }, + ...TOOL_CATEGORIES.map(category => ({ + value: category, + label: TOOL_CATEGORY_LABELS[category], + count: counts[category] + })) + ] return ( -
+
{options.map(option => ( ))}
) } -function RecentTools() { - const { recent, hydrated } = useRecentTools() - - if (!hydrated || recent.length === 0) return null - - const tools = recent - .map(slug => getToolBySlug(slug)) - .filter((tool): tool is TToolDefinition => Boolean(tool)) - - if (tools.length === 0) return null - - return ( -
-
-
- - Continue where you left off -
- -
-
- ) -} - export function ToolsHub({ intro }: Props) { const [query, setQuery] = useState('') const [category, setCategory] = useState('all') - const deferredQuery = useDeferredValue(query) const searchRef = useRef(null) useShortcutMap({ @@ -116,11 +96,11 @@ export function ToolsHub({ intro }: Props) { }) const tools = useMemo(() => { - const matches = searchTools(deferredQuery) + const matches = searchTools(query) return category === 'all' ? matches : matches.filter(tool => tool.category === category) - }, [deferredQuery, category]) + }, [query, category]) return (
@@ -148,7 +128,10 @@ export function ToolsHub({ intro }: Props) { />
- +

- -

{tools.length > 0 ? ( diff --git a/src/features/miscellaneous/constants/tool-seo.ts b/src/features/miscellaneous/constants/tool-seo.ts index 7ee85bf6..21c3e882 100644 --- a/src/features/miscellaneous/constants/tool-seo.ts +++ b/src/features/miscellaneous/constants/tool-seo.ts @@ -1,10 +1,5 @@ import type { TToolSlug } from './tools' -export type TToolFaq = { - question: string - answer: string -} - export type TToolSeoContent = { metaTitle: string metaDescription: string @@ -12,7 +7,6 @@ export type TToolSeoContent = { highlights: readonly string[] steps: readonly string[] formats: string - faqs: readonly TToolFaq[] } const TOOL_SEO_CONTENT = { @@ -32,21 +26,7 @@ const TOOL_SEO_CONTENT = { 'Copy the generated TSX or export the complete collection as files or a ZIP.' ], formats: - 'Input: SVG markup and .svg files. Output: React TSX components, combined registries and downloadable ZIP packages.', - faqs: [ - { - question: 'Are my SVG files uploaded?', - answer: 'No. Parsing, sanitizing and component generation happen locally in your browser.' - }, - { - question: 'Can I convert multiple SVG icons at once?', - answer: 'Yes. You can process a collection, rename components and export individual files or one combined package.' - }, - { - question: 'Does it prevent duplicate SVG IDs?', - answer: 'Yes. Internal IDs and their references are rewritten so multiple generated icons can render safely on the same page.' - } - ] + 'Input: SVG markup and .svg files. Output: React TSX components, combined registries and downloadable ZIP packages.' }, 'sendable-video': { metaTitle: 'WhatsApp Video Converter — MOV/MKV to MP4', @@ -64,22 +44,7 @@ const TOOL_SEO_CONTENT = { 'Export a compatible MP4 or an optimized GIF and download it locally.' ], formats: - 'Input: MP4, MOV, MKV, WebM and AVI. Output: chat-friendly H.264 MP4 or an optimized animated GIF.', - faqs: [ - { - question: - 'Why does a video work locally but not in WhatsApp Web?', - answer: 'The container may be supported while its video or audio codec is not. Re-encoding to a conventional H.264 MP4 resolves many compatibility problems.' - }, - { - question: 'Is my video uploaded anywhere?', - answer: 'No. The file is decoded and encoded locally in your browser and remains on your device.' - }, - { - question: 'Can I trim the video before converting it?', - answer: 'Yes. Set the clip range in the preview before exporting to avoid encoding footage you do not need.' - } - ] + 'Input: MP4, MOV, MKV, WebM and AVI. Output: chat-friendly H.264 MP4 or an optimized animated GIF.' }, 'gif-to-video': { metaTitle: 'GIF to MP4 & WebM Converter', @@ -97,21 +62,7 @@ const TOOL_SEO_CONTENT = { 'Convert, inspect the preview and download the finished video.' ], formats: - 'Input: animated GIF files. Output: H.264 MP4 for compatibility or WebM for efficient web delivery.', - faqs: [ - { - question: 'Why convert a GIF to MP4 or WebM?', - answer: 'Video compression is substantially more efficient than animated GIF compression, so the result is often smaller at similar visual quality.' - }, - { - question: 'Will the animation still loop?', - answer: 'The complete animation is preserved in the exported video. Whether it loops during playback depends on the website or video player.' - }, - { - question: 'Does the GIF get uploaded?', - answer: 'No. Conversion runs locally in the browser, and the input and output stay on your device.' - } - ] + 'Input: animated GIF files. Output: H.264 MP4 for compatibility or WebM for efficient web delivery.' }, 'video-to-gif': { metaTitle: 'Video to GIF Converter — MP4, MOV & WebM', @@ -129,22 +80,7 @@ const TOOL_SEO_CONTENT = { 'Render the looping GIF, review it and download the result.' ], formats: - 'Input: MP4, MOV, MKV, WebM and AVI video. Output: optimized animated GIF with a generated color palette.', - faqs: [ - { - question: 'How can I make the GIF file smaller?', - answer: 'Trim the clip, reduce its width or lower the frame rate. Shorter dimensions and fewer frames usually have the largest effect.' - }, - { - question: - 'Can I preview the result before the full conversion?', - answer: 'Yes. The preview renders a short sample and estimates the complete file size using the selected settings.' - }, - { - question: 'Is the source video uploaded?', - answer: 'No. FFmpeg runs inside your browser, so the source video and generated GIF remain local.' - } - ] + 'Input: MP4, MOV, MKV, WebM and AVI video. Output: optimized animated GIF with a generated color palette.' } } as const satisfies Partial> diff --git a/src/features/miscellaneous/coordinate-marker/index.tsx b/src/features/miscellaneous/coordinate-marker/index.tsx index 6ec8d89b..b74f3e98 100644 --- a/src/features/miscellaneous/coordinate-marker/index.tsx +++ b/src/features/miscellaneous/coordinate-marker/index.tsx @@ -15,6 +15,7 @@ import { noop } from '@/shared/lib/noop' import 'leaflet/dist/leaflet.css' import type * as L from 'leaflet' import { SendToTool } from '../components/send-to-tool' +import { CoordinateMarkerSkeleton } from '../components/tool-skeletons' import { geolocationErrorMessage, locate } from '../utils/geolocation' import { consumeLocations, @@ -405,16 +406,7 @@ export default function CoordinateMarkerTool() { ) if (!ready) { - return ( -
-
-
-
- ) + return } return ( diff --git a/src/features/miscellaneous/diff-checker/index.tsx b/src/features/miscellaneous/diff-checker/index.tsx index 5667aaad..4e8b1b00 100644 --- a/src/features/miscellaneous/diff-checker/index.tsx +++ b/src/features/miscellaneous/diff-checker/index.tsx @@ -4,6 +4,7 @@ import { useEffect, useRef, useState } from 'react' import { ArrowLeftRight, Eraser } from 'lucide-react' import { toast } from 'sonner' import { Button } from '@/components/ui/button' +import { DiffCheckerSkeleton } from '../components/tool-skeletons' import { useLocalStorage } from '../hooks/use-local-storage' import { DiffOutput } from './components/diff-output' import { TextPanel } from './components/text-panel' @@ -34,17 +35,7 @@ export default function DiffCheckerTool() { }, [leftHydrated, rightHydrated, setLeft, setRight]) if (!leftHydrated || !rightHydrated) { - return ( -
-
-
-
-
- ) + return } function swap() { diff --git a/src/features/miscellaneous/find-replace/index.tsx b/src/features/miscellaneous/find-replace/index.tsx index e39e6cdd..b737cade 100644 --- a/src/features/miscellaneous/find-replace/index.tsx +++ b/src/features/miscellaneous/find-replace/index.tsx @@ -19,6 +19,7 @@ import { CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible' +import { FindReplaceSkeleton } from '../components/tool-skeletons' import { writeDiffHandoff } from '../diff-checker/utils/handoff' import { AdvancedReplace } from './components/advanced-replace' import { EditorPanels } from './components/editor-panels' @@ -165,17 +166,7 @@ export default function FindReplaceTool() { } if (!store.hydrated) { - return ( -
-
-
-
-
- ) + return } return ( diff --git a/src/features/miscellaneous/gif-to-video/hooks/use-gif-to-video-store.ts b/src/features/miscellaneous/gif-to-video/hooks/use-gif-to-video-store.ts index a4b6d1d4..9781e170 100644 --- a/src/features/miscellaneous/gif-to-video/hooks/use-gif-to-video-store.ts +++ b/src/features/miscellaneous/gif-to-video/hooks/use-gif-to-video-store.ts @@ -3,6 +3,7 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { noop } from '@/shared/lib/noop' import { useLocalStorage } from '../../hooks/use-local-storage' +import { useMediaSession } from '../../hooks/use-media-session' import type { TMediaOutput, TMediaStatus } from '../../types/media' import { deleteQuiet, @@ -58,6 +59,29 @@ export function useGifToVideoStore() { const [output, setOutput] = useState(null) const urlsRef = useRef([]) + const restoredRef = useRef(false) + + const { persistFile, persistOutput } = useMediaSession( + STORAGE_KEY, + session => { + if (restoredRef.current) return + restoredRef.current = true + setFile(session.file) + setFileUrl(URL.createObjectURL(session.file)) + if (session.output) { + setOutput({ + url: URL.createObjectURL(session.output.blob), + name: session.output.name, + size: session.output.blob.size, + mime: session.output.mime + }) + } + setStatus({ + message: 'Restored your previous session from this browser.', + mode: 'idle' + }) + } + ) useEffect(() => { urlsRef.current = [fileUrl, output?.url].filter( @@ -73,16 +97,18 @@ export function useGifToVideoStore() { ) const clearOutput = useCallback(() => { + persistOutput(null) setOutput(previous => { if (previous) URL.revokeObjectURL(previous.url) return null }) - }, []) + }, [persistOutput]) const selectFile = useCallback( (next: File | null) => { if (busy) return + restoredRef.current = true clearOutput() setFileUrl(previous => { if (previous) URL.revokeObjectURL(previous) @@ -91,6 +117,7 @@ export function useGifToVideoStore() { setProgress(0) if (!next) { + persistFile(null) setFile(null) setStatus(IDLE_STATUS) return @@ -98,6 +125,7 @@ export function useGifToVideoStore() { const sizeMb = next.size / (1024 * 1024) if (sizeMb > MAX_INPUT_MB) { + persistFile(null) setFile(null) setStatus({ message: `File is ${sizeMb.toFixed(1)} MB. Keep it under ${MAX_INPUT_MB} MB for browser conversion.`, @@ -106,6 +134,7 @@ export function useGifToVideoStore() { return } + persistFile(next) setFile(next) setFileUrl(URL.createObjectURL(next)) setStatus({ @@ -113,7 +142,7 @@ export function useGifToVideoStore() { mode: 'idle' }) }, - [busy, clearOutput] + [busy, clearOutput, persistFile] ) const setFormat = useCallback( @@ -167,9 +196,15 @@ export function useGifToVideoStore() { outputName, OUTPUT_MIMES[options.format] ) + const outputFileName = `${stem(file.name)}.${options.format}` + persistOutput({ + blob, + name: outputFileName, + mime: OUTPUT_MIMES[options.format] + }) setOutput({ url: URL.createObjectURL(blob), - name: `${stem(file.name)}.${options.format}`, + name: outputFileName, size: blob.size, mime: OUTPUT_MIMES[options.format] }) @@ -187,7 +222,7 @@ export function useGifToVideoStore() { setFFmpegProgressHandler(noop) setBusy(false) } - }, [busy, clearOutput, file, options]) + }, [busy, clearOutput, file, options, persistOutput]) return { file, diff --git a/src/features/miscellaneous/hemelsbreed/index.tsx b/src/features/miscellaneous/hemelsbreed/index.tsx index ad676496..e8de47c3 100644 --- a/src/features/miscellaneous/hemelsbreed/index.tsx +++ b/src/features/miscellaneous/hemelsbreed/index.tsx @@ -20,6 +20,7 @@ import { Button } from '@/components/ui/button' import { cn } from '@/shared/lib/cn' import { useLocalStorage } from '../hooks/use-local-storage' import { SendToTool } from '../components/send-to-tool' +import { HemelsbreedSkeleton } from '../components/tool-skeletons' import { geolocationErrorMessage, locate } from '../utils/geolocation' import { consumeLocations, type TLocationPoint } from '../utils/location-handoff' import { appendSavedLocation, locationLabel } from '../utils/locations' @@ -396,16 +397,7 @@ export default function HemelsbreedTool() { } if (!hydrated) { - return ( -
-
-
-
- ) + return } return ( diff --git a/src/features/miscellaneous/hooks/use-media-session.ts b/src/features/miscellaneous/hooks/use-media-session.ts new file mode 100644 index 00000000..9b19e03f --- /dev/null +++ b/src/features/miscellaneous/hooks/use-media-session.ts @@ -0,0 +1,69 @@ +'use client' + +import { useCallback, useEffect, useRef } from 'react' +import type { TTrimRange } from '../types/media' +import { + loadMediaSession, + saveMediaFile, + saveMediaOutput, + saveMediaTrim, + type TPersistedOutput, + type TPersistedSession +} from '../utils/media-persistence' + +export type TRestoredMediaSession = TPersistedSession & { file: File } + +function warn(error: unknown) { + console.warn('Media session persistence failed', error) +} + +/** + * Restores a tool's last media session from IndexedDB on mount and exposes + * fire-and-forget persistence for the input file, trim range, and output. + * onRestore only fires when a file was previously saved. + */ +export function useMediaSession( + toolKey: string, + onRestore: (session: TRestoredMediaSession) => void +) { + const onRestoreRef = useRef(onRestore) + const startedRef = useRef(false) + + useEffect(() => { + onRestoreRef.current = onRestore + }) + + useEffect(() => { + if (startedRef.current) return + startedRef.current = true + loadMediaSession(toolKey) + .then(session => { + if (!session.file) return + onRestoreRef.current({ ...session, file: session.file }) + }) + .catch(warn) + }, [toolKey]) + + const persistFile = useCallback( + (file: File | null) => { + saveMediaFile(toolKey, file).catch(warn) + }, + [toolKey] + ) + + const persistTrim = useCallback( + (trim: TTrimRange | null) => { + saveMediaTrim(toolKey, trim).catch(warn) + }, + [toolKey] + ) + + const persistOutput = useCallback( + (output: TPersistedOutput | null) => { + saveMediaOutput(toolKey, output).catch(warn) + }, + [toolKey] + ) + + return { persistFile, persistTrim, persistOutput } +} diff --git a/src/features/miscellaneous/hooks/use-tool-usage.ts b/src/features/miscellaneous/hooks/use-tool-usage.ts deleted file mode 100644 index 29cd3541..00000000 --- a/src/features/miscellaneous/hooks/use-tool-usage.ts +++ /dev/null @@ -1,28 +0,0 @@ -'use client' - -import { useCallback } from 'react' -import { useLocalStorage } from './use-local-storage' - -const RECENT_KEY = 'misc-tools:recent' -const MAX_RECENT = 6 - -export function useRecentTools() { - const [recent, setRecent, hydrated] = useLocalStorage( - RECENT_KEY, - [] - ) - - const markUsed = useCallback( - (slug: string) => { - setRecent(previous => - [slug, ...previous.filter(item => item !== slug)].slice( - 0, - MAX_RECENT - ) - ) - }, - [setRecent] - ) - - return { recent, markUsed, hydrated } -} diff --git a/src/features/miscellaneous/hooks/use-trim-state.ts b/src/features/miscellaneous/hooks/use-trim-state.ts index 22842fbe..52a62f4b 100644 --- a/src/features/miscellaneous/hooks/use-trim-state.ts +++ b/src/features/miscellaneous/hooks/use-trim-state.ts @@ -40,13 +40,17 @@ export function useTrimState() { setHistory([]) }, []) - const handleMetadata = useCallback((nextDuration: number) => { - if (!Number.isFinite(nextDuration) || nextDuration <= 0) return - const full = { start: 0, end: nextDuration } - setDuration(nextDuration) - setTrim(full) - setHistory([full]) - }, []) + const handleMetadata = useCallback( + (nextDuration: number, initial?: TTrimRange) => { + if (!Number.isFinite(nextDuration) || nextDuration <= 0) return + const full = { start: 0, end: nextDuration } + const start = initial ? clampTrim(initial, nextDuration) : full + setDuration(nextDuration) + setTrim(start) + setHistory(rangesEqual(start, full) ? [full] : [full, start]) + }, + [] + ) const updateTrim = useCallback( (next: TTrimRange) => { diff --git a/src/features/miscellaneous/sendable-video/components/export-panel.tsx b/src/features/miscellaneous/sendable-video/components/export-panel.tsx index 268fb65a..cb47ec95 100644 --- a/src/features/miscellaneous/sendable-video/components/export-panel.tsx +++ b/src/features/miscellaneous/sendable-video/components/export-panel.tsx @@ -3,7 +3,10 @@ import { Download, Film, Image as ImageIcon, Loader2 } from 'lucide-react' import { Button } from '@/components/ui/button' import { cn } from '@/shared/lib/cn' -import { Segmented, ToggleChip } from '../../link-extractor/components/segmented' +import { + Segmented, + ToggleChip +} from '../../link-extractor/components/segmented' import { GIF_FPS_OPTIONS } from '../constants' import type { TSendableVideoStore } from '../hooks/use-sendable-video-store' import { bytesToHuman } from '../../utils/format' @@ -64,18 +67,43 @@ export function ExportPanel({ store }: Props) { Export GIF {output ? ( - + <> + +
+ {output.kind === 'gif' ? ( +
+ Rendered GIF result +
+ Rendered GIF: {output.name} ( + {bytesToHuman(output.size)}) +
+
+ ) : ( +
diff --git a/src/features/miscellaneous/sendable-video/hooks/use-sendable-video-store.ts b/src/features/miscellaneous/sendable-video/hooks/use-sendable-video-store.ts index 503f3c7b..c52b4735 100644 --- a/src/features/miscellaneous/sendable-video/hooks/use-sendable-video-store.ts +++ b/src/features/miscellaneous/sendable-video/hooks/use-sendable-video-store.ts @@ -3,7 +3,9 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { noop } from '@/shared/lib/noop' import { useLocalStorage } from '../../hooks/use-local-storage' +import { useMediaSession } from '../../hooks/use-media-session' import { useTrimState } from '../../hooks/use-trim-state' +import type { TTrimRange } from '../../types/media' import { DEFAULT_OPTIONS, GIF_PRESETS, @@ -18,12 +20,18 @@ import { deleteQuiet, execWithLogs, loadFFmpeg, + probeLogs, readOutputBlob, setFFmpegProgressHandler, writeInputFile } from '../../utils/ffmpeg' import { stem } from '../../utils/format' -import { encodeArgs, gifArgs, remuxArgs } from '../utils/ffmpeg' +import { + encodeArgs, + gifArgs, + isRemuxCompatible, + remuxArgs +} from '../utils/ffmpeg' const IDLE_STATUS: TStatus = { message: 'Select a video to get started.', @@ -60,6 +68,31 @@ export function useSendableVideoStore() { file: null, output: null }) + const pendingTrimRef = useRef(null) + const restoredRef = useRef(false) + + const { persistFile, persistTrim, persistOutput } = useMediaSession( + STORAGE_KEY, + session => { + if (restoredRef.current) return + restoredRef.current = true + pendingTrimRef.current = session.trim + setFile(session.file) + setFileUrl(URL.createObjectURL(session.file)) + if (session.output) { + setOutput({ + url: URL.createObjectURL(session.output.blob), + name: session.output.name, + size: session.output.blob.size, + kind: session.output.mime === 'image/gif' ? 'gif' : 'mp4' + }) + } + setStatus({ + message: 'Restored your previous session from this browser.', + mode: 'idle' + }) + } + ) useEffect(() => { urlsRef.current.file = fileUrl @@ -79,16 +112,19 @@ export function useSendableVideoStore() { ) const clearOutput = useCallback(() => { + persistOutput(null) setOutput(previous => { if (previous) URL.revokeObjectURL(previous.url) return null }) - }, []) + }, [persistOutput]) const selectFile = useCallback( (next: File | null) => { if (busy) return + restoredRef.current = true + pendingTrimRef.current = null clearOutput() setFileUrl(previous => { if (previous) URL.revokeObjectURL(previous) @@ -98,6 +134,7 @@ export function useSendableVideoStore() { setProgress(0) if (!next) { + persistFile(null) setFile(null) setStatus(IDLE_STATUS) return @@ -105,6 +142,7 @@ export function useSendableVideoStore() { const sizeMb = next.size / (1024 * 1024) if (sizeMb > MAX_INPUT_MB) { + persistFile(null) setFile(null) setStatus({ message: `File is ${sizeMb.toFixed(1)} MB. Keep it under ${MAX_INPUT_MB} MB for browser conversion.`, @@ -113,6 +151,7 @@ export function useSendableVideoStore() { return } + persistFile(next) setFile(next) setFileUrl(URL.createObjectURL(next)) setStatus({ @@ -120,9 +159,28 @@ export function useSendableVideoStore() { mode: 'idle' }) }, - [busy, clearOutput, clearTrim] + [busy, clearOutput, clearTrim, persistFile] ) + const handleMetadata = useCallback( + (nextDuration: number) => { + trimState.handleMetadata( + nextDuration, + pendingTrimRef.current ?? undefined + ) + pendingTrimRef.current = null + }, + [trimState.handleMetadata] + ) + + useEffect(() => { + if (!file || trimState.duration <= 0) return + const timeout = setTimeout(() => { + persistTrim(hasTrim ? trim : null) + }, 300) + return () => clearTimeout(timeout) + }, [file, trimState.duration, hasTrim, trim, persistTrim]) + const setMuteAudio = useCallback( (muteAudio: boolean) => { setOptions(previous => ({ ...previous, muteAudio })) @@ -168,7 +226,10 @@ export function useSendableVideoStore() { }) let blob: Blob | null = null - if (!options.muteAudio) { + if ( + !options.muteAudio && + isRemuxCompatible(await probeLogs(ffmpeg, INPUT_NAME)) + ) { try { await execWithLogs(ffmpeg, remuxArgs(range)) blob = await readOutputBlob(ffmpeg, OUTPUT_MP4, 'video/mp4') @@ -183,22 +244,21 @@ export function useSendableVideoStore() { message: 'Re-encoding for compatibility…', mode: 'processing' }) - await execWithLogs( - ffmpeg, - encodeArgs(range, options.muteAudio) - ) + await execWithLogs(ffmpeg, encodeArgs(range, options.muteAudio)) blob = await readOutputBlob(ffmpeg, OUTPUT_MP4, 'video/mp4') } + const outputName = `${stem(file.name)}_sendable.mp4` + persistOutput({ blob, name: outputName, mime: 'video/mp4' }) setOutput({ url: URL.createObjectURL(blob), - name: `${stem(file.name)}_sendable.mp4`, + name: outputName, size: blob.size, kind: 'mp4' }) setProgress(100) setStatus({ - message: 'Done — your MP4 is ready to download.', + message: 'Done - your MP4 is ready to download.', mode: 'success' }) @@ -210,7 +270,7 @@ export function useSendableVideoStore() { setFFmpegProgressHandler(noop) setBusy(false) } - }, [busy, clearOutput, file, hasTrim, options.muteAudio, trim]) + }, [busy, clearOutput, file, hasTrim, options.muteAudio, persistOutput, trim]) const exportGif = useCallback(async () => { if (!file || busy) return @@ -244,15 +304,17 @@ export function useSendableVideoStore() { await execWithLogs(ffmpeg, gifArgs(hasTrim ? trim : null, preset)) const blob = await readOutputBlob(ffmpeg, OUTPUT_GIF, 'image/gif') + const outputName = `${stem(file.name)}_${preset.fps}fps.gif` + persistOutput({ blob, name: outputName, mime: 'image/gif' }) setOutput({ url: URL.createObjectURL(blob), - name: `${stem(file.name)}_${preset.fps}fps.gif`, + name: outputName, size: blob.size, kind: 'gif' }) setProgress(100) setStatus({ - message: 'Done — your GIF is ready to download.', + message: 'Done - your GIF is ready to download.', mode: 'success' }) @@ -264,7 +326,7 @@ export function useSendableVideoStore() { setFFmpegProgressHandler(noop) setBusy(false) } - }, [busy, clearOutput, file, hasTrim, options.gifFps, trim]) + }, [busy, clearOutput, file, hasTrim, options.gifFps, persistOutput, trim]) return { file, @@ -279,7 +341,7 @@ export function useSendableVideoStore() { busy, output, selectFile, - handleMetadata: trimState.handleMetadata, + handleMetadata, updateTrim: trimState.updateTrim, commitTrim: trimState.commitTrim, undoTrim: trimState.undoTrim, diff --git a/src/features/miscellaneous/sendable-video/index.tsx b/src/features/miscellaneous/sendable-video/index.tsx index a621e5f3..d9744ca0 100644 --- a/src/features/miscellaneous/sendable-video/index.tsx +++ b/src/features/miscellaneous/sendable-video/index.tsx @@ -1,5 +1,7 @@ 'use client' +import { X } from 'lucide-react' +import { Button } from '@/components/ui/button' import { MediaDropzone } from '../components/media-dropzone' import { MediaTrimPanel } from '../components/media-trim-panel' import { ExportPanel } from './components/export-panel' @@ -11,15 +13,30 @@ export default function SendableVideo() { return (
- +
+ + {store.file ? ( + + ) : null} +
{store.fileUrl ? ( { + const logs: string[] = [] + const onLog = ({ message }: { message: string }) => { + logs.push(message) + } + ffmpeg.on('log', onLog) + + try { + await ffmpeg.exec(['-i', name]) + } catch { + noop() + } finally { + ffmpeg.off('log', onLog) + } + + return logs.join('\n') +} + /** * Writes a browser File into the ffmpeg virtual filesystem under the given * name, replacing any previous file with that name. diff --git a/src/features/miscellaneous/utils/media-persistence.ts b/src/features/miscellaneous/utils/media-persistence.ts new file mode 100644 index 00000000..f76db9cb --- /dev/null +++ b/src/features/miscellaneous/utils/media-persistence.ts @@ -0,0 +1,152 @@ +'use client' + +import type { TTrimRange } from '../types/media' + +const DB_NAME = 'media-tools' +const STORE_NAME = 'sessions' +const DB_VERSION = 1 + +export type TPersistedOutput = { + blob: Blob + name: string + mime: string +} + +export type TPersistedSession = { + file: File | null + trim: TTrimRange | null + output: TPersistedOutput | null +} + +function inputKey(toolKey: string): string { + return `${toolKey}:input` +} + +function trimKey(toolKey: string): string { + return `${toolKey}:trim` +} + +function outputKey(toolKey: string): string { + return `${toolKey}:output` +} + +function openDatabase(): Promise { + return new Promise((resolve, reject) => { + const request = window.indexedDB.open(DB_NAME, DB_VERSION) + request.onupgradeneeded = () => { + if (!request.result.objectStoreNames.contains(STORE_NAME)) { + request.result.createObjectStore(STORE_NAME) + } + } + request.onsuccess = () => resolve(request.result) + request.onerror = () => reject(request.error) + }) +} + +async function readKey(key: string): Promise { + const db = await openDatabase() + try { + return await new Promise((resolve, reject) => { + const request = db + .transaction(STORE_NAME) + .objectStore(STORE_NAME) + .get(key) + request.onsuccess = () => resolve(request.result as T | undefined) + request.onerror = () => reject(request.error) + }) + } finally { + db.close() + } +} + +async function writeKey(key: string, value: unknown): Promise { + const db = await openDatabase() + try { + await new Promise((resolve, reject) => { + const transaction = db.transaction(STORE_NAME, 'readwrite') + transaction.objectStore(STORE_NAME).put(value, key) + transaction.oncomplete = () => resolve() + transaction.onerror = () => reject(transaction.error) + transaction.onabort = () => reject(transaction.error) + }) + } finally { + db.close() + } +} + +async function deleteKeys(keys: string[]): Promise { + const db = await openDatabase() + try { + await new Promise((resolve, reject) => { + const transaction = db.transaction(STORE_NAME, 'readwrite') + const store = transaction.objectStore(STORE_NAME) + for (const key of keys) store.delete(key) + transaction.oncomplete = () => resolve() + transaction.onerror = () => reject(transaction.error) + transaction.onabort = () => reject(transaction.error) + }) + } finally { + db.close() + } +} + +/** + * Loads a tool's persisted media session (input file, trim range, output) + * from IndexedDB. Missing pieces come back as null. + */ +export async function loadMediaSession( + toolKey: string +): Promise { + const [file, trim, output] = await Promise.all([ + readKey(inputKey(toolKey)), + readKey(trimKey(toolKey)), + readKey(outputKey(toolKey)) + ]) + return { file: file ?? null, trim: trim ?? null, output: output ?? null } +} + +/** + * Persists a newly selected input file. Passing a file resets the stored trim + * and output (they belong to the previous file); passing null clears the + * whole session. + */ +export async function saveMediaFile( + toolKey: string, + file: File | null +): Promise { + if (!file) { + await clearMediaSession(toolKey) + return + } + await deleteKeys([trimKey(toolKey), outputKey(toolKey)]) + await writeKey(inputKey(toolKey), file) +} + +/** Persists the current trim range, or clears it when null. */ +export async function saveMediaTrim( + toolKey: string, + trim: TTrimRange | null +): Promise { + if (!trim) { + await deleteKeys([trimKey(toolKey)]) + return + } + await writeKey(trimKey(toolKey), trim) +} + +/** Persists the latest converted output, or clears it when null. */ +export async function saveMediaOutput( + toolKey: string, + output: TPersistedOutput | null +): Promise { + if (!output) { + await deleteKeys([outputKey(toolKey)]) + return + } + await writeKey(outputKey(toolKey), output) +} + +/** Removes every persisted piece of a tool's media session. */ +export async function clearMediaSession(toolKey: string): Promise { + await deleteKeys([inputKey(toolKey), trimKey(toolKey), outputKey(toolKey)]) +} diff --git a/src/features/miscellaneous/video-to-gif/hooks/use-video-to-gif-store.ts b/src/features/miscellaneous/video-to-gif/hooks/use-video-to-gif-store.ts index 29e6c4a6..78169015 100644 --- a/src/features/miscellaneous/video-to-gif/hooks/use-video-to-gif-store.ts +++ b/src/features/miscellaneous/video-to-gif/hooks/use-video-to-gif-store.ts @@ -3,6 +3,7 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { noop } from '@/shared/lib/noop' import { useLocalStorage } from '../../hooks/use-local-storage' +import { useMediaSession } from '../../hooks/use-media-session' import { useTrimState } from '../../hooks/use-trim-state' import type { TMediaOutput, @@ -70,6 +71,31 @@ export function useVideoToGifStore() { const inputWrittenFor = useRef(null) const urlsRef = useRef([]) + const pendingTrimRef = useRef(null) + const restoredRef = useRef(false) + + const { persistFile, persistTrim, persistOutput } = useMediaSession( + STORAGE_KEY, + session => { + if (restoredRef.current) return + restoredRef.current = true + pendingTrimRef.current = session.trim + setFile(session.file) + setFileUrl(URL.createObjectURL(session.file)) + if (session.output) { + setOutput({ + url: URL.createObjectURL(session.output.blob), + name: session.output.name, + size: session.output.blob.size, + mime: session.output.mime + }) + } + setStatus({ + message: 'Restored your previous session from this browser.', + mode: 'idle' + }) + } + ) useEffect(() => { urlsRef.current = [fileUrl, preview?.url, output?.url].filter( @@ -92,16 +118,19 @@ export function useVideoToGifStore() { }, []) const clearOutput = useCallback(() => { + persistOutput(null) setOutput(previous => { if (previous) URL.revokeObjectURL(previous.url) return null }) - }, []) + }, [persistOutput]) const selectFile = useCallback( (next: File | null) => { if (busy) return + restoredRef.current = true + pendingTrimRef.current = null clearPreview() clearOutput() inputWrittenFor.current = null @@ -113,6 +142,7 @@ export function useVideoToGifStore() { setProgress(0) if (!next) { + persistFile(null) setFile(null) setStatus(IDLE_STATUS) return @@ -120,6 +150,7 @@ export function useVideoToGifStore() { const sizeMb = next.size / (1024 * 1024) if (sizeMb > MAX_INPUT_MB) { + persistFile(null) setFile(null) setStatus({ message: `File is ${sizeMb.toFixed(1)} MB. Keep it under ${MAX_INPUT_MB} MB for browser conversion.`, @@ -128,6 +159,7 @@ export function useVideoToGifStore() { return } + persistFile(next) setFile(next) setFileUrl(URL.createObjectURL(next)) setStatus({ @@ -136,9 +168,28 @@ export function useVideoToGifStore() { mode: 'idle' }) }, - [busy, clearOutput, clearPreview, clearTrim] + [busy, clearOutput, clearPreview, clearTrim, persistFile] + ) + + const handleMetadata = useCallback( + (nextDuration: number) => { + trimState.handleMetadata( + nextDuration, + pendingTrimRef.current ?? undefined + ) + pendingTrimRef.current = null + }, + [trimState.handleMetadata] ) + useEffect(() => { + if (!file || duration <= 0) return + const timeout = setTimeout(() => { + persistTrim(hasTrim ? trim : null) + }, 300) + return () => clearTimeout(timeout) + }, [file, duration, hasTrim, trim, persistTrim]) + const updateTrim = useCallback( (next: TTrimRange) => { trimState.updateTrim(next) @@ -285,9 +336,11 @@ export function useVideoToGifStore() { ) const blob = await readOutputBlob(ffmpeg, OUTPUT_NAME, 'image/gif') + const outputName = `${stem(file.name)}_${options.fps}fps.gif` + persistOutput({ blob, name: outputName, mime: 'image/gif' }) setOutput({ url: URL.createObjectURL(blob), - name: `${stem(file.name)}_${options.fps}fps.gif`, + name: outputName, size: blob.size, mime: 'image/gif' }) @@ -304,7 +357,16 @@ export function useVideoToGifStore() { setFFmpegProgressHandler(noop) setBusy(false) } - }, [busy, clearOutput, ensureInputWritten, file, hasTrim, options, trim]) + }, [ + busy, + clearOutput, + ensureInputWritten, + file, + hasTrim, + options, + persistOutput, + trim + ]) const effectiveDuration = hasTrim ? trim.end - trim.start : duration const estimatedSize = @@ -327,7 +389,7 @@ export function useVideoToGifStore() { output, estimatedSize, selectFile, - handleMetadata: trimState.handleMetadata, + handleMetadata, updateTrim, commitTrim: trimState.commitTrim, undoTrim, diff --git a/src/features/packages/data.ts b/src/features/packages/data.ts index 67396dfe..fbf17552 100644 --- a/src/features/packages/data.ts +++ b/src/features/packages/data.ts @@ -31,7 +31,6 @@ export type DeveloperPackage = { code: string fileName: string }[] - faqs: { question: string; answer: string }[] } export const developerPackages: readonly DeveloperPackage[] = [ @@ -264,28 +263,6 @@ export const adapter = createAdapter({ } })` } - ], - faqs: [ - { - question: 'Does it include an auth backend?', - answer: 'No, deliberately. It is the sign-in surface plus a typed adapter over whatever already owns your sessions: Better Auth, Supabase, NextAuth, Clerk, Firebase, Passport, or your own JWT and REST endpoints. Your backend does not change; the drawer just stops you from rebuilding its UI in every app.' - }, - { - question: 'My auth provider is not in the adapter list. Am I stuck?', - answer: 'No. Implement the exported AuthAdapter contract (signIn, signUp, signOut, useSession) and the drawer treats it like any first-party adapter. TypeScript checks your implementation against the contract, and the UI reveals registration, OAuth, and reset flows based on which methods you actually provide.' - }, - { - question: 'Do I need Tailwind or a separate stylesheet?', - answer: 'Neither. Prebuilt styles ship with the component import, so there is no CSS file to remember and no Tailwind requirement in your app. Presentation, copy, provider buttons, and motion are all shaped through one deep-merged config object instead.' - }, - { - question: 'Does it work with the Next.js App Router?', - answer: 'Yes. Mount AuthProvider and AuthDrawer in a small client shell near the root and add a portal div to your layout so the drawer renders above page content with scroll lock. Server components everywhere else stay server components.' - }, - { - question: 'Can I build the UI before the backend exists?', - answer: 'Yes. Ship the bundled mock adapter, click through sign-in, registration, and reset with fake sessions, then swap in the real adapter later. Nothing in the UI layer changes.' - } ] }, { @@ -439,16 +416,6 @@ $.mod.key('k').on(() => openPalette())` } ] } - ], - faqs: [ - { - question: 'Does mod work on Windows and macOS?', - answer: 'Yes. The mod modifier maps to Cmd on macOS and Ctrl elsewhere.' - }, - { - question: 'Can shortcuts be scoped to an editor or modal?', - answer: 'Yes. Use scopes and guards so only active UI owns a shortcut.' - } ] }, { @@ -603,16 +570,6 @@ saveSettings().then(() => notice.success('Settings saved'))` } ] } - ], - faqs: [ - { - question: 'Can I use promise tracking?', - answer: 'Yes. notify.promise() manages loading, success, and error states around async work.' - }, - { - question: 'Can notifications be themed?', - answer: 'Yes. Set color mode, radius, icon treatment, position, duration, and swipe behavior on Notifier.' - } ] } ] diff --git a/src/features/ytmusic/components/ytmusic-diagnostics.tsx b/src/features/ytmusic/components/ytmusic-diagnostics.tsx new file mode 100644 index 00000000..9b396172 --- /dev/null +++ b/src/features/ytmusic/components/ytmusic-diagnostics.tsx @@ -0,0 +1,391 @@ +'use client' + +import Image from 'next/image' +import { useState, type ReactNode } from 'react' +import { + AlertTriangle, + Check, + Clock3, + Database, + ExternalLink, + Music2, + Radio, + RefreshCw, + ShieldCheck, + WifiOff +} from 'lucide-react' +import type { + YTMusicResult, + YTMusicStatus, + YTMusicTrack +} from '@/features/ytmusic/types' + +interface YTMusicDiagnosticsProps { + initialResult: YTMusicResult +} + +const IMAGE_HOSTS = new Set([ + 'lh3.googleusercontent.com', + 'yt3.googleusercontent.com', + 'i.ytimg.com' +]) + +const STATUS_PRESENTATION: Record< + YTMusicStatus, + { label: string; tone: string; dot: string } +> = { + ok: { + label: 'Signal locked', + tone: 'text-emerald-600 dark:text-emerald-400', + dot: 'bg-emerald-500' + }, + stale: { + label: 'Fallback signal', + tone: 'text-amber-600 dark:text-amber-400', + dot: 'bg-amber-500' + }, + empty: { + label: 'No history', + tone: 'text-muted-foreground', + dot: 'bg-muted-foreground' + }, + unconfigured: { + label: 'Not connected', + tone: 'text-muted-foreground', + dot: 'bg-muted-foreground' + }, + unauthorized: { + label: 'Session rejected', + tone: 'text-red-600 dark:text-red-400', + dot: 'bg-red-500' + }, + error: { + label: 'Signal lost', + tone: 'text-red-600 dark:text-red-400', + dot: 'bg-red-500' + } +} + +export function YTMusicDiagnostics({ initialResult }: YTMusicDiagnosticsProps) { + const [result, setResult] = useState(initialResult) + const [isRefreshing, setIsRefreshing] = useState(false) + const [latency, setLatency] = useState(null) + const status = STATUS_PRESENTATION[result.status] + + async function refresh() { + setIsRefreshing(true) + const startedAt = performance.now() + + try { + const response = await fetch( + '/api/ytmusic/recent?limit=20&refresh=1', + { + cache: 'no-store' + } + ) + const payload = (await response.json()) as YTMusicResult + setResult(payload) + setLatency(Math.round(performance.now() - startedAt)) + } catch { + setResult(current => ({ + ...current, + status: 'error', + message: + 'The diagnostic request could not reach the API endpoint.', + fetchedAt: new Date().toISOString() + })) + setLatency(null) + } finally { + setIsRefreshing(false) + } + } + + return ( +
+
+ +
+
+ + YT Music / signal check +
+

+ Is the listening wire alive? +

+

+ A direct read on authentication, persistent fallback and + the latest tracks. Refresh forces a real YouTube Music + request. +

+
+
+ +
+
+
+ +
+

+ {status.label} +

+

+ {result.message} +

+
+
+ +
+ +
+ } + /> + + ) : ( + + ) + } + /> + } + /> + } + /> +
+
+ +
+
+
+

+ Payload preview +

+

+ Recent history +

+
+ +
+ + {result.tracks.length > 0 ? ( +
    + {result.tracks.map((track, index) => ( + + ))} +
+ ) : ( + + )} +
+ +
+

+ Cache:{' '} + {result.cacheUpdatedAt + ? formatTimestamp(result.cacheUpdatedAt) + : 'No saved response'} +

+

+ Times marked approximate are inferred because YouTube does + not provide exact play times. +

+
+
+ ) +} + +function Metric({ + label, + value, + icon +}: { + label: string + value: string + icon: ReactNode +}) { + return ( +
+
+ {icon} + {label} +
+

+ {value} +

+
+ ) +} + +function TrackRow({ track, index }: { track: YTMusicTrack; index: number }) { + const image = getSafeImage(track.image) + return ( +
  • + + {String(index + 1).padStart(2, '0')} + +
    + {image ? ( + + ) : ( +
    + +
    + )} +
    +
    + + {track.name} + + +

    + {track.artist} + {track.album ? ` · ${track.album}` : ''} +

    +
    +
    +

    + {track.played_at_label || formatTimestamp(track.played_at)} +

    + {track.played_at_estimated ? ( +

    Approx.

    + ) : null} +
    +
  • + ) +} + +function EmptyState({ status }: { status: YTMusicStatus }) { + const needsAttention = + status === 'error' || + status === 'unauthorized' || + status === 'unconfigured' + return ( +
    + {needsAttention ? ( + + ) : ( + + )} +
    +

    + {needsAttention + ? 'Configuration needs attention' + : 'The response is valid but contains no tracks'} +

    +

    + Use the status message above for the next action. +

    +
    +
    + ) +} + +function SignalTrace({ active }: { active: boolean }) { + return ( + + + + + ) +} + +function getSafeImage(value: string): string | null { + try { + const url = new URL(value) + return url.protocol === 'https:' && IMAGE_HOSTS.has(url.hostname) + ? value + : null + } catch { + return null + } +} + +function formatSource(result: YTMusicResult): string { + if (result.source === 'youtube-music') return 'Live API' + if (result.source === 'database') + return result.isStale ? 'Stale cache' : 'Fresh cache' + return 'None' +} + +function formatTimestamp(value: string): string { + const date = new Date(value) + if (Number.isNaN(date.getTime())) return 'Unknown' + return new Intl.DateTimeFormat('en-GB', { + day: '2-digit', + month: 'short', + hour: '2-digit', + minute: '2-digit' + }).format(date) +} diff --git a/src/features/ytmusic/types.ts b/src/features/ytmusic/types.ts index ecf19f1e..c11b0ef2 100644 --- a/src/features/ytmusic/types.ts +++ b/src/features/ytmusic/types.ts @@ -6,4 +6,27 @@ export interface YTMusicTrack { url: string image: string played_at: string + played_at_estimated: boolean + played_at_label?: string +} + +export type YTMusicStatus = + | 'ok' + | 'stale' + | 'empty' + | 'unconfigured' + | 'unauthorized' + | 'error' + +export type YTMusicSource = 'youtube-music' | 'database' | 'none' + +export interface YTMusicResult { + status: YTMusicStatus + source: YTMusicSource + tracks: YTMusicTrack[] + message: string + credentialsConfigured: boolean + isStale: boolean + fetchedAt: string + cacheUpdatedAt: string | null } diff --git a/src/server/env.ts b/src/server/env.ts index 1e1706db..d8d0f961 100644 --- a/src/server/env.ts +++ b/src/server/env.ts @@ -18,6 +18,8 @@ export const env = createEnv({ ADMIN_EMAIL: z.string().email().optional(), ALLOWED_GITHUB_USERNAME: z.string().optional(), CRON_SECRET: z.string().min(1).optional(), + YTM_COOKIE: z.string().optional(), + YTM_AUTH_USER: z.string().optional(), IP_INFO_TOKEN: z.string().optional() }, @@ -37,6 +39,8 @@ export const env = createEnv({ ADMIN_EMAIL: process.env.ADMIN_EMAIL, ALLOWED_GITHUB_USERNAME: process.env.ALLOWED_GITHUB_USERNAME, CRON_SECRET: process.env.CRON_SECRET, + YTM_COOKIE: process.env.YTM_COOKIE, + YTM_AUTH_USER: process.env.YTM_AUTH_USER, IP_INFO_TOKEN: process.env.IP_INFO_TOKEN } diff --git a/src/server/ytmusic/auth.ts b/src/server/ytmusic/auth.ts index f5d48cdc..9b653199 100644 --- a/src/server/ytmusic/auth.ts +++ b/src/server/ytmusic/auth.ts @@ -1,69 +1,87 @@ import crypto from 'node:crypto' const YTM_ORIGIN = 'https://music.youtube.com' -const YTM_API_KEY = 'AIzaSyC9XL3ZjWddXya6X74dJoCTL-WEYFDNX30' +const FALLBACK_API_KEY = 'AIzaSyC9XL3ZjWddXya6X74dJoCTL-WEYFDNX30' +const FALLBACK_CLIENT_VERSION = '1.20260531.05.00' +const REQUEST_TIMEOUT_MS = 10_000 +const USER_AGENT = + 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36' + +interface PageConfig { + apiKey: string + clientVersion: string + pageId: string + userSessionId: string + visitorData: string +} +let cachedCookie = '' let cachedCookieMap: Map | null = null -let cachedAuthUser = '0' -let cachedUserSessionId: string | null = null -let cachedVisitorData: string | null = null -let cachedPageId: string | null = null +let cachedPageConfig: PageConfig | null = null export function hasYTMusicCredentials(): boolean { - const cookie = process.env.YTM_COOKIE - return !!( - cookie && - cookie !== 'your_ytm_cookie_here' && - !cookie.startsWith('#') + const cookie = process.env.YTM_COOKIE?.trim() + return Boolean( + cookie && cookie !== 'your_ytm_cookie_here' && !cookie.startsWith('#') ) } -function parseCookieMap(cookieStr: string): Map { +function parseCookieMap(cookie: string): Map { const map = new Map() - for (const part of cookieStr.split(';')) { - const eq = part.indexOf('=') - if (eq > 0) { - map.set(part.substring(0, eq).trim(), part.substring(eq + 1).trim()) - } + for (const part of cookie.split(';')) { + const separator = part.indexOf('=') + if (separator <= 0) continue + map.set( + part.slice(0, separator).trim(), + part.slice(separator + 1).trim() + ) } return map } function rebuildCookieHeader(cookieMap: Map): string { - const parts: string[] = [] - cookieMap.forEach((value, key) => parts.push(`${key}=${value}`)) - return parts.join('; ') + return Array.from(cookieMap, ([key, value]) => `${key}=${value}`).join('; ') +} + +function readConfigValue(html: string, key: string): string { + const match = html.match(new RegExp(`"${key}"\\s*:\\s*"([^"]+)"`)) + return match?.[1]?.replace(/\\u003d/gi, '=') ?? '' } async function fetchPageConfig( cookieMap: Map -): Promise<{ userSessionId: string; visitorData: string; pageId: string }> { - // SOCS=CAI prevents YouTube from showing the GDPR consent page - const cookieHeader = rebuildCookieHeader(cookieMap) + '; SOCS=CAI' - const response = await fetch('https://music.youtube.com', { +): Promise { + const cookieHeader = `${rebuildCookieHeader(cookieMap)}; SOCS=CAI` + const response = await fetch(YTM_ORIGIN, { + cache: 'no-store', headers: { accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', 'accept-language': 'en-US,en;q=0.9', cookie: cookieHeader, - 'user-agent': - 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36' - } + 'user-agent': USER_AGENT + }, + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) }) - const html = await response.text() - - const datasyncMatch = html.match(/"DATASYNC_ID"\s*:\s*"([^"]+)"/) - if (!datasyncMatch) - throw new Error('Could not find DATASYNC_ID in YT Music page') - const datasyncParts = datasyncMatch[1].split('||') - const userSessionId = datasyncParts[1] || datasyncParts[0] || '' - const pageId = datasyncParts[0] || '' + if (!response.ok) { + throw new Error(`YouTube Music bootstrap failed (${response.status})`) + } - const visitorMatch = html.match(/"VISITOR_DATA"\s*:\s*"([^"]+)"/) - const visitorData = visitorMatch - ? visitorMatch[1].replace(/\\u003d/gi, '=') - : '' + const html = await response.text() + const datasyncId = readConfigValue(html, 'DATASYNC_ID') + if (!datasyncId) { + throw new Error('YouTube Music bootstrap did not return a session ID') + } - return { userSessionId, visitorData, pageId } + const [pageId = '', userSessionId = pageId] = datasyncId.split('||') + return { + apiKey: readConfigValue(html, 'INNERTUBE_API_KEY') || FALLBACK_API_KEY, + clientVersion: + readConfigValue(html, 'INNERTUBE_CLIENT_VERSION') || + FALLBACK_CLIENT_VERSION, + pageId, + userSessionId, + visitorData: readConfigValue(html, 'VISITOR_DATA') + } } function sapisidHash( @@ -79,65 +97,52 @@ function sapisidHash( } async function generateAuthHeader( - cookieMap: Map -): Promise<{ authHeader: string; visitorData: string; pageId: string }> { - const ts = Math.floor(Date.now() / 1000).toString() - - if (!cachedUserSessionId) { - const config = await fetchPageConfig(cookieMap) - cachedUserSessionId = config.userSessionId - cachedVisitorData = config.visitorData - cachedPageId = config.pageId - } - + cookieMap: Map, + pageConfig: PageConfig +): Promise { + const timestamp = Math.floor(Date.now() / 1000).toString() const sapisid = cookieMap.get('__Secure-3PAPISID') || cookieMap.get('SAPISID') - if (!sapisid) throw new Error('Missing __Secure-3PAPISID or SAPISID cookie') - const sapisid1 = cookieMap.get('__Secure-1PAPISID') || cookieMap.get('APISID') - if (!sapisid1) throw new Error('Missing __Secure-1PAPISID or APISID cookie') - - const sapisid3 = cookieMap.get('__Secure-3PAPISID') || sapisid - const parts = [ - `SAPISIDHASH ${sapisidHash(ts, sapisid, cachedUserSessionId)}`, - `SAPISID1PHASH ${sapisidHash(ts, sapisid1, cachedUserSessionId)}`, - `SAPISID3PHASH ${sapisidHash(ts, sapisid3, cachedUserSessionId)}` - ] - - return { - authHeader: parts.join(' '), - visitorData: cachedVisitorData!, - pageId: cachedPageId! + if (!sapisid || !sapisid1) { + throw new Error( + 'The YouTube Music cookie is missing its SAPISID credentials' + ) } + + return [ + `SAPISIDHASH ${sapisidHash(timestamp, sapisid, pageConfig.userSessionId)}`, + `SAPISID1PHASH ${sapisidHash(timestamp, sapisid1, pageConfig.userSessionId)}`, + `SAPISID3PHASH ${sapisidHash(timestamp, sapisid, pageConfig.userSessionId)}` + ].join(' ') } export function invalidateYTMusicClient(): void { + cachedCookie = '' cachedCookieMap = null - cachedAuthUser = '0' - cachedUserSessionId = null - cachedVisitorData = null - cachedPageId = null + cachedPageConfig = null } export async function fetchInnertube( endpoint: string, - body: Record, - clientVersion?: string -) { - const cookieStr = process.env.YTM_COOKIE - if (!cookieStr) throw new Error('YTM_COOKIE not configured') - - if (!cachedCookieMap) { - cachedCookieMap = parseCookieMap(cookieStr) + body: Record +): Promise { + const cookie = process.env.YTM_COOKIE?.trim() + if (!cookie) throw new Error('YTM_COOKIE is not configured') + + if (!cachedCookieMap || cachedCookie !== cookie) { + invalidateYTMusicClient() + cachedCookie = cookie + cachedCookieMap = parseCookieMap(cookie) + } + if (!cachedPageConfig) { + cachedPageConfig = await fetchPageConfig(cachedCookieMap) } - cachedAuthUser = process.env.YTM_AUTH_USER || '0' - - const { authHeader, visitorData, pageId } = - await generateAuthHeader(cachedCookieMap) - const cookieHeader = rebuildCookieHeader(cachedCookieMap) + const pageConfig = cachedPageConfig + const authorization = await generateAuthHeader(cachedCookieMap, pageConfig) const payload = { ...body, context: { @@ -145,8 +150,10 @@ export async function fetchInnertube( hl: 'en', gl: 'US', clientName: 'WEB_REMIX', - clientVersion: clientVersion || '1.20260531.05.00', - ...(visitorData && { visitorData }) + clientVersion: pageConfig.clientVersion, + ...(pageConfig.visitorData && { + visitorData: pageConfig.visitorData + }) }, user: { lockedSafetyMode: false }, request: { @@ -158,33 +165,34 @@ export async function fetchInnertube( } const response = await fetch( - `https://music.youtube.com/youtubei/v1/${endpoint}?alt=json&key=${YTM_API_KEY}`, + `${YTM_ORIGIN}/youtubei/v1/${endpoint}?alt=json&key=${pageConfig.apiKey}`, { method: 'POST', headers: { accept: '*/*', 'accept-language': 'en-US,en;q=0.9', 'content-type': 'application/json', - Authorization: authHeader, - Cookie: cookieHeader, + Authorization: authorization, + Cookie: rebuildCookieHeader(cachedCookieMap), origin: YTM_ORIGIN, - referer: 'https://music.youtube.com/', - 'user-agent': - 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36', - 'X-Goog-AuthUser': cachedAuthUser, - 'X-Goog-PageId': pageId, + referer: `${YTM_ORIGIN}/`, + 'user-agent': USER_AGENT, + 'X-Goog-AuthUser': process.env.YTM_AUTH_USER || '0', + 'X-Goog-PageId': pageConfig.pageId, 'X-Origin': YTM_ORIGIN, 'X-Youtube-Bootstrap-Logged-In': 'true', 'X-Youtube-Client-Name': '67', - ...(visitorData && { 'X-Goog-Visitor-Id': visitorData }) + ...(pageConfig.visitorData && { + 'X-Goog-Visitor-Id': pageConfig.visitorData + }) }, - body: JSON.stringify(payload) + body: JSON.stringify(payload), + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) } ) if (!response.ok) { - throw new Error(`Innertube API error: ${response.status}`) + throw new Error(`YouTube Music API failed (${response.status})`) } - return response.json() } diff --git a/src/server/ytmusic/cache.ts b/src/server/ytmusic/cache.ts new file mode 100644 index 00000000..ccfd6658 --- /dev/null +++ b/src/server/ytmusic/cache.ts @@ -0,0 +1,50 @@ +import { eq } from 'drizzle-orm' +import type { YTMusicTrack } from '@/features/ytmusic/types' +import { db } from '@/server/db/connection' +import { ytmusicCache } from '@/server/db/ytmusic-schema' + +const DB_CACHE_KEY = 'recent' + +export interface YTMusicCacheEntry { + tracks: YTMusicTrack[] + updatedAt: Date +} + +export async function readYTMusicCache(): Promise { + try { + const row = await db.query.ytmusicCache.findFirst({ + where: eq(ytmusicCache.key, DB_CACHE_KEY) + }) + if (!row) return null + + return { + tracks: row.tracks.map(track => ({ + ...track, + played_at_estimated: track.played_at_estimated ?? true + })), + updatedAt: row.updatedAt + } + } catch (error) { + console.error('[YTM Cache] Read failed:', error) + return null + } +} + +export async function writeYTMusicCache( + tracks: YTMusicTrack[] +): Promise { + const updatedAt = new Date() + try { + await db + .insert(ytmusicCache) + .values({ key: DB_CACHE_KEY, tracks, updatedAt }) + .onConflictDoUpdate({ + target: ytmusicCache.key, + set: { tracks, updatedAt } + }) + return updatedAt + } catch (error) { + console.error('[YTM Cache] Write failed:', error) + return null + } +} diff --git a/src/server/ytmusic/index.ts b/src/server/ytmusic/index.ts index d3e5756b..8792f45f 100644 --- a/src/server/ytmusic/index.ts +++ b/src/server/ytmusic/index.ts @@ -1,3 +1,8 @@ export * from './auth' export * from './tracks' -export type { YTMusicTrack } from '@/features/ytmusic/types' +export type { + YTMusicResult, + YTMusicSource, + YTMusicStatus, + YTMusicTrack +} from '@/features/ytmusic/types' diff --git a/src/server/ytmusic/parser.ts b/src/server/ytmusic/parser.ts new file mode 100644 index 00000000..44b46e8d --- /dev/null +++ b/src/server/ytmusic/parser.ts @@ -0,0 +1,170 @@ +import type { YTMusicTrack } from '@/features/ytmusic/types' + +const YTM_BASE = 'https://music.youtube.com' +const APPROXIMATE_TRACK_SPACING_MS = 3 * 60 * 1000 + +type Renderer = Record + +export class YTMusicUnauthorizedError extends Error { + constructor(message = 'YouTube Music rejected the configured session') { + super(message) + this.name = 'YTMusicUnauthorizedError' + } +} + +export function parseInnertubeTracks( + data: unknown, + limit: number, + now = new Date() +): YTMusicTrack[] { + const sections = getSections(data) + assertAuthenticatedResponse(sections) + + const tracks: YTMusicTrack[] = [] + for (const section of sections) { + const shelf = + section?.musicShelfRenderer ?? section?.musicCarouselShelfRenderer + if (!shelf?.contents) continue + + const playedLabel = getText(shelf.title) + for (const rawTrack of shelf.contents) { + if (tracks.length >= limit) return tracks + + const item = rawTrack?.musicResponsiveListItemRenderer + if (!item) continue + + const id = getVideoId(item) + const name = getFlexColumnText(item, 0) + if (!id || !name) continue + + tracks.push({ + id, + name, + artist: extractArtists(item) || 'Unknown', + album: getFlexColumnText(item, 2), + url: `${YTM_BASE}/watch?v=${id}`, + image: extractThumbnail(item), + played_at: estimatePlayedAt(playedLabel, tracks.length, now), + played_at_estimated: true, + ...(playedLabel && { played_at_label: playedLabel }) + }) + } + } + + return tracks +} + +export function stabilizeTrackTimestamps( + freshTracks: YTMusicTrack[], + cachedTracks: YTMusicTrack[] +): YTMusicTrack[] { + const cachedByOccurrence = new Map() + const cachedCounts = new Map() + + for (const track of cachedTracks) { + const occurrence = cachedCounts.get(track.id) ?? 0 + cachedCounts.set(track.id, occurrence + 1) + cachedByOccurrence.set(`${track.id}:${occurrence}`, track) + } + + const freshCounts = new Map() + return freshTracks.map(track => { + const occurrence = freshCounts.get(track.id) ?? 0 + freshCounts.set(track.id, occurrence + 1) + const cached = cachedByOccurrence.get(`${track.id}:${occurrence}`) + + if (!cached || !isIsoTimestamp(cached.played_at)) return track + return { + ...track, + played_at: cached.played_at, + played_at_estimated: true + } + }) +} + +function getSections(data: unknown): Renderer[] { + const contents = (data as Renderer)?.contents + ?.singleColumnBrowseResultsRenderer?.tabs?.[0]?.tabRenderer?.content + ?.sectionListRenderer?.contents + return Array.isArray(contents) ? contents : [] +} + +function assertAuthenticatedResponse(sections: Renderer[]) { + for (const section of sections) { + const messages = section?.itemSectionRenderer?.contents + if (!Array.isArray(messages)) continue + + for (const item of messages) { + const message = getText(item?.messageRenderer?.text) + if (/sign in/i.test(message)) { + throw new YTMusicUnauthorizedError(message) + } + } + } +} + +function getVideoId(item: Renderer): string { + return ( + item?.overlay?.musicItemThumbnailOverlayRenderer?.content + ?.musicPlayButtonRenderer?.playNavigationEndpoint?.watchEndpoint + ?.videoId ?? '' + ) +} + +function getFlexColumnText(item: Renderer, index: number): string { + return getText( + item?.flexColumns?.[index]?.musicResponsiveListItemFlexColumnRenderer + ?.text + ) +} + +function getText(textRenderer: Renderer | undefined): string { + if (!Array.isArray(textRenderer?.runs)) return '' + return textRenderer.runs + .map((run: Renderer) => run?.text?.trim()) + .filter(Boolean) + .join(' ') +} + +function extractArtists(item: Renderer): string { + const runs = + item?.flexColumns?.[1]?.musicResponsiveListItemFlexColumnRenderer?.text + ?.runs + if (!Array.isArray(runs)) return '' + + return runs + .map((run: Renderer) => run?.text?.trim()) + .filter((text: string | undefined) => { + if (!text || text === '•' || text === '/') return false + if (text.includes('/') || text.startsWith('Album')) return false + return !/\d+(\.\d+)?[KMB]?\s*views?/i.test(text) + }) + .join(', ') +} + +function extractThumbnail(item: Renderer): string { + const thumbnails = + item?.thumbnail?.musicThumbnailRenderer?.thumbnail?.thumbnails + if (!Array.isArray(thumbnails) || thumbnails.length === 0) return '' + return thumbnails.at(-1)?.url ?? '' +} + +function estimatePlayedAt(label: string, index: number, now: Date): string { + const lowerLabel = label.toLowerCase() + const estimate = new Date(now) + + if (lowerLabel.includes('yesterday')) { + estimate.setUTCDate(estimate.getUTCDate() - 1) + estimate.setUTCHours(12, 0, 0, 0) + } else { + estimate.setTime( + estimate.getTime() - index * APPROXIMATE_TRACK_SPACING_MS + ) + } + + return estimate.toISOString() +} + +function isIsoTimestamp(value: string): boolean { + return !Number.isNaN(Date.parse(value)) +} diff --git a/src/server/ytmusic/python-bridge.ts b/src/server/ytmusic/python-bridge.ts deleted file mode 100644 index ca478adc..00000000 --- a/src/server/ytmusic/python-bridge.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { execSync } from 'node:child_process' -import path from 'node:path' -import type { YTMusicTrack } from '@/features/ytmusic/types' - -const PYTHON_VENV = path.resolve( - process.cwd(), - 'scripts/ytmusic/.venv/bin/python3' -) -const HISTORY_SCRIPT = path.resolve( - process.cwd(), - 'scripts/ytmusic/get_history.py' -) -const OAUTH_FILE = path.resolve(process.cwd(), 'scripts/ytmusic/oauth.json') - -let oauthAvailable: boolean | null = null - -export function hasOAuthCredentials(): boolean { - if (oauthAvailable !== null) return oauthAvailable - let available = false - try { - const { existsSync } = require('node:fs') as typeof import('node:fs') - available = existsSync(OAUTH_FILE) - } catch { - available = false - } - oauthAvailable = available - return available -} - -interface YtmusicapiTrack { - title?: string - artists?: { name: string }[] - album?: { name: string; thumbnails?: { url: string }[] } - videoId?: string - played?: string -} - -function parsePythonHistory(raw: YtmusicapiTrack[]): YTMusicTrack[] { - return raw.map(item => ({ - id: item.videoId || '', - name: item.title || 'Unknown', - artist: item.artists?.map(a => a.name).join(', ') || 'Unknown', - album: item.album?.name || '', - url: item.videoId - ? `https://music.youtube.com/watch?v=${item.videoId}` - : '', - image: item.album?.thumbnails?.slice(-1)[0]?.url || '', - played_at: item.played || new Date().toISOString() - })) -} - -export function getPythonYTMusicTracks(limit: number): YTMusicTrack[] { - if (!hasOAuthCredentials()) return [] - - try { - const result = execSync(`${PYTHON_VENV} ${HISTORY_SCRIPT} ${limit}`, { - encoding: 'utf-8', - timeout: 15000 - }) - const data = JSON.parse(result.trim()) - return parsePythonHistory(data) - } catch (error) { - console.error('[YTM Python] Error:', error) - return [] - } -} - -export async function getLocalServerTracks( - limit: number -): Promise { - try { - const res = await fetch(`http://127.0.0.1:8370/recent?limit=${limit}`, { - signal: AbortSignal.timeout(5000) - }) - if (!res.ok) return [] - const data = await res.json() - if (!Array.isArray(data) || data.length === 0) return [] - return data.map((t: any) => ({ - id: t.id || '', - name: t.name || 'Unknown', - artist: - (t.artist || '').replace(/,?\s*•\s*/g, '').trim() || 'Unknown', - album: t.album || '', - url: `https://music.youtube.com/watch?v=${t.id}`, - image: t.image || '', - played_at: t.played_at || new Date().toISOString() - })) - } catch { - return [] - } -} diff --git a/src/server/ytmusic/tracks.ts b/src/server/ytmusic/tracks.ts index 268e3396..00661302 100644 --- a/src/server/ytmusic/tracks.ts +++ b/src/server/ytmusic/tracks.ts @@ -1,281 +1,154 @@ -import { unstable_cache } from 'next/cache' +import type { YTMusicResult, YTMusicTrack } from '@/features/ytmusic/types' import { fetchInnertube, hasYTMusicCredentials } from './auth' +import { readYTMusicCache, writeYTMusicCache } from './cache' import { - hasOAuthCredentials, - getPythonYTMusicTracks, - getLocalServerTracks -} from './python-bridge' -import type { YTMusicTrack } from '@/features/ytmusic/types' -import { db } from '@/server/db/connection' -import { ytmusicCache } from '@/server/db/ytmusic-schema' -import { eq } from 'drizzle-orm' + parseInnertubeTracks, + stabilizeTrackTimestamps, + YTMusicUnauthorizedError +} from './parser' -const YTM_BASE = 'https://music.youtube.com' -const MAX_DETECTED_TRACKS = 500 -const DB_CACHE_KEY = 'recent' +const FRESH_CACHE_MS = 30_000 -let lastSeenIds: Set = new Set() -let firstSeenAt: Map = new Map() +interface GetYTMusicOptions { + forceRefresh?: boolean +} -async function readDbCache(): Promise { - try { - const row = await db.query.ytmusicCache.findFirst({ - where: eq(ytmusicCache.key, DB_CACHE_KEY) +export async function getYTMusicResult( + limit: number, + { forceRefresh = false }: GetYTMusicOptions = {} +): Promise { + const fetchedAt = new Date().toISOString() + const credentialsConfigured = hasYTMusicCredentials() + const cache = await readYTMusicCache() + const cachedTracks = cache?.tracks.slice(0, limit) ?? [] + const cacheUpdatedAt = cache?.updatedAt.toISOString() ?? null + const cacheIsFresh = cache + ? Date.now() - cache.updatedAt.getTime() < FRESH_CACHE_MS + : false + + if (!forceRefresh && cacheIsFresh && cachedTracks.length > 0) { + return createResult({ + status: 'ok', + source: 'database', + tracks: cachedTracks, + message: 'Serving the latest saved YouTube Music response.', + credentialsConfigured, + isStale: false, + fetchedAt, + cacheUpdatedAt }) - return row?.tracks ?? [] - } catch { - return [] } -} - -async function writeDbCache(tracks: YTMusicTrack[]): Promise { - try { - await db - .insert(ytmusicCache) - .values({ key: DB_CACHE_KEY, tracks, updatedAt: new Date() }) - .onConflictDoUpdate({ - target: ytmusicCache.key, - set: { tracks, updatedAt: new Date() } - }) - } catch {} -} - -function formatPythonTracks(tracks: YTMusicTrack[]): YTMusicTrack[] { - const now = Date.now() - return tracks.map((track, i) => ({ - ...track, - played_at: track.played_at || new Date(now - i * 180_000).toISOString() - })) -} - -export const getYTMusicTracks = unstable_cache( - async (limit: number): Promise => { - let result: YTMusicTrack[] = [] - - try { - if (hasYTMusicCredentials()) { - const data = await fetchInnertube('browse', { - browseId: 'FEmusic_history' - }) - const contents = traverse( - data, - 'contents', - 'singleColumnBrowseResultsRenderer', - 'tabs', - '0', - 'tabRenderer', - 'content', - 'sectionListRenderer', - 'contents' - ) as any[] | undefined - if (Array.isArray(contents) && contents.length > 0) { - result = parseInnertubeTracks(contents, limit) - } - } - - if (result.length === 0) { - const localTracks = await getLocalServerTracks(limit) - if (localTracks.length > 0) result = localTracks - } - - if (result.length === 0 && hasOAuthCredentials()) { - const tracks = getPythonYTMusicTracks(limit) - if (tracks.length > 0) result = formatPythonTracks(tracks) - } - - if (result.length > 0) { - await writeDbCache(result) - return result - } - } catch (error) { - console.error('[YTM Tracks] Error:', error) - const localTracks = await getLocalServerTracks(limit) - if (localTracks.length > 0) { - result = localTracks - await writeDbCache(result) - return result - } - if (hasOAuthCredentials()) { - const tracks = getPythonYTMusicTracks(limit) - if (tracks.length > 0) { - result = formatPythonTracks(tracks) - await writeDbCache(result) - return result - } - } + if (!credentialsConfigured) { + if (cachedTracks.length > 0) { + return createStaleResult( + cachedTracks, + fetchedAt, + cacheUpdatedAt, + 'Live access is not configured. Showing the last saved response.' + ) } - const cached = await readDbCache() - if (cached.length > 0) return cached.slice(0, limit) - return [] - }, - ['ytmusic-recent-v2'], - { revalidate: 30, tags: ['ytmusic'] } -) - -function parseInnertubeTracks(contents: any[], limit: number): YTMusicTrack[] { - const rawTracks: any[] = [] - for (const section of contents) { - const shelf = - section?.musicShelfRenderer ?? section?.musicCarouselShelfRenderer - if (shelf?.contents) { - rawTracks.push(...shelf.contents) - } + return createResult({ + status: 'unconfigured', + source: 'none', + tracks: [], + message: 'Set YTM_COOKIE to connect YouTube Music.', + credentialsConfigured: false, + isStale: false, + fetchedAt, + cacheUpdatedAt: null + }) } - if (!rawTracks.length) return [] - - const freshIds = new Set() - const result: YTMusicTrack[] = [] - const now = Date.now() - - for (let i = 0; i < rawTracks.length; i++) { - if (result.length >= limit) break - - const item = rawTracks[i]?.musicResponsiveListItemRenderer - if (!item) continue - - const id = traverse( - item, - 'overlay', - 'musicItemThumbnailOverlayRenderer', - 'content', - 'musicPlayButtonRenderer', - 'playNavigationEndpoint', - 'watchEndpoint', - 'videoId' - ) as string | undefined - const title = traverse( - item, - 'flexColumns', - '0', - 'musicResponsiveListItemFlexColumnRenderer', - 'text', - 'runs', - '0', - 'text' - ) as string | undefined - - if (!id || !title) continue - - freshIds.add(id) - - if (!firstSeenAt.has(id)) { - firstSeenAt.set(id, new Date(now - i * 180_000).toISOString()) - } + try { + const data = await fetchInnertube('browse', { + browseId: 'FEmusic_history' + }) + const parsedTracks = parseInnertubeTracks(data, limit) + const tracks = stabilizeTrackTimestamps( + parsedTracks, + cache?.tracks ?? [] + ) - if (firstSeenAt.size > MAX_DETECTED_TRACKS) { - const keys = firstSeenAt.keys() - for (let j = 0; j < 100; j++) { - const key = keys.next() - if (key.done) break - if (!freshIds.has(key.value)) { - firstSeenAt.delete(key.value) - } - } + if (tracks.length === 0) { + return createResult({ + status: 'empty', + source: 'youtube-music', + tracks: [], + message: 'YouTube Music returned an empty listening history.', + credentialsConfigured: true, + isStale: false, + fetchedAt, + cacheUpdatedAt + }) } - const artists = extractArtists(item) - const album = extractAlbum(item) - const thumbnail = extractThumbnail(item) - - result.push({ - id, - name: title, - artist: artists || 'Unknown', - album: album || '', - url: `${YTM_BASE}/watch?v=${id}`, - image: thumbnail || '', - played_at: firstSeenAt.get(id)! + const updatedAt = await writeYTMusicCache(tracks) + return createResult({ + status: 'ok', + source: 'youtube-music', + tracks, + message: 'Live listening history received from YouTube Music.', + credentialsConfigured: true, + isStale: false, + fetchedAt, + cacheUpdatedAt: updatedAt?.toISOString() ?? cacheUpdatedAt }) - } - - lastSeenIds = freshIds - return result -} - -function traverse(obj: unknown, ...keys: string[]): unknown { - if (!obj || typeof obj !== 'object') return undefined - let current: any = obj - for (const key of keys) { - if (key === '*') { - if (Array.isArray(current)) { - for (const item of current) { - const result = traverse( - item, - ...keys.slice(keys.indexOf('*') + 1) - ) - if (result !== undefined) return result - } - return undefined - } - return undefined + } catch (error) { + const unauthorized = error instanceof YTMusicUnauthorizedError + const message = unauthorized + ? 'YouTube Music rejected the session. Replace YTM_COOKIE with a fresh browser cookie.' + : 'YouTube Music could not be reached.' + + if (!unauthorized) { + console.error('[YTM Tracks]', message, error) } - if (current == null || typeof current !== 'object') return undefined - if (Array.isArray(current)) { - const idx = parseInt(key, 10) - if (isNaN(idx)) return undefined - current = current[idx] - } else { - current = (current as Record)[key] + if (cachedTracks.length > 0) { + return createStaleResult( + cachedTracks, + fetchedAt, + cacheUpdatedAt, + `${message} Showing the last saved response.` + ) } - } - return current -} -function extractArtists(item: any): string { - const runs = traverse( - item, - 'flexColumns', - '1', - 'musicResponsiveListItemFlexColumnRenderer', - 'text', - 'runs' - ) as any[] | undefined - if (!Array.isArray(runs)) return '' - - const names: string[] = [] - for (const run of runs) { - const text = run?.text?.trim() - if ( - !text || - text === '•' || - text === '/' || - text.includes('/') || - text.startsWith('Album') || - /\d+(\.\d+)?[KMB]?\s*views?/i.test(text) - ) - continue - names.push(text) + return createResult({ + status: unauthorized ? 'unauthorized' : 'error', + source: 'none', + tracks: [], + message, + credentialsConfigured: true, + isStale: false, + fetchedAt, + cacheUpdatedAt + }) } +} - return names.join(', ') +export async function getYTMusicTracks(limit: number): Promise { + const result = await getYTMusicResult(limit) + return result.tracks } -function extractAlbum(item: any): string { - const album = traverse( - item, - 'flexColumns', - '2', - 'musicResponsiveListItemFlexColumnRenderer', - 'text', - 'runs', - '0', - 'text' - ) as string | undefined - return album || '' +function createStaleResult( + tracks: YTMusicTrack[], + fetchedAt: string, + cacheUpdatedAt: string | null, + message: string +): YTMusicResult { + return createResult({ + status: 'stale', + source: 'database', + tracks, + message, + credentialsConfigured: hasYTMusicCredentials(), + isStale: true, + fetchedAt, + cacheUpdatedAt + }) } -function extractThumbnail(item: any): string { - const thumbnails = traverse( - item, - 'thumbnail', - 'musicThumbnailRenderer', - 'thumbnail', - 'thumbnails' - ) as any[] | undefined - if (!Array.isArray(thumbnails) || !thumbnails.length) return '' - const last = thumbnails[thumbnails.length - 1] - return last?.url || '' +function createResult(result: YTMusicResult): YTMusicResult { + return result }