lipdub 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/Rakefile ADDED
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ # Define RSpec task
7
+ RSpec::Core::RakeTask.new(:spec)
8
+
9
+ # Default task
10
+ task default: :spec
11
+
12
+ # Custom tasks for gem management
13
+ namespace :gem do
14
+ desc "Build the gem"
15
+ task :build do
16
+ sh "gem build lipdub.gemspec"
17
+ end
18
+
19
+ desc "Install the gem locally"
20
+ task install: :build do
21
+ version = File.read("lib/lipdub/version.rb").match(/VERSION = "(.+)"/)[1]
22
+ sh "gem install ./lipdub-#{version}.gem"
23
+ end
24
+
25
+ desc "Uninstall the gem"
26
+ task :uninstall do
27
+ version = File.read("lib/lipdub/version.rb").match(/VERSION = "(.+)"/)[1]
28
+ sh "gem uninstall lipdub -v #{version}"
29
+ end
30
+
31
+ desc "Clean up built gems"
32
+ task :clean do
33
+ sh "rm -f *.gem"
34
+ end
35
+
36
+ desc "Push gem to RubyGems"
37
+ task push: :build do
38
+ version = File.read("lib/lipdub/version.rb").match(/VERSION = "(.+)"/)[1]
39
+ sh "gem push lipdub-#{version}.gem"
40
+ end
41
+ end
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lipdub
4
+ class Client
5
+ attr_reader :configuration
6
+
7
+ def initialize(configuration = nil)
8
+ @configuration = configuration || Lipdub.configuration
9
+ validate_configuration!
10
+ end
11
+
12
+ def videos
13
+ @videos ||= Resources::Videos.new(self)
14
+ end
15
+
16
+ def audios
17
+ @audios ||= Resources::Audios.new(self)
18
+ end
19
+
20
+ def shots
21
+ @shots ||= Resources::Shots.new(self)
22
+ end
23
+
24
+ def projects
25
+ @projects ||= Resources::Projects.new(self)
26
+ end
27
+
28
+ def get(path, params = {})
29
+ request(:get, path, params: params)
30
+ end
31
+
32
+ def post(path, body = {}, headers = {})
33
+ request(:post, path, body: body, headers: headers)
34
+ end
35
+
36
+ def put(path, body = {}, headers = {})
37
+ request(:put, path, body: body, headers: headers)
38
+ end
39
+
40
+ def put_file(url, file_content, content_type)
41
+ connection = Faraday.new do |conn|
42
+ conn.request :multipart
43
+ conn.adapter Faraday.default_adapter
44
+ end
45
+
46
+ response = connection.put(url) do |req|
47
+ req.headers['Content-Type'] = content_type
48
+ req.body = file_content
49
+ end
50
+
51
+ case response.status
52
+ when 200..299
53
+ {} # Return empty hash for successful file uploads
54
+ else
55
+ handle_response(response)
56
+ end
57
+ end
58
+
59
+ private
60
+
61
+ def request(method, path, params: {}, body: {}, headers: {})
62
+ response = connection.public_send(method, path) do |req|
63
+ req.params.merge!(params) if params.any?
64
+ req.headers.merge!(headers) if headers.any?
65
+ req.body = body.to_json if body.any? && !body.is_a?(String)
66
+ end
67
+
68
+ handle_response(response)
69
+ rescue Faraday::TimeoutError => e
70
+ raise TimeoutError, "Request timed out: #{e.message}"
71
+ rescue Faraday::ConnectionFailed => e
72
+ # WebMock timeout simulation raises ConnectionFailed with "execution expired"
73
+ if e.message.include?("execution expired")
74
+ raise TimeoutError, "Request timed out: #{e.message}"
75
+ else
76
+ raise ConnectionError, "Connection failed: #{e.message}"
77
+ end
78
+ end
79
+
80
+ def connection
81
+ @connection ||= Faraday.new(url: configuration.base_url) do |conn|
82
+ conn.request :json
83
+ conn.response :json, content_type: /\bjson$/
84
+ conn.response :json # Add fallback JSON parsing
85
+ conn.headers['Authorization'] = "Bearer #{configuration.api_key}"
86
+ conn.headers['User-Agent'] = "lipdub-ruby/#{VERSION}"
87
+ conn.options.timeout = configuration.timeout
88
+ conn.options.open_timeout = configuration.open_timeout
89
+ conn.adapter Faraday.default_adapter
90
+ end
91
+ end
92
+
93
+ def handle_response(response)
94
+ case response.status
95
+ when 200..299
96
+ response.body || {}
97
+ when 401
98
+ raise AuthenticationError.new("Authentication failed",
99
+ status_code: response.status,
100
+ response_body: response.body)
101
+ when 422
102
+ raise ValidationError.new("Validation error: #{extract_error_message(response)}",
103
+ status_code: response.status,
104
+ response_body: response.body)
105
+ when 404
106
+ raise NotFoundError.new("Resource not found",
107
+ status_code: response.status,
108
+ response_body: response.body)
109
+ when 429
110
+ raise RateLimitError.new("Rate limit exceeded",
111
+ status_code: response.status,
112
+ response_body: response.body)
113
+ when 500..599
114
+ raise ServerError.new("Server error",
115
+ status_code: response.status,
116
+ response_body: response.body)
117
+ else
118
+ raise APIError.new("Unexpected response: #{response.status}",
119
+ status_code: response.status,
120
+ response_body: response.body)
121
+ end
122
+ end
123
+
124
+ def extract_error_message(response)
125
+ return response.reason_phrase unless response.body.is_a?(Hash)
126
+
127
+ message = response.body.dig("error", "message") ||
128
+ response.body["message"] ||
129
+ response.body["error"] ||
130
+ response.reason_phrase
131
+
132
+ message.to_s.empty? ? response.reason_phrase : message
133
+ end
134
+
135
+ def validate_configuration!
136
+ raise ConfigurationError, "API key is required" unless configuration.valid?
137
+ end
138
+ end
139
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lipdub
4
+ class Configuration
5
+ attr_accessor :api_key, :base_url, :timeout, :open_timeout
6
+
7
+ def initialize
8
+ @base_url = "https://api.lipdub.ai"
9
+ @timeout = 30
10
+ @open_timeout = 10
11
+ end
12
+
13
+ def valid?
14
+ !api_key.nil? && !api_key.empty?
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lipdub
4
+ class Error < StandardError; end
5
+
6
+ class ConfigurationError < Error; end
7
+
8
+ class APIError < Error
9
+ attr_reader :status_code, :response_body
10
+
11
+ def initialize(message, status_code: nil, response_body: nil)
12
+ super(message)
13
+ @status_code = status_code
14
+ @response_body = response_body
15
+ end
16
+ end
17
+
18
+ class AuthenticationError < APIError; end
19
+ class ValidationError < APIError; end
20
+ class NotFoundError < APIError; end
21
+ class RateLimitError < APIError; end
22
+ class ServerError < APIError; end
23
+ class TimeoutError < Error; end
24
+ class ConnectionError < Error; end
25
+ end
@@ -0,0 +1,182 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lipdub
4
+ module Resources
5
+ class Audios < Base
6
+ # Initiate audio upload process
7
+ #
8
+ # @param size_bytes [Integer] Size of the audio file in bytes (1 to 104857600)
9
+ # @param file_name [String] Name of the audio file with extension
10
+ # @param content_type [String] MIME type of the audio file (audio/mpeg, audio/wav, audio/mp4)
11
+ # @param audio_source_url [String, nil] Optional URL of the audio source file
12
+ # @return [Hash] Response containing audio_id, upload_url, success_url, and failure_url
13
+ #
14
+ # @example
15
+ # response = client.audios.upload(
16
+ # size_bytes: 5242880,
17
+ # file_name: "voiceover.mp3",
18
+ # content_type: "audio/mpeg"
19
+ # )
20
+ # # => {
21
+ # # "data" => {
22
+ # # "audio_id" => "audio_123",
23
+ # # "upload_url" => "https://storage.lipdub.ai/upload/audio_123?token=xyz",
24
+ # # "success_url" => "https://api.lipdub.ai/v1/audio/success/audio_123",
25
+ # # "failure_url" => "https://api.lipdub.ai/v1/audio/failure/audio_123"
26
+ # # }
27
+ # # }
28
+ def upload(size_bytes:, file_name:, content_type:, audio_source_url: nil)
29
+ validate_audio_params!(size_bytes, content_type)
30
+
31
+ body = {
32
+ size_bytes: size_bytes,
33
+ file_name: file_name,
34
+ content_type: content_type
35
+ }
36
+ body[:audio_source_url] = audio_source_url if audio_source_url
37
+
38
+ post("/v1/audio", body)
39
+ end
40
+
41
+ # Upload audio file to the provided upload URL
42
+ #
43
+ # @param upload_url [String] The upload URL received from the upload method
44
+ # @param file_content [String, IO] The audio file content to upload
45
+ # @param content_type [String] MIME type of the audio file
46
+ # @return [Hash] Response from the upload
47
+ #
48
+ # @example
49
+ # file_content = File.read("voiceover.mp3")
50
+ # client.audios.upload_file(upload_url, file_content, "audio/mpeg")
51
+ def upload_file(upload_url, file_content, content_type)
52
+ put_file(upload_url, file_content, content_type)
53
+ end
54
+
55
+ # Complete audio upload process with a file path
56
+ #
57
+ # @param file_path [String] Path to the audio file
58
+ # @param content_type [String, nil] MIME type of the audio file (auto-detected if nil)
59
+ # @return [Hash] Response after successful upload
60
+ #
61
+ # @example
62
+ # response = client.audios.upload_complete("path/to/audio.mp3")
63
+ # # This method handles the entire upload flow:
64
+ # # 1. Initiates upload
65
+ # # 2. Uploads the file
66
+ # # 3. Calls success callback
67
+ def upload_complete(file_path, content_type: nil)
68
+ raise ArgumentError, "File does not exist: #{file_path}" unless File.exist?(file_path)
69
+
70
+ file_content = File.read(file_path)
71
+ file_name = File.basename(file_path)
72
+ content_type ||= detect_content_type(file_path)
73
+ size_bytes = File.size(file_path)
74
+
75
+ # Step 1: Initiate upload
76
+ upload_response = upload(
77
+ size_bytes: size_bytes,
78
+ file_name: file_name,
79
+ content_type: content_type
80
+ )
81
+
82
+ audio_id = upload_response.dig("data", "audio_id") if upload_response.is_a?(Hash)
83
+ upload_url = upload_response.dig("data", "upload_url") if upload_response.is_a?(Hash)
84
+
85
+ # Handle case where response might still be a string (fallback)
86
+ if upload_response.is_a?(String)
87
+ parsed = JSON.parse(upload_response)
88
+ audio_id = parsed.dig("data", "audio_id")
89
+ upload_url = parsed.dig("data", "upload_url")
90
+ end
91
+
92
+ begin
93
+ # Step 2: Upload file
94
+ upload_file(upload_url, file_content, content_type)
95
+
96
+ # Step 3: Mark as successful
97
+ success(audio_id)
98
+ rescue => e
99
+ # Step 3 (alternative): Mark as failed
100
+ failure(audio_id)
101
+ raise e
102
+ end
103
+ end
104
+
105
+ # Mark audio upload as successful
106
+ #
107
+ # @param audio_id [String] Unique identifier of the audio file
108
+ # @return [Hash] Response (typically empty)
109
+ #
110
+ # @example
111
+ # client.audios.success("audio_123")
112
+ def success(audio_id)
113
+ post("/v1/audio/success/#{audio_id}")
114
+ end
115
+
116
+ # Mark audio upload as failed
117
+ #
118
+ # @param audio_id [String] Unique identifier of the audio file
119
+ # @return [Hash] Response (typically empty)
120
+ #
121
+ # @example
122
+ # client.audios.failure("audio_123")
123
+ def failure(audio_id)
124
+ post("/v1/audio/failure/#{audio_id}")
125
+ end
126
+
127
+ # Get audio processing status
128
+ #
129
+ # @param audio_id [String] Unique identifier of the audio file
130
+ # @return [Hash] Response containing audio status information
131
+ #
132
+ # @example
133
+ # status = client.audios.status("audio_123")
134
+ def status(audio_id)
135
+ get("/v1/audio/status/#{audio_id}")
136
+ end
137
+
138
+ # List all audio files
139
+ #
140
+ # @param page [Integer] Page number (defaults to 1)
141
+ # @param page_size [Integer] Number of items per page (defaults to 10)
142
+ # @return [Hash] Response containing list of audio files
143
+ #
144
+ # @example
145
+ # audios = client.audios.list(page: 1, page_size: 20)
146
+ def list(page: 1, page_size: 10)
147
+ params = {
148
+ page: page,
149
+ page_size: page_size
150
+ }
151
+ get("/v1/audio", params)
152
+ end
153
+
154
+ private
155
+
156
+ def validate_audio_params!(size_bytes, content_type)
157
+ unless (1..104857600).include?(size_bytes)
158
+ raise ValidationError, "Audio file size must be between 1 and 104857600 bytes"
159
+ end
160
+
161
+ valid_types = %w[audio/mpeg audio/wav audio/mp4]
162
+ unless valid_types.include?(content_type)
163
+ raise ValidationError, "Content type must be one of: #{valid_types.join(', ')}"
164
+ end
165
+ end
166
+
167
+ def detect_content_type(file_path)
168
+ extension = File.extname(file_path).downcase
169
+ case extension
170
+ when '.mp3'
171
+ 'audio/mpeg'
172
+ when '.wav'
173
+ 'audio/wav'
174
+ when '.mp4', '.m4a'
175
+ 'audio/mp4'
176
+ else
177
+ 'audio/mpeg' # Default fallback
178
+ end
179
+ end
180
+ end
181
+ end
182
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lipdub
4
+ module Resources
5
+ class Base
6
+ attr_reader :client
7
+
8
+ def initialize(client)
9
+ @client = client
10
+ end
11
+
12
+ private
13
+
14
+ def get(path, params = {})
15
+ client.get(path, params)
16
+ end
17
+
18
+ def post(path, body = {}, headers = {})
19
+ client.post(path, body, headers)
20
+ end
21
+
22
+ def put(path, body = {}, headers = {})
23
+ client.put(path, body, headers)
24
+ end
25
+
26
+ def put_file(url, file_content, content_type)
27
+ client.put_file(url, file_content, content_type)
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lipdub
4
+ module Resources
5
+ class Projects < Base
6
+ # List all projects
7
+ #
8
+ # @param page [Integer] Page number for pagination (defaults to 1)
9
+ # @param per_page [Integer] Number of items per page, max 100 (defaults to 20)
10
+ # @return [Hash] Response containing list of projects and count
11
+ #
12
+ # @example
13
+ # projects = client.projects.list(page: 1, per_page: 50)
14
+ # # => {
15
+ # # "data" => [
16
+ # # {
17
+ # # "project_id" => 123,
18
+ # # "projects_tenant_id" => 1,
19
+ # # "projects_user_id" => 47,
20
+ # # "project_name" => "My Sample Project",
21
+ # # "user_email" => "user@example.com",
22
+ # # "created_at" => "2024-01-15T10:30:00Z",
23
+ # # "updated_at" => nil,
24
+ # # "source_language" => {
25
+ # # "language_id" => 1,
26
+ # # "name" => "English",
27
+ # # "supported" => true
28
+ # # },
29
+ # # "project_identity_type" => "single_identity",
30
+ # # "language_project_links" => []
31
+ # # }
32
+ # # ],
33
+ # # "count" => 1
34
+ # # }
35
+ def list(page: 1, per_page: 20)
36
+ validate_pagination_params!(page, per_page)
37
+
38
+ params = {
39
+ page: page,
40
+ per_page: per_page
41
+ }
42
+ get("/v1/projects", params)
43
+ end
44
+
45
+ private
46
+
47
+ def validate_pagination_params!(page, per_page)
48
+ raise ValidationError, "Page must be >= 1" if page < 1
49
+ raise ValidationError, "Per page must be between 1 and 100" unless (1..100).include?(per_page)
50
+ end
51
+ end
52
+ end
53
+ end