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.
- checksums.yaml +7 -0
- data/.rspec +3 -0
- data/CHANGELOG.md +50 -0
- data/LICENSE.txt +21 -0
- data/README.md +955 -0
- data/Rakefile +41 -0
- data/lib/lipdub/client.rb +139 -0
- data/lib/lipdub/configuration.rb +17 -0
- data/lib/lipdub/errors.rb +25 -0
- data/lib/lipdub/resources/audios.rb +182 -0
- data/lib/lipdub/resources/base.rb +31 -0
- data/lib/lipdub/resources/projects.rb +53 -0
- data/lib/lipdub/resources/shots.rb +400 -0
- data/lib/lipdub/resources/videos.rb +163 -0
- data/lib/lipdub/version.rb +5 -0
- data/lib/lipdub.rb +33 -0
- data/lipdub.gemspec +46 -0
- metadata +205 -0
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
|