API Server

NestJS API Server

REST API with NestJS controllers, modules, and dependency injection

NestJS is a Node.js framework with decorators, dependency injection, and modular architecture. This example shows how to integrate lyra-sdk using NestJS patterns.

Setup

nest new lyra-nest-api
cd lyra-nest-api
npm install lyra-sdk zod dotenv

Add to .env:

YOUTUBE_API_KEY=your_key_here

Transcript endpoints do not require YOUTUBE_API_KEY.

File structure

main.ts
app.module.ts
lyra.module.ts
transcript.util.ts
all-exceptions.filter.ts
video.controller.ts
channel.controller.ts
playlist.controller.ts
transcript.controller.ts
url.controller.ts

Lyra module

Wraps the yt() client in a NestJS provider for dependency injection:

src/common/lyra.module.ts
import { Module } from '@nestjs/common'
import { ConfigModule } from '@nestjs/config'
import { yt, YTClient } from 'lyra-sdk'

@Module({
  imports: [ConfigModule.forRoot({ isGlobal: true })],
  providers: [
    {
      provide: 'YT_CLIENT',
      useFactory: () => {
        const key = process.env.YOUTUBE_API_KEY
        if (!key) throw new Error('YOUTUBE_API_KEY is required')
        return yt(key)
      },
    },
  ],
  exports: ['YT_CLIENT'],
})
export class LyraModule {}

Transcript utility

Transcript functions are standalone — no yt() client needed:

src/common/transcript.util.ts
export {
  transcribeVideo,
  listCaptionTracks,
  toSRT,
  toVTT,
  toPlainText,
} from 'lyra-sdk/transcript'

Exception filter

Maps SDK errors to HTTP responses:

src/common/all-exceptions.filter.ts
import {
  Catch,
  ExceptionFilter,
  ArgumentsHost,
  HttpException,
  HttpStatus,
} from '@nestjs/common'
import { Response } from 'express'
import { AuthError, NotFoundError, QuotaError, YTError } from 'lyra-sdk'
import { TranscriptError } from 'lyra-sdk/transcript'
import { ZodError } from 'zod'

@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
  catch(err: Error, host: ArgumentsHost) {
    const ctx = host.switchToHttp()
    const res = ctx.getResponse<Response>()

    if (err instanceof HttpException) return err

    const map: Record<string, number> = {}
    if (err instanceof ZodError) {
      res.status(HttpStatus.BAD_REQUEST).json({
        error: { code: 400, message: 'Validation error', details: err.issues },
      })
      return
    }
    if (err instanceof NotFoundError) {
      res.status(HttpStatus.NOT_FOUND).json({ error: { code: 404, message: err.message } })
      return
    }
    if (err instanceof AuthError) {
      res.status(HttpStatus.UNAUTHORIZED).json({ error: { code: 401, message: err.message } })
      return
    }
    if (err instanceof QuotaError) {
      res.status(HttpStatus.TOO_MANY_REQUESTS).json({ error: { code: 429, message: err.message } })
      return
    }
    if (err instanceof TranscriptError) {
      res.status(404).json({ error: { code: 404, message: err.message } })
      return
    }
    if (err instanceof YTError) {
      res.status(HttpStatus.BAD_GATEWAY).json({ error: { code: 502, message: err.message } })
      return
    }

    res.status(500).json({ error: { code: 500, message: 'Internal server error' } })
  }
}

Video controller

src/video/video.controller.ts
import { Controller, Get, Param, Query, Inject } from '@nestjs/common'
import { YTClient } from 'lyra-sdk'

@Controller('api/video')
export class VideoController {
  constructor(@Inject('YT_CLIENT') private readonly client: YTClient) {}

  @Get(':id')
  async getVideo(@Param('id') id: string) {
    return this.client.video(id)
  }

  @Get(':id/title')
  async getTitle(@Param('id') id: string) {
    return this.client.videoTitle(id)
  }
}

@Controller('api/videos')
export class VideosController {
  constructor(@Inject('YT_CLIENT') private readonly client: YTClient) {}

  @Get()
  async getVideos(@Query('ids') ids: string) {
    const idList = ids.split(',').map((s) => s.trim())
    return this.client.videos(idList)
  }
}

Channel controller

src/channel/channel.controller.ts
import { Controller, Get, Param, Query, Inject } from '@nestjs/common'
import { YTClient } from 'lyra-sdk'

@Controller('api/channel')
export class ChannelController {
  constructor(@Inject('YT_CLIENT') private readonly client: YTClient) {}

  @Get(':id')
  async getChannel(@Param('id') id: string) {
    return this.client.channel(id)
  }

  @Get(':id/videos')
  async getVideos(@Param('id') id: string, @Query('limit') limit?: string) {
    return this.client.channelVideos(id, { limit: limit ? Number(limit) : undefined })
  }
}

