setlistfm-ts
Version:
A TypeScript client for the setlist.fm API
103 lines (102 loc) • 20.3 kB
JavaScript
"use strict"
var e=require("zod"),t=require("axios")
class r extends Error{constructor(e,t,r,s){super(e),this.statusCode=t,this.endpoint=r,this.response=s,this.name="SetlistFMAPIError"}}class s extends r{constructor(e="Invalid or missing API key"){super(e,401),this.name="AuthenticationError"}}class i extends r{constructor(e,t){super(`${e} with identifier '${t}' not found`,404),this.name="NotFoundError"}}class a extends r{constructor(e="Rate limit exceeded"){super(e,429),this.name="RateLimitError"}}class n extends r{constructor(e,t){super(e,400),this.field=t,this.name="ValidationError"}}class o extends r{constructor(e="Internal server error"){super(e,500),this.name="ServerError"}}function createErrorFromResponse(e,t,c,u){switch(e){case 401:case 403:return new s(t)
case 404:{const e=c?.split("/")||[],t=e[e.length-2]||"Resource",r=e[e.length-1]||"unknown"
return new i(t,r)}case 429:return new a(t)
case 400:return new n(t)
case 500:case 502:case 503:case 504:return new o(t)
default:return new r(t,e,c,u)}}const c=e.z.string().min(1,"MBID is required").regex(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,"MBID must be a valid UUID format"),u=e.z.object({p:e.z.number().int("Page number must be an integer").min(1,"Page number must be greater than 0").optional()}),l=u.extend({itemsPerPage:e.z.number().int("Items per page must be an integer").min(1,"Items per page must be greater than 0").max(100,"Items per page cannot exceed 100").optional()}),m=e.z.string().length(2,"Language code must be exactly 2 characters").regex(/^[a-z]{2}$/,"Language code must contain only lowercase letters"),h=e.z.enum(["asc","desc"]),d=e.z.string().regex(/^\d{4}-\d{2}-\d{2}$/,"Date must be in YYYY-MM-DD format").refine((e=>{const t=new Date(e)
return!Number.isNaN(t.getTime())}),"Date must be a valid date"),p=e.z.object({from:d.optional(),to:d.optional()}).refine((e=>!e.from||!e.to||new Date(e.from)<=new Date(e.to)),"Start date must be before or equal to end date"),g=e.z.string().trim().min(1,"Value cannot be empty"),f=e.z.string().trim().optional(),S=e.z.string().url("Must be a valid URL").optional()
function validateWithSchema(t,r,s){try{return t.parse(r)}catch(t){if(t instanceof e.z.ZodError){const e=t.errors[0],r=`Invalid ${s}: ${e.message}`
throw new n(r,e.path?.[0]?.toString())}throw t}}const y=u.extend({artistName:g.optional(),artistMbid:c.optional(),artistTmid:e.z.number().int("Ticketmaster ID must be an integer").positive("Ticketmaster ID must be positive").optional(),sort:e.z.enum(["sortName","relevance"]).optional()}).refine((e=>e.artistName||e.artistMbid||e.artistTmid),"At least one of artistName, artistMbid, or artistTmid must be provided"),x=u,b=c,w=e.z.object({mbid:c,name:g,sortName:g,disambiguation:e.z.string().optional(),url:e.z.string().url().optional()})
e.z.object({artist:e.z.array(w),total:e.z.number().int().min(0),page:e.z.number().int().min(1),itemsPerPage:e.z.number().int().min(1)}),e.z.object({setlist:e.z.array(e.z.any()),total:e.z.number().int().min(0),page:e.z.number().int().min(1),itemsPerPage:e.z.number().int().min(1)})
const P=g.refine((e=>/^\d+$/.test(e)),"GeoId must be a numeric string"),v=e.z.string().length(2,"Country code must be exactly 2 characters").regex(/^[A-Z]{2}$/,"Country code must be 2 uppercase letters (ISO 3166-1 alpha-2)"),L=u.extend({country:v.optional(),name:g.optional(),state:g.optional(),stateCode:g.optional()}),R=e.z.object({long:e.z.number().min(-180).max(180),lat:e.z.number().min(-90).max(90)}),D=e.z.object({code:v,name:g}),z=e.z.object({id:g,name:g,stateCode:g,state:g,coords:R,country:D})
e.z.object({cities:e.z.array(z),total:e.z.number().int().min(0),page:e.z.number().int().min(1),itemsPerPage:e.z.number().int().min(1)}),e.z.object({country:e.z.array(D),total:e.z.number().int().min(0),page:e.z.number().int().min(1),itemsPerPage:e.z.number().int().min(1)})
const E=e.z.string().length(2,"Country code must be exactly 2 characters").regex(/^[A-Z]{2}$/,"Country code must be 2 uppercase letters (ISO 3166-1 alpha-2)"),q=e.z.object({code:E,name:g})
e.z.object({country:e.z.array(q),total:e.z.number().int().min(0),page:e.z.number().int().min(1),itemsPerPage:e.z.number().int().min(1)})
const I=e.z.object({}).strict(),M=g.refine((e=>/^[a-f0-9]{8}$/.test(e)),"Venue ID must be an 8-character hexadecimal string"),F=u.extend({cityId:P.optional(),cityName:g.optional(),country:v.optional(),name:g.optional(),state:g.optional(),stateCode:g.optional()}),A=u,C=e.z.object({city:z.optional(),url:e.z.string().url("Must be a valid URL"),id:g,name:g})
e.z.object({venue:e.z.array(C),total:e.z.number().int().min(0),page:e.z.number().int().min(1),itemsPerPage:e.z.number().int().min(1)})
const N=e.z.string().min(1,"Setlist ID is required").regex(/^[a-f0-9]{7,8}$/,"Setlist ID must be a 7-8 character hexadecimal string"),T=e.z.string().min(1,"Version ID is required").min(7,"Version ID must be at least 7 characters").max(9,"Version ID must be at most 9 characters"),W=e.z.string().regex(/^\d{2}-\d{2}-\d{4}$/,"Date must be in dd-MM-yyyy format").refine((e=>{const[t,r,s]=e.split("-").map(Number),i=new Date(s,r-1,t)
return i.getDate()===t&&i.getMonth()===r-1&&i.getFullYear()===s}),"Date must be a valid date"),$=e.z.string().regex(/^\d{14}$/,"Last updated must be in yyyyMMddHHmmss format").refine((e=>{const t=Number.parseInt(e.slice(0,4),10),r=Number.parseInt(e.slice(4,6),10),s=Number.parseInt(e.slice(6,8),10),i=Number.parseInt(e.slice(8,10),10),a=Number.parseInt(e.slice(10,12),10),n=Number.parseInt(e.slice(12,14),10),o=new Date(t,r-1,s,i,a,n)
return o.getFullYear()===t&&o.getMonth()===r-1&&o.getDate()===s&&o.getHours()===i&&o.getMinutes()===a&&o.getSeconds()===n}),"Last updated must be a valid timestamp"),j=e.z.object({name:g}),U=e.z.object({name:g,with:w.optional(),cover:w.optional(),info:f,tape:e.z.boolean().optional()}),B=e.z.object({name:f,encore:e.z.number().int().min(1).optional(),song:e.z.array(U)}),O=e.z.object({artist:w,venue:C,tour:j.optional(),sets:e.z.object({set:e.z.array(B)}),info:f,url:e.z.string().url(),id:N,versionId:T,eventDate:W,lastUpdated:e.z.string().regex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}[+-]\d{4}$/,"lastUpdated must be in yyyy-MM-ddTHH:mm:ss.SSSZZZZZ format")}),Q=e.z.object({setlist:e.z.array(O),total:e.z.number().int().min(0),page:e.z.number().int().min(1),itemsPerPage:e.z.number().int().min(1)}),k=u.extend({artistMbid:c.optional(),artistName:g.optional(),artistTmid:e.z.number().int().positive().optional(),cityId:g.optional(),cityName:g.optional(),countryCode:e.z.string().length(2).optional(),date:W.optional(),lastFm:e.z.number().int().positive().optional(),lastUpdated:$.optional(),state:g.optional(),stateCode:g.optional(),tourName:g.optional(),venueId:g.optional(),venueName:g.optional(),year:e.z.number().int().min(1900).max((new Date).getFullYear()+10).optional()}).refine((e=>Object.values(e).some((e=>null!=e))),"At least one search parameter must be provided"),V=e.z.object({setlistId:N})
var Z
function getProfileSettings(e){switch(e){case exports.RateLimitProfile.STANDARD:return{requestsPerSecond:2,requestsPerDay:1440}
case exports.RateLimitProfile.PREMIUM:return{requestsPerSecond:16,requestsPerDay:5e4}
case exports.RateLimitProfile.DISABLED:return{requestsPerSecond:void 0,requestsPerDay:void 0}
default:throw new Error(`Unknown rate limit profile: ${e}`)}}exports.RateLimitProfile=void 0,(Z=exports.RateLimitProfile||(exports.RateLimitProfile={})).STANDARD="standard",Z.PREMIUM="premium",Z.DISABLED="disabled"
class H{constructor(e){const t=getProfileSettings(e.profile)
this.config={profile:e.profile,requestsPerSecond:e.requestsPerSecond??t.requestsPerSecond,requestsPerDay:e.requestsPerDay??t.requestsPerDay,queueRequests:e.queueRequests??!0,maxQueueSize:e.maxQueueSize??100,onRateLimitApproached:e.onRateLimitApproached,onRateLimitExceeded:e.onRateLimitExceeded},this.state={requestsThisSecond:0,currentSecondWindow:Math.floor(Date.now()/1e3),requestsThisDay:0,currentDayWindow:Math.floor(Date.now()/864e5),requestQueue:[],processingQueue:!1}}canMakeRequest(){if(this.config.profile===exports.RateLimitProfile.DISABLED)return!0
this.updateWindows()
const e=!this.config.requestsPerSecond||this.state.requestsThisSecond<this.config.requestsPerSecond,t=!this.config.requestsPerDay||this.state.requestsThisDay<this.config.requestsPerDay
return e&&t}recordRequest(){if(this.config.profile!==exports.RateLimitProfile.DISABLED&&(this.updateWindows(),this.state.requestsThisSecond++,this.state.requestsThisDay++,this.config.onRateLimitApproached)){const e=this.config.requestsPerSecond-this.state.requestsThisSecond,t=this.config.requestsPerDay-this.state.requestsThisDay,r=Math.min(e,t)
if(r<=2){const e=this.getNextResetTime()
this.config.onRateLimitApproached(r,e)}}}async waitForNextSlot(){if(this.config.profile===exports.RateLimitProfile.DISABLED)return Promise.resolve()
if(this.canMakeRequest())return Promise.resolve()
if(!this.config.queueRequests){const e=this.getRetryAfter()
throw this.config.onRateLimitExceeded&&this.config.onRateLimitExceeded(e),new Error(`Rate limit exceeded. Retry after ${e}ms`)}return new Promise(((e,t)=>{this.state.requestQueue.length>=this.config.maxQueueSize?t(new Error("Request queue is full")):(this.state.requestQueue.push((()=>e())),this.processQueue())}))}getRetryAfter(){if(this.config.profile===exports.RateLimitProfile.DISABLED)return 0
this.updateWindows()
const e=this.config.requestsPerSecond&&this.state.requestsThisSecond>=this.config.requestsPerSecond
return this.config.requestsPerDay&&this.state.requestsThisDay>=this.config.requestsPerDay?864e5*(this.state.currentDayWindow+1)-Date.now():e?1e3*(this.state.currentSecondWindow+1)-Date.now():0}getNextResetTime(){if(this.config.profile===exports.RateLimitProfile.DISABLED)return 0
const e=1e3*(this.state.currentSecondWindow+1),t=864e5*(this.state.currentDayWindow+1)
return Math.min(e,t)}getStatus(){return this.config.profile===exports.RateLimitProfile.DISABLED?{profile:this.config.profile,canMakeRequest:!0,requestsThisSecond:0,requestsThisDay:0,queueSize:0}:(this.updateWindows(),{profile:this.config.profile,canMakeRequest:this.canMakeRequest(),requestsThisSecond:this.state.requestsThisSecond,secondLimit:this.config.requestsPerSecond,requestsThisDay:this.state.requestsThisDay,dayLimit:this.config.requestsPerDay,queueSize:this.state.requestQueue.length,retryAfter:this.getRetryAfter()})}updateWindows(){const e=Date.now(),t=Math.floor(e/1e3),r=Math.floor(e/864e5)
t>this.state.currentSecondWindow&&(this.state.requestsThisSecond=0,this.state.currentSecondWindow=t),r>this.state.currentDayWindow&&(this.state.requestsThisDay=0,this.state.currentDayWindow=r)}async processQueue(){if(!this.state.processingQueue&&0!==this.state.requestQueue.length){for(this.state.processingQueue=!0;this.state.requestQueue.length>0;)if(this.canMakeRequest()){const e=this.state.requestQueue.shift()
e&&e()}else{const e=this.getRetryAfter()
await new Promise((t=>setTimeout(t,e)))}this.state.processingQueue=!1}}}const Y="https://api.setlist.fm/rest/1.0"
class G extends Error{constructor(e,t,r){super(e),this.statusCode=t,this.response=r,this.name="SetlistFMError"}}class K{constructor(e){this.client=t.create({baseURL:Y,timeout:e.timeout||1e4,headers:{"x-api-key":e.apiKey,Accept:"application/json","User-Agent":e.userAgent,...e.language&&{"Accept-Language":e.language}}}),this.rateLimiter=new H(e.rateLimit),this.setupResponseInterceptor()}setupResponseInterceptor(){this.client.interceptors.response.use((e=>e),(e=>{if(e.response){const t=e.response.data?.error||e.message
throw new G(t,e.response.status,e.response.data)}throw e.request?new G("No response received from API"):new G(e.message)}))}async get(e,t){await this.rateLimiter.waitForNextSlot()
const r={}
t&&(r.params=t)
const s=await this.client.get(e,r)
return this.rateLimiter.recordRequest(),s.data}setLanguage(e){this.client.defaults.headers["Accept-Language"]=e}getBaseUrl(){return this.client.defaults.baseURL||Y}getRateLimiter(){return this.rateLimiter}getRateLimitStatus(){return this.rateLimiter.getStatus()}}class _{constructor(e){const t=e.rateLimit??{profile:exports.RateLimitProfile.STANDARD},r={apiKey:e.apiKey,userAgent:e.userAgent,timeout:e.timeout,language:e.language,rateLimit:t}
this.httpClient=new K(r)}setLanguage(e){this.httpClient.setLanguage(e)}getBaseUrl(){return this.httpClient.getBaseUrl()}getHttpClient(){return this.httpClient}getRateLimitStatus(){return this.httpClient.getRateLimitStatus()}searchArtists(e){return async function(e,t){const r=validateWithSchema(y,t,"search parameters")
try{const t="/search/artists"
return await e.get(t,r)}catch(e){if(e.name?.includes("SetlistFM")||e.name?.includes("Error"))throw e
throw createErrorFromResponse(e.statusCode||500,e.message||"Failed to search artists","/search/artists",e.response)}}(this.httpClient,e)}getArtist(e){return async function(e,t){const r=validateWithSchema(b,t,"artist MBID")
try{const t=`/artist/${r}`
return await e.get(t)}catch(e){if(e.name?.includes("SetlistFM")||e.name?.includes("Error"))throw e
throw createErrorFromResponse(e.statusCode||500,e.message||"Failed to retrieve artist",`/artist/${r}`,e.response)}}(this.httpClient,e)}getArtistSetlists(e,t){const r=t?{p:t}:{}
return async function(e,t,r={}){const s=validateWithSchema(b,t,"artist MBID"),i=validateWithSchema(x,r,"pagination parameters")
try{const t=`/artist/${s}/setlists`
return await e.get(t,i)}catch(e){if(e.name?.includes("SetlistFM")||e.name?.includes("Error"))throw e
throw createErrorFromResponse(e.statusCode||500,e.message||"Failed to retrieve artist setlists",`/artist/${s}/setlists`,e.response)}}(this.httpClient,e,r)}getSetlist(e){return async function(e,t){const{setlistId:r}=validateWithSchema(V,{setlistId:t},"setlist ID parameter")
try{const t=`/setlist/${r}`,s=await e.get(t)
return validateWithSchema(O,s,"setlist response")}catch(e){if(e.name?.includes("SetlistFM")||e.name?.includes("Error"))throw e
throw createErrorFromResponse(e.statusCode||500,e.message||"Failed to retrieve setlist",`/setlist/${r}`,e.response)}}(this.httpClient,e)}searchSetlists(e){return async function(e,t){const r=validateWithSchema(k,t,"search parameters")
try{const t="/search/setlists",s=await e.get(t,r)
return validateWithSchema(Q,s,"setlists search response")}catch(e){if(e.name?.includes("SetlistFM")||e.name?.includes("Error"))throw e
throw createErrorFromResponse(e.statusCode||500,e.message||"Failed to search setlists","/search/setlists",e.response)}}(this.httpClient,e)}getVenue(e){return async function(e,t){const r=validateWithSchema(M,t,"venue ID")
try{const t=`/venue/${r}`
return await e.get(t)}catch(e){if(e.name?.includes("SetlistFM")||e.name?.includes("Error"))throw e
throw createErrorFromResponse(e.statusCode||500,e.message||"Failed to retrieve venue",`/venue/${r}`,e.response)}}(this.httpClient,e)}searchVenues(e){return async function(e,t={}){const r=validateWithSchema(F,t,"search parameters")
try{const t="/search/venues"
return await e.get(t,r)}catch(e){if(e.name?.includes("SetlistFM")||e.name?.includes("Error"))throw e
throw createErrorFromResponse(e.statusCode||500,e.message||"Failed to search venues","/search/venues",e.response)}}(this.httpClient,e)}getVenueSetlists(e){return async function(e,t,r={}){const s=validateWithSchema(M,t,"venue ID"),i=validateWithSchema(A,r,"pagination parameters")
try{const t=`/venue/${s}/setlists`
return await e.get(t,i)}catch(e){if(e.name?.includes("SetlistFM")||e.name?.includes("Error"))throw e
throw createErrorFromResponse(e.statusCode||500,e.message||"Failed to retrieve venue setlists",`/venue/${s}/setlists`,e.response)}}(this.httpClient,e)}searchCities(e){return async function(e,t={}){const r=validateWithSchema(L,t,"search parameters")
try{const t="/search/cities"
return await e.get(t,r)}catch(e){if(e.name?.includes("SetlistFM")||e.name?.includes("Error"))throw e
throw createErrorFromResponse(e.statusCode||500,e.message||"Failed to search cities","/search/cities",e.response)}}(this.httpClient,e)}getCityByGeoId(e){return async function(e,t){const r=validateWithSchema(P,t,"city geoId")
try{const t=`/city/${r}`
return await e.get(t)}catch(e){if(e.name?.includes("SetlistFM")||e.name?.includes("Error"))throw e
throw createErrorFromResponse(e.statusCode||500,e.message||"Failed to retrieve city",`/city/${r}`,e.response)}}(this.httpClient,e)}searchCountries(){return async function(e,t={}){const r=validateWithSchema(I,t,"search parameters")
try{const t="/search/countries"
return await e.get(t,r)}catch(e){if(e.name?.includes("SetlistFM")||e.name?.includes("Error"))throw e
throw createErrorFromResponse(e.statusCode||500,e.message||"Failed to search countries","/search/countries",e.response)}}(this.httpClient)}}var J
exports.LogLevel=void 0,(J=exports.LogLevel||(exports.LogLevel={}))[J.SILENT=-1]="SILENT",J[J.ERROR=0]="ERROR",J[J.WARN=1]="WARN",J[J.INFO=2]="INFO",J[J.DEBUG=3]="DEBUG"
class X{constructor(e){this.config={level:e.level,includeTimestamp:e.includeTimestamp??!0,includeLocation:e.includeLocation??!1,prefix:e.prefix??"[SetlistFM]"}}error(e,...t){this.config.level>=exports.LogLevel.ERROR&&this.log("ERROR",e,...t)}warn(e,...t){this.config.level>=exports.LogLevel.WARN&&this.log("WARN",e,...t)}info(e,...t){this.config.level>=exports.LogLevel.INFO&&this.log("INFO",e,...t)}debug(e,...t){this.config.level>=exports.LogLevel.DEBUG&&this.log("DEBUG",e,...t)}getCallerLocation(){if(!this.config.includeLocation)return null
try{const e=new Error("Stack trace for logging").stack
if(!e)return null
const t=e.split("\n")[4]
if(!t)return null
const r=t.match(/\(([^)]+):(\d+):\d+\)/)||t.match(/at ([^:]+):(\d+):\d+/)||t.match(/\(([^)]+\.[jt]s):(\d+):\d+\)/)||t.match(/at [^(]*\(([^\\/\s]+\.[jt]s):(\d+):\d+\)/)||t.match(/at ([^\\/\s]+\.[jt]s):(\d+):\d+/)
if(r){const[,e,t]=r
return`${e.split(/[/\\]/).pop()||e}:${t}`}return null}catch{return null}}log(e,t,...r){const s=this.config.includeTimestamp?(new Date).toISOString():null,i=this.getCallerLocation(),a=[s&&`[${s}]`,i&&`[${i}]`,this.config.prefix,`[${e}]`,t].filter(Boolean)
console.log(a.join(" "),...r)}}function createLogger(e){return new X(e)}const ee=createLogger({level:exports.LogLevel.WARN,includeTimestamp:!0,prefix:"[SetlistFM]"})
exports.API_BASE_URL=Y,exports.AuthenticationError=s,exports.DEFAULT_TIMEOUT=1e4,exports.DateRangeSchema=p,exports.DateSchema=d,exports.ExtendedPaginationSchema=l,exports.HttpClient=K,exports.LanguageSchema=m,exports.Logger=X,exports.MbidSchema=c,exports.NonEmptyStringSchema=g,exports.NotFoundError=i,exports.OptionalStringSchema=f,exports.PaginationSchema=u,exports.RateLimitError=a,exports.RateLimiter=H,exports.ServerError=o,exports.SetlistFMAPIError=r,exports.SetlistFMClient=_,exports.SetlistFMError=G,exports.SortOrderSchema=h,exports.UrlSchema=S,exports.ValidationError=n,exports.createErrorFromResponse=createErrorFromResponse,exports.createLogger=createLogger,exports.createResponseMetadata=function(e,t="1.0"){const r={timestamp:(new Date).toISOString(),apiVersion:t}
return e["x-request-id"]&&(r.requestId=e["x-request-id"]),r},exports.createSetlistFMClient=function(e){if(!e.apiKey)throw new Error("API key is required")
if(!e.userAgent)throw new Error("User agent is required")
return new _(e)},exports.createSilentLogger=function(){return new X({level:exports.LogLevel.SILENT})},exports.defaultLogger=ee,exports.delay=function(e){return new Promise((t=>setTimeout(t,e)))},exports.extractPaginationInfo=function(e){const t=Math.ceil(e.total/e.itemsPerPage)
return{currentPage:e.page,totalItems:e.total,itemsPerPage:e.itemsPerPage,totalPages:t,hasNextPage:e.page<t,hasPrevPage:e.page>1}},exports.getLibraryInfo=function(){return{name:"setlistfm-ts",version:"0.1.0",buildTime:void 0,commit:void 0}},exports.getNextPageParams=function(e){return e.hasNextPage?{p:e.currentPage+1}:null},exports.getPrevPageParams=function(e){return e.hasPrevPage?{p:e.currentPage-1}:null},exports.getProfileSettings=getProfileSettings,exports.safeValidate=function(e,t){const r=e.safeParse(t)
return r.success?{success:!0,data:r.data}:{success:!1,error:r.error}},exports.validatePaginationParams=function(e){if(void 0!==e.p){if(e.p<1)throw new Error("Page number must be greater than 0")
if(!Number.isInteger(e.p))throw new TypeError("Page number must be an integer")}},exports.validateWithSchema=validateWithSchema