Single source of truth for any AI (new chat, new model) to work on tvbot instantly. Read this file first, then act. No forgetting.
tvbot is a private, unlimited Discord bot mirroring fmbot-dev for a closed friend group. Two pillars: Last.fm stats (/fm, wk, chart, at, overview, top*) + Lavalink music (Moonlink.js v5 + Spotify/YouTube). TypeScript + Node 20+ + discord.js 14 + Prisma (PostgreSQL) + tsyringe DI + ioredis + Puppeteer.
- Read this file fully before any edit. This file is the context — no need to re-ask.
- Reference files as
path:line(e.g.src/bot/startup.ts:103). - Verify via execution:
npm run build+npm testafter every feature. Never guess outputs — runnode -eortsx. - Skill: Load
tvbotskill when doing tvbot work:skill(name: "tvbot")— contains workflows for adding commands/services. - Conventions:
tsyringemanualregisterInstanceinstartup.ts, unifiedContextModel→ResponseModel,CommandResponseenum,artworkServiceis single source for covers (never trust Last.fmimageUrldirectly). - Update this file when you add a major pillar.
npm install
cp .env.example .env # fill DISCORD_TOKEN, DATABASE_URL, LASTFM_API_KEY/SECRET
npm run db:generate && npm run db:deploy
npm run dev # tsx watch src/bot/index.ts — ephemeral Puppeteer, Lavalink disabled in dev unless ENABLE_LAVALINK=true
npm run build # tsc + tsc-alias + copy-assets
npm test # vitest run (9 suites, 1 pre-existing lavalinkConfig fail)Env: DISCORD_TOKEN, DATABASE_URL, LASTFM_API_KEY/SECRET required; SPOTIFY_CLIENT_ID/SECRET, REDIS_URL=redis://localhost:6379, LAVALINK_NODES JSON, LAVALINK_BACKUP_HOST, ENVIRONMENT=local, BOT_PREFIX=., ENABLE_LAVALINK=false in dev, STAGING_CHANNEL_ID for chart uploads.
index.ts— bootstrapstartup.ts:103— DI container (500+ lines, theProgram.cs). Manuallynew+registerInstancefor every service/repo/command/handler. Add new service here.configurations/configData.ts— reads.envviadotenv, validates required.handlers/— Discord event routerscommandHandler.ts—MessageCreate→getTextCommand→isBlockedInContext→ContextModel.fromMessage→ color →executeAsync→send(embeds/components)interactionHandler.ts—InteractionCreate→ slash/autocomplete/button/select/modal →getSlashCommand/getAutoCompleteResponder/tryHandleModal. RoutesFM_MODE_PREFIX,friends:selecttype:,music:filter:,chart-edit:,track-preview:,top*,overview:,at:etc. UsescomponentTrackerfallback.musicHandler.ts— LavalinknodeConnected/Disconnect/playerSwitched→ failoverupdateQueueHandler.ts,userEventHandler.ts,clientLogHandler.ts
slashCommands/— one file per domain, each exportscommands: SlashCommandDefinition[]withdata: SlashCommandBuilder+executeAsyncuserSlashCommands.ts—/fm(User+lfm options),/fmmode,/register— usesArtworkServicefallback fortrack.imageUrlalbumSlashCommands.ts—/cover,/album→AlbumServicechartSlashCommands.ts—/chart albums|artists— delegates toBotChartServicewhoKnowsSlashCommands.ts—/whoknows,/wktrack,/wkalbum,/friendswhoknow→WhoKnows*Service+ArtworkServicefriendSlashCommands.ts—/addfriendetc.musicSlashCommands.ts—/playetc. viaMusicServicetopSlashCommands.ts—/topartists/topalbums/toptracks(time-period autocompleteweekly→overallviaSettingService) →LastFmRepository.getTop*1000 limit →TopBuildersoverviewSlashCommands.ts—/overview→OverviewService(DBuser_playsgroup by day, 500 rows, timeZone) →OverviewBuildersartistTrackSlashCommands.ts—/at artist?→ArtistTrackServiceGetTopTracksForArtistfrom DBtrackSlashCommands.ts—/trackdetails track?→TrackDetailsService(Essentia BPM/key) →TrackDetailsBuilderscrownSlashCommands.ts,tasteSlashCommands.ts,artistSlashCommands.ts,updateSlashCommands.ts— other domains
textCommands/— mirror of slash for prefix.lastfm/playCommands.ts—fm(25 aliasesnp,qm,wm…) withlfm:+<@mention>+parseFmEmbedType,cooldown 3s, usesArtworkServicelastfm/chartCommands.ts,loginCommands.ts,topCommands.ts,overviewCommands.ts,artistTrackCommands.ts,trackCommands.ts,tasteCommands.ts,updateCommands.tsguild/whoKnowsCommands.ts,guild/crownCommands.tsmusic/musicCommands.ts
builders/—ResponseModelfactoriesplayBuilders.ts—buildFmResponse6 embed types (EmbedMini/Full/Tiny vs Text*),buildFmModeResponsewhoKnowsBuilders.ts—buildWhoKnowsResponseembedcrown?+👑+Artist - X listeners - Y plays - Z avgfooter (genres-),generatePagesfor pagination 10/pagechartBuilders.ts,albumBuilders.ts,artistBuilders.ts,artistTrackBuilders.ts,topBuilders.ts,overviewBuilders.ts,trackDetailsBuilders.ts,crownBuilders.ts,tasteBuilders.ts,updateBuilders.ts,footerBuilder.ts
interactions/— button/select/modal handlerschartInteractions.ts,albumInteractions.ts,friendInteractions.ts,fmModeInteractions.ts,musicInteractions.ts,topInteractions.ts(also handlesoverviewjump modal1-31),artistTrackInteractions.ts,trackPreviewInteractions.ts(track-preview:→VoiceMessageServiceflags8192),crownInteractions.ts,tasteInteractions.ts,recentInteractions.ts
services/updateService.ts— delta sync heart (900 lines, mirrors fmbotUpdateService.cs):OVERLAP_HOURS=3,FALLBACK 14d,getUserRecentTracksWithMetadatawith retry500,2500,5000,10000,25000, deduptimePlayed msequality,applyIncrementalTopListsif<200elserecalculateTopLists,genreServicewarm.indexService.ts— full re-index:deleteAllPlays→ fetch 1000×1000 → flush every 10 pages →recalculateTopListsGROUP BYchartService.ts—ArtworkServiceprimary (Spotify→Deezer→Apple→Last.fm,isPlaceholder 2a96cb…),BotChartServiceorchestration,PuppeteerServicescreenshot,ImageUploadServicestagingartworkService.ts—getAlbumCoverUrl/getArtistImageUrl/getTrackCoverUrlwithsanitizeMusicName,isPlaceholder,FRESHNESS 90d, cacheart:*3600s, DB persistaudio/—essentiaService.ts(WASMRhythmExtractor2013BPM +KeyExtractor),audioSignalService.ts(ffmpeg-staticdecode toFloat32Array),previewResolverService.ts(spotifyScraper→Apple→Deezerscored5000/4000/2000,preview:v3:cache),trackDetailsService.ts,voiceMessageService.ts(p.scdn.coMP3_96→libopus oggflags:8192waveformbase64,previewMap)music/—moonlinkManager.ts(5 public nodes +LAVALINK_NODESenv,retryAmount:0custom failover,cooldown 10-15s,healthCheck 10s,ENABLE_LAVALINK=falsedev no-connect + Redislavalink:cooldown:*persist),musicService.ts(play/skip/stop/filters),spotifyResolver.ts+spotifyScraperService.ts(HTML__NEXT_DATA__+spclient.wg.spotify.com+getPreviewByIdembed),queueService.ts,playlistChunkManager.tswhoKnows/—whoKnowsService.ts(14-cap list,nameWithLinkusesdiscordName),whoKnowsArtistService.ts/Track/Album/PlayService.ts(fetchdiscord displayNameviaguild.members.fetchif not cached)crown/crownService.ts—GetAndUpdateCrownForArtist(eligibilityBlockedFromCrowns,CrownRoles,30plays, steal logic with livegetArtistInfo),SeedCrownsForGuildoverviewService.ts— DBprisma.userPlay.findMany take 500grouped byYYYY-MM-DD(UTC) →DailyBlock(date, playCount, duration210sest, topArtist/Album/Track, genres viaGenreService)artistTrackService.ts—getTopTracksForArtist(userId, artist, Weekly=7d else all)groupBytrackNamefromuser_playstimerService.ts— cron*/5update queue,*/2index queue,0 6,14enqueue outdated (lastUpdate<48h),0 8stale index,0 4privacy,*/10logcacheService.ts—ioredis+ in-memory fallback,get/set/deleteJSONcolorService.ts,genreService.ts,friendsService.ts,loginService.ts,settingService.ts(getTimePeriodweekly→overall +1d-6d+YYYY+MMMMviaPeriodAliases),topListSettings,localizationService.ts,componentInteractionTracker.ts,paginationService.tsguild/—guildService.ts,guildUserService.ts,disabledChannelService.ts, etc.
models/—contextModel.ts(unified slash/text),responseModel.ts(embed+content+componentsV2Container+isComponentsV2),commandModels.ts,chartModels.ts,whoKnowsModels.tsresources/discordConstants.ts— colors, emojis (sp:1496297132381048995,dez:1496297153717473311,am:1496297174869479548,fmbot_playpreview:1305607890941378672)
enums/—commandResponse,fmEmbedType(0-5),fmFooterOption(28 flags bigint),fmButton,fmAccentColor,whoKnowsMode,updateType,privacyLevel,friendTypeinterfaces/—IUserRepository,IPlayRepository,IArtistRepository,IAlbumRepository,ITrackRepository,ILastfmRepository,IFriendRepository,IWhoKnowsRepository, etc.models/—recentTrack.ts,lastFmUser.ts,botSettings.ts,musicTrack.ts,timeSettings.ts,topLists.tslogger.ts(pino),statistics.ts,lastfmErrorRateTracker.ts,constants.ts
prisma/schema.prisma298 lines —users,user_fm_settings(PKuser_id,embed_type,footer_options bigint,buttons bigint),guilds,channels,guild_users,artists,artist_genres,albums,tracks,user_plays(bigint PK,time_played timestamptz),user_artists/albums/tracks,friends,user_crowns(guild+artist unique where active)repositories/—userRepository.ts,playRepository.ts,artistRepository.ts,albumRepository.ts,trackRepository.ts,whoKnowsRepository.ts,crownRepository.ts,guildRepository.ts, etc.prismaClient.tssingleton
lastfm/api/lastfmApi.ts(fetchws.audioscrobbler.com/2.0?method=,LastfmApiErrorhandling,call/callSigned),repositories/lastFmRepository.ts(callWithRetry5 retries500,2500…),converters/recentTrackConverter.ts(pickLargestImagefilters2a96cb…placeholder),topListConverter.ts,infoConverter.tsspotify/api/spotifyTokenManager.ts,spotifySearchApi.ts(searchTracks+getSpotifyTrackUrlscored5000exact),deezer/apis/deezerApi.ts(/search/album/track),applemusic/apis/appleMusicWebApi.ts+appleMusicSearchApi.ts(upscaleArtwork)
generators/puppeteerService.ts— singletonBrowser(ephemeral dev, persistent prod.puppeteer),preheatAsync,screenshotHtml/WithRainbowSort,close()withpages1.5s +browser.close3s cap +SIGKILL,registerProcessCleanupforSIGINT/TEXT/beforeExitgenerators/chartService.ts— buildschart.htmltemplate +screenshotHtml,pages/chart.html
5 public nodes Serenetia, AjieBlogs, Jirayu-SSL, MilloHost, Jirayu-NonSSL + LAVALINK_NODES/LAVALINK_BACKUP_HOST env override
users:user_id,discord_user_id,user_name_last_fm,last_update,last_indexed,last_scrobble_update,total_play_count,session_key,privacy_leveluser_plays:(user_id,time_played)indexed,artist_name,album_name,track_name,play_sourceuser_fm_settings:embed_type,footer_options,buttons,accent_color,custom_color,small_text_typeguilds:prefix,accent_color,fm_embed_type,commands_disabled
Discord → InteractionHandler (slash) / CommandHandler (text)
→ isBlockedInContext (guild/channel/command)
→ ContextModel.fromInteraction/fromMessage
→ accentColor = colorService.getAccentColorAsync(guildId)
→ command.executeAsync(context) → ResponseModel
→ sendResponse: if isComponentsV2 → ComponentsV2 else embed+components (+content for trackdetails)
All registerInstance in startup.ts:103. No decorators. Add new service → new + registerInstance + add to slashCommands/index.ts + textCommands/index.ts + interactionHandler.ts route.
Single source ArtworkService (Spotify→Deezer→Apple→Last.fm, placeholder 2a96cb… filtered, 3600s cache, 90d freshness). Chart resolveAlbumCovers now forces ArtworkService first. fm enriches track.imageUrl via getAlbumCoverUrl + getTrackCoverUrl before PlayBuilders.
whoKnowsArtistService.getFilteredUsersForArtist → guildUsers map userId→FullGuildUserDetails → whoKnowsRepository.getIndexedUsersForArtist → map to WhoKnowsUser{userId,playcount,lastFmUsername,discordName: member.displayName ?? lastFmUsername, discordUserId} via guild.members.fetch if not cached → addOrReplaceUserToIndexList (inject caller live playcount) → filterWhoKnowsObjects (blocked/banned) → WhoKnowsBuilders.buildWhoKnowsResponse (embed crown? + Artist - X listeners - Y plays - Z avg footer where avg hidden if 1 listener).
wkt artwork bug fixed: was getAlbumCoverUrl(albumName ?? trackName) → now getTrackCoverUrl(track, artist) for strict track cover.
- Top (
ta/tt/tab):SettingService.getTimePeriod(weekly→overall +1d-6d/yesterday/today/2024/marchas chart) +LastFmRepository.getTopArtists/Albums/Tracks(…,1000)→TopBuilderspaginator (10/page, 5 btnsfirst/prev/next/last/jump8838255…/11388496…, customIdtopartists:next:0:Moha504:weekly,top-jumpmodalPage number (1-31)). Limit bumped 200→1000 to matchPage 1/40 - 608 tracks(was1/20). - Overview (
o):OverviewService.getOverviewnow DBprisma.userPlay.findMany take 500 orderBy timePlayed descgroupedYYYY-MM-DDUTC (fmbot usestimeZonelocal midnights — tvbot uses UTC for simplicity, 500 rows covers ~7d for 483 plays/week) → 4 blocks/page,8pages, footer1/8 - Top genres…395 unique tracks - 483 total plays - 120 avg. Fixes403fromuser.getRecentTrackssigned (stalesk).atis not overview:artistTrackService+at→ArtistTrackService.getTopTracksForArtist(userId, artist)groupBytrackNamefrom DB, builderYour top tracks for 'zaf'10per page📊buttonartist-overview:Id:…. - Interaction
TopInteractionshandlestop*/overview:buttons +top-jump/overview-jumpmodals.
TrackDetailsService → PreviewResolverService (spotifyScraper.getTrackPreview HTML __NEXT_DATA__ p.scdn.co/mp3-preview/... first, fallback Apple → Deezer scored 5000 + getPreviewById via SpotifySearchApi.getSpotifyTrackUrl limit 5 + baba penalty) → audioSignalService.getAudioSignalAndSr (ffmpeg-static decode to Float32) → EssentiaService RhythmExtractor2013 BPM + KeyExtractor → TrackDetailsBuilders **TRACK** by **ARTIST** has \140.0` bpm, is in key `G#` and lasts `3:18`+ rowPreview track-preview:Id: fmbot_playpreview:1305607890941378672+Open on Spotify sp:1496297132381048995/Deezer dez:1496297153717473311/Apple am:1496297174869479548(source-aware).VoiceMessageService sendViaWebhook/sendViaChannelwithflags:8192 waveformbase64 100 bytes +duration_secs audio/ogg libopusviafluent-ffmpeg`.
- Table
user_crownsguild_id+artistunique where active,seeded_crownflag. CrownService.getAndUpdateCrownForArtiststeal/claim (30plays default,BlockedFromCrowns,CrownRoles, livegetArtistInfocheck,IssuesAtLastFmguard) +SeedCrownsForGuild(DISTINCT ON(ua.name)bulkCOPY).CrownBuildersduel embed +WhoKnowswiring (call insidewhoKnowsArtistServiceafter filter, appendCrown claimed by moha!to footer).- Commands
crown,crowns, slash/crown, interactioncrown-overview.
MoonlinkManager singleton, hasHealthyNode() gate, handleNodeFailover migrates players. MusicService.play() → SpotifyResolver (playlist → YouTube search 5 concurrency) → manager.search → queue.add → player.play() → PlaylistChunkManager lazy 100. Nodes: Serenetia, AjieBlogs, Jirayu-SSL/NonSSL, MilloHost. Dev ENABLE_LAVALINK=false skips (dummy dummy-disabled). Prod ENVIRONMENT=production + Redis lavalink:cooldown:* persist.
PuppeteerService ephemeral dev (no userDataDir lock → 40 chrome leak fixed), persistent prod .puppeteer, preheatAsync, close() with pages 1.5s + browser.close 3s cap + SIGKILL, gracefulShutdown 4s hard cap (startup.ts:496).
- Service
src/bot/services/fooService.ts(e.g.TasteService,OverviewService) — queryprisma/LastFmRepository/ArtworkService. - Builder
src/bot/builders/fooBuilders.ts—static buildXResponse(): ResponseModel(embed vsContainerBuilder). - Slash
src/bot/slashCommands/fooSlashCommands.ts—new SlashCommandBuilder().setName('foo').addStringOption(...setAutocomplete(true))+executeAsync(SettingService.getTimePeriod+UserServicemention/lfm:+ builder). - Text
src/bot/textCommands/lastfm/fooCommands.ts—name: 'foo', aliases: ['f']+parseArgs+ same builder. - Interaction
src/bot/interactions/fooInteractions.tsif buttons/selects/modals (preview:,top:jump:). - Wire
src/bot/startup.ts:110imports +new FooService(...)+registerInstance+src/bot/slashCommands/index.ts:17+src/bot/textCommands/index.ts:16+src/bot/handlers/interactionHandler.ts:28route. - Verify
npm run build+npm test+ manualnpm run devslash+o/tapaginator +#modal1-31.
When you work on tvbot, you are Muse Spark with tvbot skill loaded. Follow tvbot.md §10, use file:line refs, verify via execution, keep artworkService primary, ENABLE_LAVALINK=false in dev, and update this file after major pillars.
Invocation: User says build X → read tvbot.md + plan-*.md + schema.prisma + startup.ts, then scaffold service→builder→commands→interactions→DI in one turn, marking TodoWrite progress, verifying build.
See src/bot/startup.ts for complete DI graph. Key files: updateService.ts, indexService.ts, artworkService.ts, chartService.ts, whoKnowsService.ts, crownService.ts, overviewService.ts, artistTrackService.ts, essentiaService.ts, previewResolverService.ts, voiceMessageService.ts, moonlinkManager.ts, puppeteerService.ts, playBuilders.ts, whoKnowsBuilders.ts, topBuilders.ts, overviewBuilders.ts, trackDetailsBuilders.ts.