MediaAttachment.ts raw

   1  /**
   2   * Media type for attachments
   3   */
   4  export type MediaType = 'image' | 'video' | 'audio' | 'file'
   5  
   6  /**
   7   * Upload status for media
   8   */
   9  export type UploadStatus = 'pending' | 'uploading' | 'completed' | 'failed'
  10  
  11  /**
  12   * Image metadata from imeta tag
  13   */
  14  export interface ImageMetadata {
  15    url: string
  16    mimeType?: string
  17    width?: number
  18    height?: number
  19    size?: number
  20    blurhash?: string
  21    sha256?: string
  22    alt?: string
  23  }
  24  
  25  /**
  26   * MediaAttachment Value Object
  27   *
  28   * Represents a media file attached to a note.
  29   * Handles URL validation, type detection, and imeta tag generation.
  30   */
  31  export class MediaAttachment {
  32    private constructor(
  33      private readonly _url: string,
  34      private readonly _type: MediaType,
  35      private readonly _mimeType: string | null,
  36      private readonly _metadata: ImageMetadata | null,
  37      private readonly _status: UploadStatus,
  38      private readonly _alt: string | null
  39    ) {}
  40  
  41    /**
  42     * Create from a URL (after upload)
  43     */
  44    static fromUrl(url: string, mimeType?: string): MediaAttachment {
  45      const type = MediaAttachment.detectType(url, mimeType)
  46      return new MediaAttachment(
  47        url,
  48        type,
  49        mimeType ?? null,
  50        null,
  51        'completed',
  52        null
  53      )
  54    }
  55  
  56    /**
  57     * Create with full metadata (from imeta)
  58     */
  59    static fromMetadata(metadata: ImageMetadata): MediaAttachment {
  60      const type = MediaAttachment.detectType(metadata.url, metadata.mimeType)
  61      return new MediaAttachment(
  62        metadata.url,
  63        type,
  64        metadata.mimeType ?? null,
  65        metadata,
  66        'completed',
  67        metadata.alt ?? null
  68      )
  69    }
  70  
  71    /**
  72     * Create a pending attachment (before upload)
  73     */
  74    static pending(_fileName: string, type: MediaType): MediaAttachment {
  75      return new MediaAttachment(
  76        '', // No URL yet
  77        type,
  78        null,
  79        null,
  80        'pending',
  81        null
  82      )
  83    }
  84  
  85    /**
  86     * Detect media type from URL or mime type
  87     */
  88    private static detectType(url: string, mimeType?: string): MediaType {
  89      // Check mime type first
  90      if (mimeType) {
  91        if (mimeType.startsWith('image/')) return 'image'
  92        if (mimeType.startsWith('video/')) return 'video'
  93        if (mimeType.startsWith('audio/')) return 'audio'
  94      }
  95  
  96      // Fall back to URL extension
  97      const urlLower = url.toLowerCase()
  98      if (/\.(jpg|jpeg|png|gif|webp|heic|avif|svg)(\?|$)/.test(urlLower)) {
  99        return 'image'
 100      }
 101      if (/\.(mp4|webm|mov|avi|mkv)(\?|$)/.test(urlLower)) {
 102        return 'video'
 103      }
 104      if (/\.(mp3|wav|ogg|flac|m4a)(\?|$)/.test(urlLower)) {
 105        return 'audio'
 106      }
 107  
 108      return 'file'
 109    }
 110  
 111    // Getters
 112    get url(): string {
 113      return this._url
 114    }
 115  
 116    get type(): MediaType {
 117      return this._type
 118    }
 119  
 120    get mimeType(): string | null {
 121      return this._mimeType
 122    }
 123  
 124    get metadata(): ImageMetadata | null {
 125      return this._metadata
 126    }
 127  
 128    get status(): UploadStatus {
 129      return this._status
 130    }
 131  
 132    get alt(): string | null {
 133      return this._alt
 134    }
 135  
 136    get isImage(): boolean {
 137      return this._type === 'image'
 138    }
 139  
 140    get isVideo(): boolean {
 141      return this._type === 'video'
 142    }
 143  
 144    get isAudio(): boolean {
 145      return this._type === 'audio'
 146    }
 147  
 148    get isUploaded(): boolean {
 149      return this._status === 'completed' && this._url !== ''
 150    }
 151  
 152    /**
 153     * Generate imeta tag for this attachment
 154     * Returns null if not an image or missing required data
 155     */
 156    toImetaTag(): string[] | null {
 157      if (!this.isImage || !this._url) return null
 158  
 159      const tag = ['imeta', `url ${this._url}`]
 160  
 161      if (this._mimeType) {
 162        tag.push(`m ${this._mimeType}`)
 163      }
 164  
 165      if (this._metadata) {
 166        if (this._metadata.width && this._metadata.height) {
 167          tag.push(`dim ${this._metadata.width}x${this._metadata.height}`)
 168        }
 169        if (this._metadata.size) {
 170          tag.push(`size ${this._metadata.size}`)
 171        }
 172        if (this._metadata.blurhash) {
 173          tag.push(`blurhash ${this._metadata.blurhash}`)
 174        }
 175        if (this._metadata.sha256) {
 176          tag.push(`x ${this._metadata.sha256}`)
 177        }
 178      }
 179  
 180      if (this._alt) {
 181        tag.push(`alt ${this._alt}`)
 182      }
 183  
 184      return tag
 185    }
 186  
 187    /**
 188     * Set alt text
 189     */
 190    withAlt(alt: string): MediaAttachment {
 191      return new MediaAttachment(
 192        this._url,
 193        this._type,
 194        this._mimeType,
 195        this._metadata,
 196        this._status,
 197        alt
 198      )
 199    }
 200  
 201    /**
 202     * Update status
 203     */
 204    withStatus(status: UploadStatus): MediaAttachment {
 205      return new MediaAttachment(
 206        this._url,
 207        this._type,
 208        this._mimeType,
 209        this._metadata,
 210        status,
 211        this._alt
 212      )
 213    }
 214  
 215    /**
 216     * Set URL after upload
 217     */
 218    withUrl(url: string, metadata?: ImageMetadata): MediaAttachment {
 219      return new MediaAttachment(
 220        url,
 221        this._type,
 222        metadata?.mimeType ?? this._mimeType,
 223        metadata ?? this._metadata,
 224        'completed',
 225        this._alt
 226      )
 227    }
 228  
 229    /**
 230     * Check equality
 231     */
 232    equals(other: MediaAttachment): boolean {
 233      return this._url === other._url
 234    }
 235  }
 236