UNPKG

setlistfm-ts

Version:

A TypeScript client for the setlist.fm API

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