Playlist controller

src/playlist/playlist.controller.ts
import { Controller, Get, Post, Param, Body, Inject } from '@nestjs/common'
import { YTClient } from 'lyra-sdk'

@Controller('api/playlist')
export class PlaylistController {
  constructor(@Inject('YT_CLIENT') private readonly client: YTClient) {}

  @Get(':id')
  async getPlaylist(@Param('id') id: string) {
    return this.client.playlist(id)
  }

  @Get(':id/info')
  async getInfo(@Param('id') id: string) {
    return this.client.playlistInfo(id)
  }

  @Get(':id/ids')
  async getIds(@Param('id') id: string) {
    const ids = await this.client.playlistVideoIds(id)
    return { id, videoIds: ids, count: ids.length }
  }

  @Post(':id/query')
  async query(@Param('id') id: string, @Body() body: any) {
    let query = this.client.playlistQuery(id)

    if (body.filter?.duration) query = query.filterByDuration(body.filter.duration)
    if (body.filter?.views) query = query.filterByViews(body.filter.views)
    if (body.filter?.likes) query = query.filterByLikes(body.filter.likes)
    if (body.sort) query = query.sortBy(body.sort.field, body.sort.order)
    if (body.range) query = query.between(body.range.start, body.range.end)

    return query.execute()
  }
}

Transcript controller

src/transcript/transcript.controller.ts
import { Controller, Get, Param, Query, Res } from '@nestjs/common'
import { Response } from 'express'
import { transcribeVideo, listCaptionTracks, toSRT, toVTT, toPlainText } from 'lyra-sdk/transcript'

@Controller('api/transcript')
export class TranscriptController {
  @Get(':id')
  async getTranscript(@Param('id') id: string, @Query('lang') lang?: string) {
    return transcribeVideo(id, { lang })
  }

  @Get(':id/languages')
  async getLanguages(@Param('id') id: string) {
    return listCaptionTracks(id)
  }

  @Get(':id/srt')
  async getSrt(@Param('id') id: string, @Query('lang') lang?: string, @Res() res?: Response) {
    const lines = await transcribeVideo(id, { lang })
    res!.type('text/plain').send(toSRT(lines))
  }

  @Get(':id/vtt')
  async getVtt(@Param('id') id: string, @Query('lang') lang?: string, @Res() res?: Response) {
    const lines = await transcribeVideo(id, { lang })
    res!.type('text/plain').send(toVTT(lines))
  }

  @Get(':id/text')
  async getText(@Param('id') id: string, @Query('lang') lang?: string, @Res() res?: Response) {
    const lines = await transcribeVideo(id, { lang })
    res!.type('text/plain').send(toPlainText(lines))
  }
}

The transcript controller does not inject YT_CLIENT. It uses standalone functions from lyra-sdk/transcript directly.


URL controller

src/url/url.controller.ts
import { Controller, Post, Body, Inject } from '@nestjs/common'
import { YTClient } from 'lyra-sdk'

@Controller('api/url')
export class UrlController {
  constructor(@Inject('YT_CLIENT') private readonly client: YTClient) {}

  @Post('parse')
  parse(@Body() body: { url: string }) {
    return this.client.url.parse(body.url)
  }

  @Post('extract')
  extract(@Body() body: { url: string; type?: string }) {
    const { url, type } = body

    if (type === 'video' || (!type && this.client.url.isVideo(url))) {
      return { type: 'video', videoId: this.client.url.extractVideoId(url) }
    }
    if (type === 'playlist' || (!type && this.client.url.isPlaylist(url))) {
      return { type: 'playlist', playlistId: this.client.url.extractPlaylistId(url) }
    }
    const channelId = this.client.url.extractChannelId(url)
    if (channelId) return { type: 'channel', channelId }

    return { type: 'unknown', id: null }
  }
}

App module

src/app.module.ts
import { Module } from '@nestjs/common'
import { LyraModule } from './common/lyra.module.js'
import { VideoController, VideosController } from './video/video.controller.js'
import { ChannelController } from './channel/channel.controller.js'
import { PlaylistController } from './playlist/playlist.controller.js'
import { TranscriptController } from './transcript/transcript.controller.js'
import { UrlController } from './url/url.controller.js'

@Module({
  imports: [LyraModule],
  controllers: [
    VideoController,
    VideosController,
    ChannelController,
    PlaylistController,
    TranscriptController,
    UrlController,
  ],
})
export class AppModule {}

Main bootstrap

src/main.ts
import { NestFactory } from '@nestjs/core'
import { AppModule } from './app.module.js'
import { AllExceptionsFilter } from './common/all-exceptions.filter.js'

