import type { WalletAdapter } from '@solana/wallet-adapter-base' import { z } from 'zod' import type { ClaimableTokensClient, EntityManagerService, LoggerService, PaymentRouterClient, SolanaClient, SolanaRelayService } from '../../services' import { PublicKeySchema } from '../../services/Solana' import { ProgressEventSchema, type StorageService } from '../../services/Storage/types' import { DDEXResourceContributor, DDEXCopyright, DDEXRightsController } from '../../types/DDEX' import { AudioFile, ImageFile } from '../../types/File' import { HashId } from '../../types/HashId' import { StemCategory } from '../../types/StemCategory' import { Mood, Genre, type UpdateTrackRequest, type CreateTrackRequest } from '../generated/default' import { MAX_DESCRIPTION_LENGTH } from './constants' const messages = { titleRequiredError: 'Your track must have a name', artworkRequiredError: 'Artwork is required', genreRequiredError: 'Genre is required', genreAllError: 'Genre cannot be set to "All Genres"' } export type TracksApiServicesConfig = { storage: StorageService entityManager?: EntityManagerService logger?: LoggerService claimableTokensClient: ClaimableTokensClient paymentRouterClient: PaymentRouterClient solanaRelay: SolanaRelayService solanaClient: SolanaClient } export const EthCollectibleGatedConditions = z .object({ chain: z.literal('eth'), address: z.string(), standard: z.union([z.literal('ERC721'), z.literal('ERC1155')]), name: z.string(), slug: z.string(), imageUrl: z.optional(z.string()), externalLink: z.optional(z.string()).nullable() }) .strict() export const SolCollectibleGatedConditions = z .object({ chain: z.literal('sol'), address: z.string(), name: z.string(), imageUrl: z.optional(z.string()), externalLink: z.optional(z.string()).nullable() }) .strict() export const CollectibleGatedConditions = z .object({ nftCollection: z.optional( z.union([EthCollectibleGatedConditions, SolCollectibleGatedConditions]) ) }) .strict() export const FollowGatedConditions = z .object({ followUserId: z.number() }) .strict() export const TipGatedConditions = z .object({ tipUserId: z.number() }) .strict() export const TokenGatedConditions = z .object({ tokenGate: z.object({ tokenMint: z.string(), tokenAmount: z.number() }) }) .strict() export const USDCPurchaseConditions = z .object({ usdcPurchase: z.object({ price: z.number().positive(), splits: z.array( z.object({ userId: z.number(), percentage: z.number().min(0).max(100) }) ) }) }) .strict() export const UploadStemMetadataSchema = z.object({ category: z.enum( Object.values(StemCategory) as [StemCategory, ...StemCategory[]] ), parentTrackId: HashId }) export const UploadTrackMetadataSchema = z.object({ trackId: z.optional(HashId), ownerId: z.optional(z.number()), aiAttributionUserId: z.optional(HashId), description: z.optional(z.string().max(MAX_DESCRIPTION_LENGTH).nullable()), fieldVisibility: z.optional( z.object({ mood: z.optional(z.boolean()), tags: z.optional(z.boolean()), genre: z.optional(z.boolean()), share: z.optional(z.boolean()), playCount: z.optional(z.boolean()), remixes: z.optional(z.boolean()) }) ), genre: z.enum(Object.values(Genre) as [Genre, ...Genre[]]), isrc: z.optional(z.string().nullable()), isUnlisted: z.optional(z.boolean()), iswc: z.optional(z.string().nullable()), license: z.optional(z.string().nullable()), mood: z.optional(z.enum(Object.values(Mood) as [Mood, ...Mood[]]).nullable()), isStreamGated: z.optional(z.boolean()), streamConditions: z .optional( z.union([ FollowGatedConditions, TipGatedConditions, USDCPurchaseConditions, TokenGatedConditions ]) ) .nullable(), accessAuthorities: z.optional(z.array(z.string()).nullable()), isDownloadGated: z.optional(z.boolean()), downloadConditions: z .optional( z.union([ FollowGatedConditions, TipGatedConditions, USDCPurchaseConditions, TokenGatedConditions ]) ) .nullable(), releaseDate: z.optional(z.date()), remixOf: z.optional( z .object({ tracks: z .array( z.object({ parentTrackId: HashId }) ) .min(1) }) .strict() ), stemOf: z.optional(UploadStemMetadataSchema.strict()), tags: z.optional(z.string()).nullable(), title: z.string({ required_error: messages.titleRequiredError }), duration: z.optional(z.number()), previewStartSeconds: z.optional(z.number()), placementHosts: z.optional(z.string()), audioUploadId: z.optional(z.string()), trackCid: z.optional(z.string()), previewCid: z.optional(z.string()), origFileCid: z.optional(z.string()), origFilename: z.optional(z.string()), coverArtSizes: z.optional(z.string()), isDownloadable: z.optional(z.boolean()), isOriginalAvailable: z.optional(z.boolean()), ddexReleaseIds: z.optional(z.record(z.string()).nullable()), ddexApp: z.optional(z.string()).nullable(), artists: z.optional(z.array(DDEXResourceContributor)).nullable(), resourceContributors: z.optional(z.array(DDEXResourceContributor).nullable()), indirectResourceContributors: z.optional( z.array(DDEXResourceContributor).nullable() ), rightsController: z.optional(DDEXRightsController.nullable()), copyrightLine: z.optional(DDEXCopyright.nullable()), producerCopyrightLine: z.optional(DDEXCopyright.nullable()), parentalWarningType: z.optional(z.string().nullable()), bpm: z.optional(z.number().nullable()), isCustomBpm: z.optional(z.boolean()), musicalKey: z.optional(z.string().nullable()), isCustomMusicalKey: z.optional(z.boolean()), audioAnalysisErrorCount: z.optional(z.number()), commentsDisabled: z.optional(z.boolean()), isScheduledRelease: z.optional(z.boolean()) }) export type TrackMetadata = z.input export const UploadTrackFilesProgressEventSchema = ProgressEventSchema.extend({ /** * Whether the event is for the audio or image file */ key: z.enum(['audio', 'image']) }) /** * The progress event emitted during track file uploads */ type UploadTrackFilesProgressEvent = z.input< typeof UploadTrackFilesProgressEventSchema > export const UploadTrackFilesProgressHandlerSchema = z .function() .args(z.number(), UploadTrackFilesProgressEventSchema) .returns(z.void()) export type UploadTrackFilesProgressHandler = ( /** * Overall progress percentage (0-1) */ progress: number, /** * The progress event */ event: UploadTrackFilesProgressEvent ) => void export const UploadTrackSchema = z .object({ userId: HashId, audioFile: AudioFile, imageFile: ImageFile, metadata: UploadTrackMetadataSchema.strict(), onProgress: z.optional(UploadTrackFilesProgressHandlerSchema) }) .strict() export type UploadTrackRequest = Omit< z.input, 'onProgress' > & { // Typing function manually because z.function() does not // support argument names onProgress?: UploadTrackFilesProgressHandler } export const UploadTrackFilesSchema = z .object({ audioFile: z.optional(AudioFile), imageFile: z.optional(ImageFile), fileMetadata: z .object({ placementHosts: z.string().optional(), previewStartSeconds: z.number().optional() }) .optional(), onProgress: z.optional(UploadTrackFilesProgressHandlerSchema) }) .strict() export type UploadTrackFilesRequest = Omit< z.input, 'onProgress' > & { // Typing function manually because z.function() does not // support argument names onProgress?: UploadTrackFilesProgressHandler } export const UpdateTrackSchema = z .object({ userId: HashId, trackId: HashId, metadata: UploadTrackMetadataSchema.strict().partial(), audioFile: z.optional(AudioFile), imageFile: z.optional(ImageFile), generatePreview: z.optional(z.boolean()), onProgress: z.optional(UploadTrackFilesProgressHandlerSchema) }) .strict() export type EntityManagerUpdateTrackRequest = Omit< z.input, 'onProgress' > & { onProgress?: UploadTrackFilesProgressHandler } export const DeleteTrackSchema = z .object({ userId: HashId, trackId: HashId }) .strict() export type EntityManagerDeleteTrackRequest = z.input export const FavoriteTrackSchema = z .object({ userId: HashId, trackId: HashId, metadata: z.optional( z .object({ /** * Is this a save of a repost? Used to dispatch notifications * when a user favorites another user's repost */ isSaveOfRepost: z.optional(z.boolean()) }) .strict() ) }) .strict() export type EntityManagerFavoriteTrackRequest = z.input< typeof FavoriteTrackSchema > export const UnfavoriteTrackSchema = z .object({ userId: HashId, trackId: HashId }) .strict() export type EntityManagerUnfavoriteTrackRequest = z.input< typeof UnfavoriteTrackSchema > export const RepostTrackSchema = z .object({ userId: HashId, trackId: HashId, metadata: z.optional( z .object({ /** * Is this a repost of a repost? Used to dispatch notifications * when a user reposts content that someone they follow has already reposted */ isRepostOfRepost: z.optional(z.boolean()) }) .strict() ) }) .strict() export type EntityManagerRepostTrackRequest = z.input export const UnrepostTrackSchema = z .object({ userId: HashId, trackId: HashId }) .strict() export type EntityManagerUnrepostTrackRequest = z.input< typeof UnrepostTrackSchema > export const RecordTrackDownloadSchema = z .object({ userId: HashId.optional(), trackId: HashId }) .strict() export const ShareTrackSchema = z .object({ userId: HashId, trackId: HashId }) .strict() export type EntityManagerShareTrackRequest = z.input export type EntityManagerRecordTrackDownloadRequest = z.input< typeof RecordTrackDownloadSchema > const PurchaseTrackSchemaBase = z.object({ /** The ID of the user purchasing the track. */ userId: HashId, /** The ID of the track to purchase. */ trackId: HashId, /** * The price of the track at the time of purchase (in dollars if number, USDC if bigint). * Used to check against current track price in case it changed, * effectively setting a "max price" for the purchase. */ price: z.union([z.number().min(0), z.bigint().min(BigInt(0))]), /** Any extra amount the user wants to donate (in dollars if number, USDC if bigint) */ extraAmount: z .union([z.number().min(0), z.bigint().min(BigInt(0))]) .optional(), /** Whether to include the staking system as a recipient */ includeNetworkCut: z.boolean().optional() }) export const GetPurchaseTrackInstructionsSchema = z .object({}) .merge(PurchaseTrackSchemaBase) export type GetPurchaseTrackInstructionsRequest = z.input< typeof GetPurchaseTrackInstructionsSchema > export const PurchaseTrackSchema = z .object({ /** A wallet to use to purchase (defaults to the authed user's user bank if not specified) */ walletAdapter: z .custom>() .optional(), /** A wallet to use to purchase (defaults to the authed user's user bank if not specified) */ wallet: PublicKeySchema.optional() }) .merge(PurchaseTrackSchemaBase) .strict() export type PurchaseTrackRequest = z.input const UploadResponseSchema = z.object({ id: z.string(), status: z.enum([ 'new', 'error', 'busy', 'timeout', 'audio_analysis', 'busy_audio_analysis', 'done' ]), orig_file_cid: z.string(), orig_filename: z.string(), results: z.record(z.string(), z.string()), probe: z .object({ format: z .object({ duration: z.string().optional() }) .optional() }) .optional(), audio_analysis_results: z .object({ bpm: z.number().optional(), key: z.string().optional() }) .optional() .nullable(), audio_analysis_error_count: z.number() }) export type UploadResponse = z.input export const PublishTrackSchema = z .object({ userId: HashId, metadata: UploadTrackMetadataSchema.strict(), audioUploadResponse: UploadResponseSchema, imageUploadResponse: UploadResponseSchema }) .strict() export type PublishTrackRequest = z.input export const PublishStemSchema = z .object({ userId: HashId, metadata: UploadStemMetadataSchema.strict(), audioUploadResponse: UploadResponseSchema }) .strict() export type PublishStemRequest = z.input export type UploadTrackFilesTask = { start: () => Promise<{ audioUploadResponse?: UploadResponse imageUploadResponse?: UploadResponse }> abort: () => void } export type TrackFileUploadParams = { audioFile?: z.input imageFile?: z.input onProgress?: UploadTrackFilesProgressHandler /** When true, regenerate the track preview (e.g. when preview start or track CID changed). Used for update. */ generatePreview?: boolean } export type CreateTrackRequestWithFiles = CreateTrackRequest & TrackFileUploadParams export type UpdateTrackRequestWithFiles = UpdateTrackRequest & TrackFileUploadParams