async function bootstrap() {
  const app = await NestFactory.create(AppModule)
  app.useGlobalFilters(new AllExceptionsFilter())
  await app.listen(3000)
  console.log('NestJS API running at http://localhost:3000')
}
bootstrap()

Endpoints

Video & Channel (API key required)

MethodPathDescription
GET/api/video/:idFetch full video details
GET/api/videos?ids=...Batch fetch videos
GET/api/video/:id/titleTitle-only lookup
GET/api/channel/:idChannel metadata
GET/api/channel/:id/videosRecent uploads

Playlist (API key required)

MethodPathDescription
GET/api/playlist/:idFull playlist with videos
GET/api/playlist/:id/infoMetadata only
GET/api/playlist/:id/idsVideo IDs only
POST/api/playlist/:id/queryFilter/sort/range query

Transcript (no API key)

MethodPathDescription
GET/api/transcript/:idFetch transcript (?lang=en)
GET/api/transcript/:id/languagesAvailable caption tracks
GET/api/transcript/:id/srtSRT format
GET/api/transcript/:id/vttWebVTT format
GET/api/transcript/:id/textPlain text

Comments (API key required)

MethodPathDescription
GET/api/comments/:videoIdComment threads for a video
GET/api/comments/:videoId/topTop comments by relevance
GET/api/comment-replies/:idAll replies for a comment
src/comment/comment.controller.ts
import { Controller, Get, Param, Query, Inject } from '@nestjs/common'
import { YTClient } from 'lyra-sdk'

@Controller('api')
export class CommentController {
  constructor(@Inject('YT_CLIENT') private client: YTClient) {}

  @Get('comments/:videoId')
  async comments(
    @Param('videoId') videoId: string,
    @Query('order') order?: string,
    @Query('search') search?: string,
    @Query('maxResults') maxResults?: string,
  ) {
    return this.client.comments(videoId, {
      order: order as any,
      searchTerms: search,
      maxResults: maxResults ? Number(maxResults) : 100,
    })
  }

  @Get('comments/:videoId/top')
  async topComments(
    @Param('videoId') videoId: string,
    @Query('limit') limit?: string,
  ) {
    return this.client.topComments(videoId, limit ? Number(limit) : 10)
  }

  @Get('comment-replies/:id')
  async replies(@Param('id') id: string) {
    return this.client.commentReplies(id)
  }
}

URL Utilities (API key required)

MethodPathDescription
POST/api/url/parseParse YouTube URL
POST/api/url/extractExtract IDs from URL

Batch Transcript (API key required)

MethodPathDescription
POST/api/playlist/:id/transcriptBatch transcripts for a playlist
src/batch-transcript/batch-transcript.controller.ts
import { Controller, Post, Param, Body } from '@nestjs/common'
import { transcribePlaylist } from 'lyra-sdk/transcript'

@Controller('api')
export class BatchTranscriptController {
  @Post('playlist/:id/transcript')
  async batchTranscript(
    @Param('id') id: string,
    @Body() body: { concurrency?: number; from?: number; to?: number; lang?: string },
  ) {
    return transcribePlaylist(id, {
      apiKey: process.env.YOUTUBE_API_KEY!,
      concurrency: body.concurrency ?? 3,
      from: body.from,
      to: body.to,
      lang: body.lang,
    })
  }
}

Video Categories (API key required)

MethodPathDescription
GET/api/video-categories?regionCode=USCategories for a region
GET/api/video-category/:idSingle category by ID
src/video-category/video-category.controller.ts
import { Controller, Get, Param, Query, Inject } from '@nestjs/common'
import { YTClient } from 'lyra-sdk'

@Controller('api')
export class VideoCategoryController {
  constructor(@Inject('YT_CLIENT') private client: YTClient) {}

  @Get('video-categories')
  async categoriesByRegion(
    @Query('regionCode') regionCode = 'US',
    @Query('hl') hl?: string,
  ) {
    return this.client.videoCategoriesByRegion(regionCode, hl)
  }

  @Get('video-category/:id')
  async category(@Param('id') id: string) {
    return this.client.videoCategory(id)
  }
}

I18n (API key required)

MethodPathDescription
GET/api/regionsList supported regions
GET/api/languagesList supported languages
src/i18n/i18n.controller.ts
import { Controller, Get, Query, Inject } from '@nestjs/common'
import { YTClient } from 'lyra-sdk'

@Controller('api')
export class I18nController {
  constructor(@Inject('YT_CLIENT') private client: YTClient) {}

  @Get('regions')
  async regions(@Query('hl') hl?: string) {
    return this.client.regions(hl)
  }

  @Get('languages')
  async languages(@Query('hl') hl?: string) {
    return this.client.languages(hl)
  }
}

Running

npm run start:dev

On this page