added a user system
This commit is contained in:
@@ -11,6 +11,7 @@ class OralTrainerSdk private constructor(
|
|||||||
|
|
||||||
val cache: OralTrainerCache = OralTrainerCache(appContext, config)
|
val cache: OralTrainerCache = OralTrainerCache(appContext, config)
|
||||||
val sentenceBoundaryApi: SentenceBoundaryApi = SentenceBoundaryApi(appContext, config)
|
val sentenceBoundaryApi: SentenceBoundaryApi = SentenceBoundaryApi(appContext, config)
|
||||||
|
val userApi: UserApi = UserApi(appContext, config)
|
||||||
val videoCatalogApi: VideoCatalogApi = VideoCatalogApi(appContext, config)
|
val videoCatalogApi: VideoCatalogApi = VideoCatalogApi(appContext, config)
|
||||||
private val remoteAssessors = CopyOnWriteArrayList<RemoteImitationQualityAssessor>()
|
private val remoteAssessors = CopyOnWriteArrayList<RemoteImitationQualityAssessor>()
|
||||||
|
|
||||||
|
|||||||
@@ -10,5 +10,6 @@ data class OralTrainerSdkConfig @JvmOverloads constructor(
|
|||||||
val readTimeoutMs: Int = 30_000,
|
val readTimeoutMs: Int = 30_000,
|
||||||
val sentenceBoundaryApiBaseUrl: String = "https://videoservice.d1kt.cn",
|
val sentenceBoundaryApiBaseUrl: String = "https://videoservice.d1kt.cn",
|
||||||
val assessmentApiKey: String? = null,
|
val assessmentApiKey: String? = null,
|
||||||
|
var userToken: String? = null,
|
||||||
val allowHttpFallback: Boolean = false,
|
val allowHttpFallback: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -89,6 +89,9 @@ class RemoteImitationQualityAssessor internal constructor(
|
|||||||
config.assessmentApiKey?.takeIf { it.isNotBlank() }?.let {
|
config.assessmentApiKey?.takeIf { it.isNotBlank() }?.let {
|
||||||
connection.setRequestProperty("X-Client-Key", it)
|
connection.setRequestProperty("X-Client-Key", it)
|
||||||
}
|
}
|
||||||
|
config.userToken?.takeIf { it.isNotBlank() }?.let {
|
||||||
|
connection.setRequestProperty("Authorization", "Bearer $it")
|
||||||
|
}
|
||||||
BufferedOutputStream(connection.outputStream).use { output ->
|
BufferedOutputStream(connection.outputStream).use { output ->
|
||||||
writeFormField(output, boundary, "language", request.locale ?: request.sentence.language.orEmpty())
|
writeFormField(output, boundary, "language", request.locale ?: request.sentence.language.orEmpty())
|
||||||
writeFilePart(output, boundary, request.recordingUri)
|
writeFilePart(output, boundary, request.recordingUri)
|
||||||
|
|||||||
@@ -114,6 +114,9 @@ class SentenceBoundaryApi internal constructor(
|
|||||||
connection.useCaches = false
|
connection.useCaches = false
|
||||||
connection.setRequestProperty("Accept", "application/json")
|
connection.setRequestProperty("Accept", "application/json")
|
||||||
connection.setRequestProperty("User-Agent", config.userAgent)
|
connection.setRequestProperty("User-Agent", config.userAgent)
|
||||||
|
config.userToken?.takeIf { it.isNotBlank() }?.let {
|
||||||
|
connection.setRequestProperty("Authorization", "Bearer $it")
|
||||||
|
}
|
||||||
val statusCode = connection.responseCode
|
val statusCode = connection.responseCode
|
||||||
val stream = if (statusCode in 200..299) {
|
val stream = if (statusCode in 200..299) {
|
||||||
connection.inputStream
|
connection.inputStream
|
||||||
|
|||||||
@@ -0,0 +1,256 @@
|
|||||||
|
package cn.learningpad.oraltrainer.sdk
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONObject
|
||||||
|
import java.io.IOException
|
||||||
|
import java.net.HttpURLConnection
|
||||||
|
import java.net.URL
|
||||||
|
import java.util.concurrent.ExecutorService
|
||||||
|
import java.util.concurrent.Executors
|
||||||
|
import java.util.concurrent.Future
|
||||||
|
|
||||||
|
data class AuthUser(
|
||||||
|
val id: String,
|
||||||
|
val username: String,
|
||||||
|
val nickname: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class AuthSession(
|
||||||
|
val token: String,
|
||||||
|
val user: AuthUser,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class Course(
|
||||||
|
val videoHash: String,
|
||||||
|
val title: String,
|
||||||
|
val durationMs: Long?,
|
||||||
|
val language: String?,
|
||||||
|
val sentenceCount: Int,
|
||||||
|
val streamUrl: String,
|
||||||
|
val enrolled: Boolean,
|
||||||
|
) {
|
||||||
|
fun toVideoSummary(): TrainingVideoSummary {
|
||||||
|
return TrainingVideoSummary(
|
||||||
|
videoHash = videoHash,
|
||||||
|
title = title,
|
||||||
|
streamUri = android.net.Uri.parse(streamUrl),
|
||||||
|
durationMs = durationMs,
|
||||||
|
sizeBytes = 0L,
|
||||||
|
language = language,
|
||||||
|
sentenceCount = sentenceCount,
|
||||||
|
status = "ready",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class UserResult(
|
||||||
|
val attemptId: String,
|
||||||
|
val videoHash: String,
|
||||||
|
val courseTitle: String,
|
||||||
|
val sentenceIndex: Int,
|
||||||
|
val sentenceText: String,
|
||||||
|
val overallScore: Float,
|
||||||
|
val passed: Boolean,
|
||||||
|
val createdAt: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class UserDubShare(
|
||||||
|
val shareId: String,
|
||||||
|
val videoHash: String,
|
||||||
|
val courseTitle: String,
|
||||||
|
val title: String,
|
||||||
|
val segmentCount: Int,
|
||||||
|
val averageScore: Float,
|
||||||
|
val createdAt: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
class UserApiException(
|
||||||
|
val statusCode: Int,
|
||||||
|
responseBody: String,
|
||||||
|
) : IOException("HTTP $statusCode: ${responseBody.take(300)}")
|
||||||
|
|
||||||
|
class UserApi internal constructor(
|
||||||
|
context: Context,
|
||||||
|
private val config: OralTrainerSdkConfig,
|
||||||
|
) {
|
||||||
|
private val appContext = context.applicationContext
|
||||||
|
private val mainHandler = Handler(Looper.getMainLooper())
|
||||||
|
private val executor: ExecutorService = Executors.newCachedThreadPool()
|
||||||
|
private val baseUri = android.net.Uri.parse(config.sentenceBoundaryApiBaseUrl.trimEnd('/'))
|
||||||
|
|
||||||
|
init {
|
||||||
|
require(baseUri.scheme == "https" || baseUri.scheme == "http") {
|
||||||
|
"sentenceBoundaryApiBaseUrl must use http or https."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun register(
|
||||||
|
username: String,
|
||||||
|
password: String,
|
||||||
|
onComplete: (Result<AuthSession>) -> Unit,
|
||||||
|
): CancellableRequest = submit(onComplete) { requestBlocking("auth/register", username, password, auth = false) }
|
||||||
|
|
||||||
|
fun login(
|
||||||
|
username: String,
|
||||||
|
password: String,
|
||||||
|
onComplete: (Result<AuthSession>) -> Unit,
|
||||||
|
): CancellableRequest = submit(onComplete) { requestBlocking("auth/login", username, password, auth = false) }
|
||||||
|
|
||||||
|
fun courses(
|
||||||
|
token: String,
|
||||||
|
onComplete: (Result<List<Course>>) -> Unit,
|
||||||
|
): CancellableRequest = submitObject(onComplete, token) { parseCourses(getBlocking(it, "courses")) }
|
||||||
|
|
||||||
|
fun enroll(
|
||||||
|
token: String,
|
||||||
|
videoHash: String,
|
||||||
|
onComplete: (Result<Unit>) -> Unit,
|
||||||
|
): CancellableRequest = submitUnit(onComplete, token) {
|
||||||
|
request("courses/$videoHash/enroll", token, method = "POST")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun results(
|
||||||
|
token: String,
|
||||||
|
onComplete: (Result<List<UserResult>>) -> Unit,
|
||||||
|
): CancellableRequest = submitObject(onComplete, token) { parseResults(getBlocking(it, "me/results")) }
|
||||||
|
|
||||||
|
fun dubShares(
|
||||||
|
token: String,
|
||||||
|
onComplete: (Result<List<UserDubShare>>) -> Unit,
|
||||||
|
): CancellableRequest = submitObject(onComplete, token) { parseDubShares(getBlocking(it, "me/dub-shares")) }
|
||||||
|
|
||||||
|
fun release() {
|
||||||
|
executor.shutdownNow()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun <T> submit(
|
||||||
|
onComplete: (Result<T>) -> Unit,
|
||||||
|
operation: () -> T,
|
||||||
|
): CancellableRequest {
|
||||||
|
val future: Future<*> = executor.submit {
|
||||||
|
post(onComplete, runCatching(operation))
|
||||||
|
}
|
||||||
|
return CancellableRequest(future)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun <T> submitObject(
|
||||||
|
onComplete: (Result<T>) -> Unit,
|
||||||
|
token: String,
|
||||||
|
operation: (String) -> T,
|
||||||
|
): CancellableRequest = submit(onComplete) { operation(token) }
|
||||||
|
|
||||||
|
private fun submitUnit(
|
||||||
|
onComplete: (Result<Unit>) -> Unit,
|
||||||
|
token: String,
|
||||||
|
operation: (String) -> Unit,
|
||||||
|
): CancellableRequest = submit(onComplete) {
|
||||||
|
operation(token)
|
||||||
|
Unit
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun <T> post(onComplete: (Result<T>) -> Unit, result: Result<T>) {
|
||||||
|
if (!Thread.currentThread().isInterrupted) {
|
||||||
|
mainHandler.post { onComplete(result) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun requestBlocking(path: String, username: String, password: String, auth: Boolean): AuthSession {
|
||||||
|
val root = JSONObject().put("username", username).put("password", password)
|
||||||
|
val response = request(path, body = root.toString(), method = "POST")
|
||||||
|
return parseSession(JSONObject(response))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getBlocking(token: String, path: String): JSONArray {
|
||||||
|
val body = request(path, token = token, method = "GET")
|
||||||
|
return JSONArray(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseCourses(items: JSONArray): List<Course> = List(items.length()) { index ->
|
||||||
|
val item = items.getJSONObject(index)
|
||||||
|
Course(
|
||||||
|
videoHash = item.getString("video_hash"),
|
||||||
|
title = item.getString("title"),
|
||||||
|
durationMs = if (item.isNull("duration_ms")) null else item.getLong("duration_ms"),
|
||||||
|
language = if (item.isNull("language")) null else item.getString("language"),
|
||||||
|
sentenceCount = item.getInt("sentence_count"),
|
||||||
|
streamUrl = item.getString("stream_url"),
|
||||||
|
enrolled = item.getBoolean("enrolled"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseResults(items: JSONArray): List<UserResult> = List(items.length()) { index ->
|
||||||
|
val item = items.getJSONObject(index)
|
||||||
|
UserResult(
|
||||||
|
attemptId = item.getString("attempt_id"),
|
||||||
|
videoHash = item.getString("video_hash"),
|
||||||
|
courseTitle = item.getString("course_title"),
|
||||||
|
sentenceIndex = item.getInt("sentence_index"),
|
||||||
|
sentenceText = item.getString("sentence_text"),
|
||||||
|
overallScore = item.getDouble("overall_score").toFloat(),
|
||||||
|
passed = item.getBoolean("passed"),
|
||||||
|
createdAt = item.getString("created_at"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseDubShares(items: JSONArray): List<UserDubShare> = List(items.length()) { index ->
|
||||||
|
val item = items.getJSONObject(index)
|
||||||
|
UserDubShare(
|
||||||
|
shareId = item.getString("share_id"),
|
||||||
|
videoHash = item.getString("video_hash"),
|
||||||
|
courseTitle = item.getString("course_title"),
|
||||||
|
title = item.getString("title"),
|
||||||
|
segmentCount = item.getInt("segment_count"),
|
||||||
|
averageScore = item.getDouble("average_score").toFloat(),
|
||||||
|
createdAt = item.getString("created_at"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun request(
|
||||||
|
path: String,
|
||||||
|
token: String? = null,
|
||||||
|
body: String? = null,
|
||||||
|
method: String = body?.let { "POST" } ?: "GET",
|
||||||
|
): String {
|
||||||
|
val endpoint = android.net.Uri.parse(config.sentenceBoundaryApiBaseUrl.trimEnd('/'))
|
||||||
|
.buildUpon().appendEncodedPath(path).build()
|
||||||
|
val connection = URL(endpoint.toString()).openConnection() as HttpURLConnection
|
||||||
|
try {
|
||||||
|
connection.requestMethod = method
|
||||||
|
connection.connectTimeout = config.connectTimeoutMs
|
||||||
|
connection.readTimeout = config.readTimeoutMs
|
||||||
|
connection.useCaches = false
|
||||||
|
connection.setRequestProperty("Accept", "application/json")
|
||||||
|
connection.setRequestProperty("User-Agent", config.userAgent)
|
||||||
|
token?.takeIf { it.isNotBlank() }?.let {
|
||||||
|
connection.setRequestProperty("Authorization", "Bearer $it")
|
||||||
|
}
|
||||||
|
if (body != null) {
|
||||||
|
connection.doOutput = true
|
||||||
|
connection.setRequestProperty("Content-Type", "application/json")
|
||||||
|
connection.outputStream.use { it.write(body.toByteArray(Charsets.UTF_8)) }
|
||||||
|
}
|
||||||
|
val statusCode = connection.responseCode
|
||||||
|
val stream = if (statusCode in 200..299) connection.inputStream else connection.errorStream
|
||||||
|
val text = stream?.bufferedReader(Charsets.UTF_8)?.use { it.readText() }.orEmpty()
|
||||||
|
if (statusCode !in 200..299) throw UserApiException(statusCode, text)
|
||||||
|
return text
|
||||||
|
} finally {
|
||||||
|
connection.disconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseSession(root: JSONObject): AuthSession {
|
||||||
|
val user = root.getJSONObject("user")
|
||||||
|
return AuthSession(
|
||||||
|
token = root.getString("token"),
|
||||||
|
user = AuthUser(
|
||||||
|
id = user.getString("id"),
|
||||||
|
username = user.getString("username"),
|
||||||
|
nickname = user.getString("nickname"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,16 +32,19 @@ import android.view.ViewGroup
|
|||||||
import android.view.animation.Animation
|
import android.view.animation.Animation
|
||||||
import android.view.animation.TranslateAnimation
|
import android.view.animation.TranslateAnimation
|
||||||
import android.widget.Button
|
import android.widget.Button
|
||||||
|
import android.widget.EditText
|
||||||
import android.widget.FrameLayout
|
import android.widget.FrameLayout
|
||||||
import android.widget.ImageButton
|
import android.widget.ImageButton
|
||||||
import android.widget.ImageView
|
import android.widget.ImageView
|
||||||
import android.widget.HorizontalScrollView
|
import android.widget.HorizontalScrollView
|
||||||
import android.widget.LinearLayout
|
import android.widget.LinearLayout
|
||||||
|
import android.widget.ScrollView
|
||||||
import android.widget.SeekBar
|
import android.widget.SeekBar
|
||||||
import android.widget.Switch
|
import android.widget.Switch
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
import cn.learningpad.oraltrainer.sdk.GestureEvent
|
import cn.learningpad.oraltrainer.sdk.GestureEvent
|
||||||
import cn.learningpad.oraltrainer.sdk.GestureKind
|
import cn.learningpad.oraltrainer.sdk.GestureKind
|
||||||
|
import cn.learningpad.oraltrainer.sdk.AuthSession
|
||||||
import cn.learningpad.oraltrainer.sdk.ImitationAssessmentCallback
|
import cn.learningpad.oraltrainer.sdk.ImitationAssessmentCallback
|
||||||
import cn.learningpad.oraltrainer.sdk.ImitationAssessmentResult
|
import cn.learningpad.oraltrainer.sdk.ImitationAssessmentResult
|
||||||
import cn.learningpad.oraltrainer.sdk.LoopMode
|
import cn.learningpad.oraltrainer.sdk.LoopMode
|
||||||
@@ -107,8 +110,15 @@ class MainActivity : Activity() {
|
|||||||
|
|
||||||
private var activeItemId: String = ""
|
private var activeItemId: String = ""
|
||||||
private var currentSentenceCount = 0
|
private var currentSentenceCount = 0
|
||||||
|
private var authToken: String = ""
|
||||||
|
private var authNickname: String = ""
|
||||||
|
private var authUsernameInput: EditText? = null
|
||||||
|
private var authPasswordInput: EditText? = null
|
||||||
|
private var authStatusText: TextView? = null
|
||||||
|
private var myContentList: LinearLayout? = null
|
||||||
private var sentenceBoundaryRequestGeneration = 0
|
private var sentenceBoundaryRequestGeneration = 0
|
||||||
private var catalogVideos: List<TrainingVideoSummary> = emptyList()
|
private var catalogVideos: List<TrainingVideoSummary> = emptyList()
|
||||||
|
private val courseEnrollment = mutableMapOf<String, Boolean>()
|
||||||
private var catalogStatusTextValue = "正在同步"
|
private var catalogStatusTextValue = "正在同步"
|
||||||
private var activeModule = Module.TRAIN
|
private var activeModule = Module.TRAIN
|
||||||
private var continuousPlaybackEnabled = false
|
private var continuousPlaybackEnabled = false
|
||||||
@@ -131,11 +141,16 @@ class MainActivity : Activity() {
|
|||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
configureWindow()
|
configureWindow()
|
||||||
|
|
||||||
|
val authPreferences = getSharedPreferences("oral_trainer_auth", MODE_PRIVATE)
|
||||||
|
authToken = authPreferences.getString("token", "").orEmpty()
|
||||||
|
authNickname = authPreferences.getString("nickname", "").orEmpty()
|
||||||
|
|
||||||
sdk = OralTrainerSdk.init(
|
sdk = OralTrainerSdk.init(
|
||||||
this,
|
this,
|
||||||
OralTrainerSdkConfig(
|
OralTrainerSdkConfig(
|
||||||
sentenceBoundaryApiBaseUrl = SERVER_BASE_URL,
|
sentenceBoundaryApiBaseUrl = SERVER_BASE_URL,
|
||||||
assessmentApiKey = ASSESSMENT_API_KEY,
|
assessmentApiKey = ASSESSMENT_API_KEY,
|
||||||
|
userToken = authToken.takeIf { it.isNotBlank() },
|
||||||
allowHttpFallback = true,
|
allowHttpFallback = true,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -152,9 +167,9 @@ class MainActivity : Activity() {
|
|||||||
controllerListener = createControllerListener()
|
controllerListener = createControllerListener()
|
||||||
controller.addListener(controllerListener)
|
controller.addListener(controllerListener)
|
||||||
|
|
||||||
setContentView(createContentView())
|
setRootView()
|
||||||
loadCatalog()
|
loadCatalog()
|
||||||
refreshCurrentUi()
|
if (isLoggedIn()) refreshCurrentUi()
|
||||||
|
|
||||||
registerReceiver(
|
registerReceiver(
|
||||||
screenActionReceiver,
|
screenActionReceiver,
|
||||||
@@ -164,7 +179,7 @@ class MainActivity : Activity() {
|
|||||||
|
|
||||||
override fun onConfigurationChanged(newConfig: Configuration) {
|
override fun onConfigurationChanged(newConfig: Configuration) {
|
||||||
super.onConfigurationChanged(newConfig)
|
super.onConfigurationChanged(newConfig)
|
||||||
setContentView(createContentView())
|
setRootView()
|
||||||
renderCatalog()
|
renderCatalog()
|
||||||
refreshCurrentUi()
|
refreshCurrentUi()
|
||||||
if (activeModule == Module.TEST) {
|
if (activeModule == Module.TEST) {
|
||||||
@@ -281,6 +296,22 @@ class MainActivity : Activity() {
|
|||||||
|
|
||||||
private fun createContentView(): View {
|
private fun createContentView(): View {
|
||||||
val isLandscape = resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE
|
val isLandscape = resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE
|
||||||
|
if (activeModule == Module.COURSE) {
|
||||||
|
return LinearLayout(this).apply {
|
||||||
|
orientation = LinearLayout.VERTICAL
|
||||||
|
setBackgroundColor(COLOR_BACKGROUND)
|
||||||
|
addView(createModuleTabBar())
|
||||||
|
addView(createCourseSection())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (activeModule == Module.MINE) {
|
||||||
|
return LinearLayout(this).apply {
|
||||||
|
orientation = LinearLayout.VERTICAL
|
||||||
|
setBackgroundColor(COLOR_BACKGROUND)
|
||||||
|
addView(createModuleTabBar())
|
||||||
|
addView(createMySection())
|
||||||
|
}
|
||||||
|
}
|
||||||
return if (isLandscape) {
|
return if (isLandscape) {
|
||||||
createLandscapeContentView()
|
createLandscapeContentView()
|
||||||
} else {
|
} else {
|
||||||
@@ -288,6 +319,106 @@ class MainActivity : Activity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun setRootView() {
|
||||||
|
setContentView(if (isLoggedIn()) createContentView() else createAuthView())
|
||||||
|
if (isLoggedIn()) {
|
||||||
|
renderCatalog()
|
||||||
|
refreshCurrentUi()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isLoggedIn(): Boolean = authToken.isNotBlank()
|
||||||
|
|
||||||
|
private fun createAuthView(): View {
|
||||||
|
return LinearLayout(this).apply {
|
||||||
|
orientation = LinearLayout.VERTICAL
|
||||||
|
gravity = Gravity.CENTER
|
||||||
|
setBackgroundColor(COLOR_BACKGROUND)
|
||||||
|
setPadding(28.dp, 28.dp, 28.dp, 28.dp)
|
||||||
|
|
||||||
|
addView(TextView(this@MainActivity).apply {
|
||||||
|
text = "八哥口语"
|
||||||
|
textSize = 32f
|
||||||
|
typeface = Typeface.DEFAULT_BOLD
|
||||||
|
setTextColor(Color.WHITE)
|
||||||
|
})
|
||||||
|
addView(TextView(this@MainActivity).apply {
|
||||||
|
text = "请先注册或登录后开始学习"
|
||||||
|
textSize = 15f
|
||||||
|
setTextColor(COLOR_TEXT_MUTED)
|
||||||
|
setPadding(0, 6.dp, 0, 24.dp)
|
||||||
|
})
|
||||||
|
|
||||||
|
authUsernameInput = EditText(this@MainActivity).apply {
|
||||||
|
hint = "用户名"
|
||||||
|
setSingleLine(true)
|
||||||
|
}
|
||||||
|
authPasswordInput = EditText(this@MainActivity).apply {
|
||||||
|
hint = "密码(至少 8 位)"
|
||||||
|
inputType = android.text.InputType.TYPE_CLASS_TEXT or android.text.InputType.TYPE_TEXT_VARIATION_PASSWORD
|
||||||
|
setSingleLine(true)
|
||||||
|
}
|
||||||
|
authStatusText = TextView(this@MainActivity).apply {
|
||||||
|
setTextColor(COLOR_ACCENT_LIGHT)
|
||||||
|
textSize = 13f
|
||||||
|
setPadding(0, 14.dp, 0, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
addView(authUsernameInput)
|
||||||
|
addView(authPasswordInput)
|
||||||
|
addView(Button(this@MainActivity).apply {
|
||||||
|
text = "登录"
|
||||||
|
background = rounded(COLOR_ACCENT, 8f)
|
||||||
|
setTextColor(Color.WHITE)
|
||||||
|
setOnClickListener { authenticate(register = false) }
|
||||||
|
layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 44.dp).withMargins(0, 20.dp, 0, 8.dp)
|
||||||
|
})
|
||||||
|
addView(Button(this@MainActivity).apply {
|
||||||
|
text = "注册"
|
||||||
|
setOnClickListener { authenticate(register = true) }
|
||||||
|
layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 44.dp).withMargins(0, 0, 0, 0)
|
||||||
|
})
|
||||||
|
addView(authStatusText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun authenticate(register: Boolean) {
|
||||||
|
val username = authUsernameInput?.text?.toString()?.trim().orEmpty()
|
||||||
|
val password = authPasswordInput?.text?.toString().orEmpty()
|
||||||
|
authStatusText?.text = if (register) "注册中..." else "登录中..."
|
||||||
|
val callback = { result: Result<AuthSession> ->
|
||||||
|
result.fold(
|
||||||
|
onSuccess = ::saveAuthSession,
|
||||||
|
onFailure = { error -> authStatusText?.text = "失败:${error.message}" },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (register) sdk.userApi.register(username, password, callback)
|
||||||
|
else sdk.userApi.login(username, password, callback)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveAuthSession(session: AuthSession) {
|
||||||
|
authToken = session.token
|
||||||
|
authNickname = session.user.nickname
|
||||||
|
sdk.config.userToken = session.token
|
||||||
|
getSharedPreferences("oral_trainer_auth", MODE_PRIVATE).edit()
|
||||||
|
.putString("token", session.token)
|
||||||
|
.putString("nickname", session.user.nickname)
|
||||||
|
.apply()
|
||||||
|
activeModule = Module.COURSE
|
||||||
|
setRootView()
|
||||||
|
loadCatalog()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun logout() {
|
||||||
|
getSharedPreferences("oral_trainer_auth", MODE_PRIVATE).edit().clear().apply()
|
||||||
|
authToken = ""
|
||||||
|
authNickname = ""
|
||||||
|
sdk.config.userToken = null
|
||||||
|
controller.pause()
|
||||||
|
activeModule = Module.TRAIN
|
||||||
|
setRootView()
|
||||||
|
}
|
||||||
|
|
||||||
private fun createPortraitContentView(): View {
|
private fun createPortraitContentView(): View {
|
||||||
return LinearLayout(this).apply {
|
return LinearLayout(this).apply {
|
||||||
orientation = LinearLayout.VERTICAL
|
orientation = LinearLayout.VERTICAL
|
||||||
@@ -322,8 +453,10 @@ class MainActivity : Activity() {
|
|||||||
return LinearLayout(this).apply {
|
return LinearLayout(this).apply {
|
||||||
orientation = LinearLayout.HORIZONTAL
|
orientation = LinearLayout.HORIZONTAL
|
||||||
setPadding(16.dp, 40.dp, 16.dp, 0)
|
setPadding(16.dp, 40.dp, 16.dp, 0)
|
||||||
|
addView(moduleTab("课程", Module.COURSE))
|
||||||
addView(moduleTab("训练", Module.TRAIN))
|
addView(moduleTab("训练", Module.TRAIN))
|
||||||
addView(moduleTab("测试", Module.TEST))
|
addView(moduleTab("测试", Module.TEST))
|
||||||
|
addView(moduleTab("我的", Module.MINE))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -346,6 +479,9 @@ class MainActivity : Activity() {
|
|||||||
if (activeModule == module) {
|
if (activeModule == module) {
|
||||||
return@setOnClickListener
|
return@setOnClickListener
|
||||||
}
|
}
|
||||||
|
if (module == Module.COURSE || module == Module.MINE) {
|
||||||
|
loadUserData()
|
||||||
|
}
|
||||||
activeModule = module
|
activeModule = module
|
||||||
controller.setContinuousPlayback(
|
controller.setContinuousPlayback(
|
||||||
if (activeModule == Module.TRAIN) continuousPlaybackEnabled else false
|
if (activeModule == Module.TRAIN) continuousPlaybackEnabled else false
|
||||||
@@ -1404,10 +1540,36 @@ class MainActivity : Activity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun loadCatalog() {
|
private fun loadCatalog() {
|
||||||
|
if (!isLoggedIn()) return
|
||||||
catalogStatusTextValue = "正在同步"
|
catalogStatusTextValue = "正在同步"
|
||||||
if (::catalogStatusText.isInitialized) {
|
if (::catalogStatusText.isInitialized) {
|
||||||
catalogStatusText.text = catalogStatusTextValue
|
catalogStatusText.text = catalogStatusTextValue
|
||||||
}
|
}
|
||||||
|
if (::catalogList.isInitialized) {
|
||||||
|
catalogList.removeAllViews()
|
||||||
|
catalogList.addView(infoCard("课程加载中..."))
|
||||||
|
}
|
||||||
|
sdk.userApi.courses(authToken) { result ->
|
||||||
|
result.fold(
|
||||||
|
onSuccess = { courses ->
|
||||||
|
catalogVideos = courses.map { it.toVideoSummary() }
|
||||||
|
courseEnrollment.clear()
|
||||||
|
courses.forEach { courseEnrollment[it.videoHash] = it.enrolled }
|
||||||
|
catalogStatusTextValue = "${catalogVideos.size} 个课程"
|
||||||
|
if (::catalogStatusText.isInitialized) catalogStatusText.text = catalogStatusTextValue
|
||||||
|
renderCatalog()
|
||||||
|
},
|
||||||
|
onFailure = {
|
||||||
|
catalogStatusTextValue = "课程加载失败:${it.message}"
|
||||||
|
if (::catalogStatusText.isInitialized) catalogStatusText.text = catalogStatusTextValue
|
||||||
|
courseEnrollment.clear()
|
||||||
|
renderCatalog()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadLegacyCatalog() {
|
||||||
sdk.videoCatalogApi.fetch(object : VideoCatalogCallback {
|
sdk.videoCatalogApi.fetch(object : VideoCatalogCallback {
|
||||||
override fun onSuccess(videos: List<TrainingVideoSummary>) {
|
override fun onSuccess(videos: List<TrainingVideoSummary>) {
|
||||||
catalogVideos = videos
|
catalogVideos = videos
|
||||||
@@ -1435,24 +1597,168 @@ class MainActivity : Activity() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun loadUserData() {
|
||||||
|
when (activeModule) {
|
||||||
|
Module.COURSE -> loadCatalog()
|
||||||
|
Module.MINE -> refreshMyContent()
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createCourseSection(): View {
|
||||||
|
return ScrollView(this).apply {
|
||||||
|
layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f)
|
||||||
|
addView(LinearLayout(this@MainActivity).apply {
|
||||||
|
orientation = LinearLayout.VERTICAL
|
||||||
|
setPadding(16.dp, 12.dp, 16.dp, 16.dp)
|
||||||
|
addView(TextView(this@MainActivity).apply {
|
||||||
|
text = "课程首页"
|
||||||
|
textSize = 26f
|
||||||
|
typeface = Typeface.DEFAULT_BOLD
|
||||||
|
setTextColor(Color.WHITE)
|
||||||
|
})
|
||||||
|
addView(TextView(this@MainActivity).apply {
|
||||||
|
text = "选择一门课程开始训练"
|
||||||
|
textSize = 13f
|
||||||
|
setTextColor(COLOR_TEXT_MUTED)
|
||||||
|
setPadding(0, 4.dp, 0, 16.dp)
|
||||||
|
})
|
||||||
|
addView(catalogList)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createMySection(): View {
|
||||||
|
myContentList = LinearLayout(this).apply {
|
||||||
|
orientation = LinearLayout.VERTICAL
|
||||||
|
}
|
||||||
|
return ScrollView(this).apply {
|
||||||
|
layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f)
|
||||||
|
addView(LinearLayout(this@MainActivity).apply {
|
||||||
|
orientation = LinearLayout.VERTICAL
|
||||||
|
setPadding(16.dp, 12.dp, 16.dp, 16.dp)
|
||||||
|
addView(TextView(this@MainActivity).apply {
|
||||||
|
text = authNickname.ifBlank { "我的学习" }
|
||||||
|
textSize = 26f
|
||||||
|
typeface = Typeface.DEFAULT_BOLD
|
||||||
|
setTextColor(Color.WHITE)
|
||||||
|
})
|
||||||
|
addView(Button(this@MainActivity).apply {
|
||||||
|
text = "退出登录"
|
||||||
|
setOnClickListener { logout() }
|
||||||
|
layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 44.dp).withMargins(0, 12.dp, 0, 20.dp)
|
||||||
|
})
|
||||||
|
addView(TextView(this@MainActivity).apply {
|
||||||
|
text = "我的测试结果"
|
||||||
|
textSize = 18f
|
||||||
|
typeface = Typeface.DEFAULT_BOLD
|
||||||
|
setTextColor(Color.WHITE)
|
||||||
|
})
|
||||||
|
addView(myContentList)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun refreshMyContent() {
|
||||||
|
val list = myContentList ?: return
|
||||||
|
list.removeAllViews()
|
||||||
|
list.addView(infoCard("加载中..."))
|
||||||
|
sdk.userApi.results(authToken) { result ->
|
||||||
|
list.removeAllViews()
|
||||||
|
result.fold(
|
||||||
|
onSuccess = { rows ->
|
||||||
|
if (rows.isEmpty()) list.addView(infoCard("还没有测试记录"))
|
||||||
|
rows.forEach { row ->
|
||||||
|
list.addView(infoCard(
|
||||||
|
"${row.courseTitle} · 第 ${row.sentenceIndex + 1} 句\n" +
|
||||||
|
"得分 ${row.overallScore.toInt()} · ${if (row.passed) "通过" else "未通过"}"
|
||||||
|
))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onFailure = { list.addView(infoCard("测试记录加载失败:${it.message}")) },
|
||||||
|
)
|
||||||
|
sdk.userApi.dubShares(authToken) { shareResult ->
|
||||||
|
list.post {
|
||||||
|
list.addView(titleDivider("我的配音分享"))
|
||||||
|
shareResult.fold(
|
||||||
|
onSuccess = { shares ->
|
||||||
|
if (shares.isEmpty()) list.addView(infoCard("还没有分享配音"))
|
||||||
|
shares.forEach { row ->
|
||||||
|
list.addView(infoCard(
|
||||||
|
"${row.title}\n${row.courseTitle} · ${row.segmentCount} 句 · 平均 ${row.averageScore.toInt()} 分"
|
||||||
|
))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onFailure = { list.addView(infoCard("配音分享加载失败:${it.message}")) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun infoCard(text: String): TextView {
|
||||||
|
return TextView(this).apply {
|
||||||
|
this.text = text
|
||||||
|
setTextColor(COLOR_TEXT_DARK)
|
||||||
|
textSize = 13f
|
||||||
|
background = rounded(COLOR_SELECTED, 8f)
|
||||||
|
setPadding(12.dp, 10.dp, 12.dp, 10.dp)
|
||||||
|
layoutParams = LinearLayout.LayoutParams(
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||||
|
ViewGroup.LayoutParams.WRAP_CONTENT,
|
||||||
|
).withMargins(0, 0, 0, 8.dp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun titleDivider(text: String): TextView {
|
||||||
|
return TextView(this).apply {
|
||||||
|
this.text = text
|
||||||
|
setTextColor(Color.WHITE)
|
||||||
|
textSize = 18f
|
||||||
|
typeface = Typeface.DEFAULT_BOLD
|
||||||
|
setPadding(0, 20.dp, 0, 8.dp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun renderCatalog() {
|
private fun renderCatalog() {
|
||||||
if (!::catalogList.isInitialized) {
|
if (!::catalogList.isInitialized) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
catalogList.removeAllViews()
|
catalogList.removeAllViews()
|
||||||
catalogVideos.forEach { video ->
|
catalogVideos.forEach { video ->
|
||||||
|
val enrolled = courseEnrollment[video.videoHash] == true
|
||||||
catalogList.addView(
|
catalogList.addView(
|
||||||
videoCard(
|
videoCard(
|
||||||
id = video.videoHash,
|
id = video.videoHash,
|
||||||
title = video.title,
|
title = video.title,
|
||||||
meta = "${video.sentenceCount} 句 · ${formatTime(video.durationMs ?: -1L)}",
|
meta = if (enrolled) {
|
||||||
|
"已选修 · ${video.sentenceCount} 句"
|
||||||
|
} else {
|
||||||
|
"未选修 · 点击选修"
|
||||||
|
},
|
||||||
) {
|
) {
|
||||||
loadRemoteVideo(video)
|
if (enrolled) loadRemoteVideo(video) else enrollCourse(video)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun enrollCourse(video: TrainingVideoSummary) {
|
||||||
|
sdk.userApi.enroll(authToken, video.videoHash) { result ->
|
||||||
|
result.fold(
|
||||||
|
onSuccess = {
|
||||||
|
courseEnrollment[video.videoHash] = true
|
||||||
|
renderCatalog()
|
||||||
|
},
|
||||||
|
onFailure = {
|
||||||
|
if (::catalogStatusText.isInitialized) {
|
||||||
|
catalogStatusText.text = "选修失败:${it.message}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun loadRemoteVideo(video: TrainingVideoSummary) {
|
private fun loadRemoteVideo(video: TrainingVideoSummary) {
|
||||||
activeItemId = video.videoHash
|
activeItemId = video.videoHash
|
||||||
lessonTitleText.text = video.title
|
lessonTitleText.text = video.title
|
||||||
@@ -1668,8 +1974,10 @@ class MainActivity : Activity() {
|
|||||||
private val SHA256_PATTERN = Regex("^[0-9a-fA-F]{64}$")
|
private val SHA256_PATTERN = Regex("^[0-9a-fA-F]{64}$")
|
||||||
|
|
||||||
enum class Module {
|
enum class Module {
|
||||||
|
COURSE,
|
||||||
TRAIN,
|
TRAIN,
|
||||||
TEST,
|
TEST,
|
||||||
|
MINE,
|
||||||
}
|
}
|
||||||
|
|
||||||
val COLOR_BACKGROUND: Int = Color.rgb(12, 15, 18)
|
val COLOR_BACKGROUND: Int = Color.rgb(12, 15, 18)
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ class AssessmentService:
|
|||||||
audio_path: Path,
|
audio_path: Path,
|
||||||
language: Optional[str] = None,
|
language: Optional[str] = None,
|
||||||
retained_audio_filename: Optional[str] = None,
|
retained_audio_filename: Optional[str] = None,
|
||||||
|
user_id: str = "",
|
||||||
) -> AssessmentResult:
|
) -> AssessmentResult:
|
||||||
if not self.transcriber.available:
|
if not self.transcriber.available:
|
||||||
raise RuntimeError("MOSS transcription is not configured on this server.")
|
raise RuntimeError("MOSS transcription is not configured on this server.")
|
||||||
@@ -103,5 +104,6 @@ class AssessmentService:
|
|||||||
sentence_index=sentence_index,
|
sentence_index=sentence_index,
|
||||||
result=result.model_dump(mode="json"),
|
result=result.model_dump(mode="json"),
|
||||||
audio_filename=retained_audio_filename,
|
audio_filename=retained_audio_filename,
|
||||||
|
user_id=user_id,
|
||||||
)
|
)
|
||||||
return result
|
return result
|
||||||
|
|||||||
49
sentence_api/auth.py
Normal file
49
sentence_api/auth.py
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import secrets
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
PBKDF2_ITERATIONS = 120_000
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class AuthenticatedUser:
|
||||||
|
id: str
|
||||||
|
username: str
|
||||||
|
nickname: str
|
||||||
|
|
||||||
|
|
||||||
|
def hash_password(password: str) -> str:
|
||||||
|
salt = secrets.token_hex(16)
|
||||||
|
digest = hashlib.pbkdf2_hmac(
|
||||||
|
"sha256",
|
||||||
|
password.encode("utf-8"),
|
||||||
|
salt.encode("ascii"),
|
||||||
|
PBKDF2_ITERATIONS,
|
||||||
|
).hex()
|
||||||
|
return f"pbkdf2_sha256${PBKDF2_ITERATIONS}${salt}${digest}"
|
||||||
|
|
||||||
|
|
||||||
|
def verify_password(password: str, stored: str) -> bool:
|
||||||
|
try:
|
||||||
|
algorithm, iterations, salt, digest = stored.split("$", 3)
|
||||||
|
if algorithm != "pbkdf2_sha256":
|
||||||
|
return False
|
||||||
|
calculated = hashlib.pbkdf2_hmac(
|
||||||
|
"sha256",
|
||||||
|
password.encode("utf-8"),
|
||||||
|
salt.encode("ascii"),
|
||||||
|
int(iterations),
|
||||||
|
).hex()
|
||||||
|
return hmac.compare_digest(calculated, digest)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def new_token() -> str:
|
||||||
|
return secrets.token_urlsafe(48)
|
||||||
|
|
||||||
|
|
||||||
|
def hash_token(token: str) -> str:
|
||||||
|
return hashlib.sha256(token.encode("utf-8")).hexdigest()
|
||||||
@@ -28,14 +28,21 @@ from starlette.concurrency import run_in_threadpool
|
|||||||
from starlette.requests import ClientDisconnect
|
from starlette.requests import ClientDisconnect
|
||||||
|
|
||||||
from .assessment import AssessmentService
|
from .assessment import AssessmentService
|
||||||
|
from .auth import AuthenticatedUser
|
||||||
from .audio_metrics import AudioAnalysisError
|
from .audio_metrics import AudioAnalysisError
|
||||||
from .config import Settings
|
from .config import Settings
|
||||||
from .models import (
|
from .models import (
|
||||||
AssessmentResult,
|
AssessmentResult,
|
||||||
|
AuthRequest,
|
||||||
|
AuthResponse,
|
||||||
|
CourseSummary,
|
||||||
SentenceBoundaryAdjust,
|
SentenceBoundaryAdjust,
|
||||||
SentenceBoundary,
|
SentenceBoundary,
|
||||||
SentenceBoundaryDocument,
|
SentenceBoundaryDocument,
|
||||||
SentenceTextUpdate,
|
SentenceTextUpdate,
|
||||||
|
UserDubShareSummary,
|
||||||
|
UserPublic,
|
||||||
|
UserResultSummary,
|
||||||
VideoDetailResponse,
|
VideoDetailResponse,
|
||||||
VideoListResponse,
|
VideoListResponse,
|
||||||
VideoSummary,
|
VideoSummary,
|
||||||
@@ -124,6 +131,20 @@ def create_app(
|
|||||||
if expected and not hmac.compare_digest(x_client_key or "", expected):
|
if expected and not hmac.compare_digest(x_client_key or "", expected):
|
||||||
raise HTTPException(status_code=401, detail="A valid X-Client-Key header is required.")
|
raise HTTPException(status_code=401, detail="A valid X-Client-Key header is required.")
|
||||||
|
|
||||||
|
def current_user(
|
||||||
|
authorization: Optional[str] = Header(default=None),
|
||||||
|
x_user_token: Optional[str] = Header(default=None),
|
||||||
|
) -> AuthenticatedUser:
|
||||||
|
token = x_user_token
|
||||||
|
if authorization and authorization.lower().startswith("bearer "):
|
||||||
|
token = authorization[7:].strip()
|
||||||
|
if not token:
|
||||||
|
raise HTTPException(status_code=401, detail="Login required.")
|
||||||
|
row = video_repository.resolve_session(token)
|
||||||
|
if row is None:
|
||||||
|
raise HTTPException(status_code=401, detail="Session expired. Please sign in again.")
|
||||||
|
return AuthenticatedUser(id=row["id"], username=row["username"], nickname=row["nickname"])
|
||||||
|
|
||||||
def find_document(video_hash: str) -> Optional[SentenceBoundaryDocument]:
|
def find_document(video_hash: str) -> Optional[SentenceBoundaryDocument]:
|
||||||
return video_repository.get_document(video_hash) or legacy_store.get(video_hash)
|
return video_repository.get_document(video_hash) or legacy_store.get(video_hash)
|
||||||
|
|
||||||
@@ -139,6 +160,86 @@ def create_app(
|
|||||||
"scoring_version": "asr-fluency-v1",
|
"scoring_version": "asr-fluency-v1",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@application.post("/api/v1/auth/register", response_model=AuthResponse, status_code=201)
|
||||||
|
def register_user(payload: AuthRequest) -> AuthResponse:
|
||||||
|
user = video_repository.create_user(
|
||||||
|
username=payload.username,
|
||||||
|
password=payload.password,
|
||||||
|
nickname=payload.username,
|
||||||
|
)
|
||||||
|
if user is None:
|
||||||
|
raise HTTPException(status_code=409, detail="Username is already taken.")
|
||||||
|
token = video_repository.create_session(user["id"])
|
||||||
|
return AuthResponse(token=token, user=UserPublic(**user))
|
||||||
|
|
||||||
|
@application.post("/api/v1/auth/login", response_model=AuthResponse)
|
||||||
|
def login_user(payload: AuthRequest) -> AuthResponse:
|
||||||
|
user = video_repository.authenticate_user(payload.username, payload.password)
|
||||||
|
if user is None:
|
||||||
|
raise HTTPException(status_code=401, detail="Incorrect username or password.")
|
||||||
|
token = video_repository.create_session(user["id"])
|
||||||
|
return AuthResponse(token=token, user=UserPublic(**user))
|
||||||
|
|
||||||
|
@application.get("/api/v1/auth/me", response_model=UserPublic)
|
||||||
|
def get_authenticated_user(user: AuthenticatedUser = Depends(current_user)) -> UserPublic:
|
||||||
|
row = video_repository.get_user_by_id(user.id)
|
||||||
|
if row is None:
|
||||||
|
raise HTTPException(status_code=404, detail="User was not found.")
|
||||||
|
return UserPublic(**row)
|
||||||
|
|
||||||
|
@application.post("/api/v1/auth/logout", status_code=204)
|
||||||
|
def logout_user(
|
||||||
|
authorization: Optional[str] = Header(default=None),
|
||||||
|
x_user_token: Optional[str] = Header(default=None),
|
||||||
|
) -> Response:
|
||||||
|
token = x_user_token
|
||||||
|
if authorization and authorization.lower().startswith("bearer "):
|
||||||
|
token = authorization[7:].strip()
|
||||||
|
if token:
|
||||||
|
video_repository.delete_session(token)
|
||||||
|
return Response(status_code=204)
|
||||||
|
|
||||||
|
@application.get("/api/v1/courses", response_model=List[CourseSummary])
|
||||||
|
def list_courses(user: AuthenticatedUser = Depends(current_user)) -> List[CourseSummary]:
|
||||||
|
return [
|
||||||
|
CourseSummary(
|
||||||
|
video_hash=row["video_hash"],
|
||||||
|
title=row["title"],
|
||||||
|
duration_ms=row["duration_ms"],
|
||||||
|
language=row["language"],
|
||||||
|
sentence_count=row["sentence_count"],
|
||||||
|
stream_url=_video_summary(row, service_settings).stream_url,
|
||||||
|
enrolled=bool(row["enrolled"]),
|
||||||
|
)
|
||||||
|
for row in video_repository.list_courses(user.id)
|
||||||
|
]
|
||||||
|
|
||||||
|
@application.post("/api/v1/courses/{video_hash}/enroll", status_code=204)
|
||||||
|
def enroll_course(
|
||||||
|
video_hash: str = SHA256_PATH,
|
||||||
|
user: AuthenticatedUser = Depends(current_user),
|
||||||
|
) -> Response:
|
||||||
|
if not video_repository.enroll_course(user.id, video_hash):
|
||||||
|
raise HTTPException(status_code=404, detail="Course is not available.")
|
||||||
|
return Response(status_code=204)
|
||||||
|
|
||||||
|
@application.delete("/api/v1/courses/{video_hash}/enroll", status_code=204)
|
||||||
|
def unenroll_course(
|
||||||
|
video_hash: str = SHA256_PATH,
|
||||||
|
user: AuthenticatedUser = Depends(current_user),
|
||||||
|
) -> Response:
|
||||||
|
if not video_repository.unenroll_course(user.id, video_hash):
|
||||||
|
raise HTTPException(status_code=404, detail="Enrollment was not found.")
|
||||||
|
return Response(status_code=204)
|
||||||
|
|
||||||
|
@application.get("/api/v1/me/results", response_model=List[UserResultSummary])
|
||||||
|
def my_results(user: AuthenticatedUser = Depends(current_user)) -> List[UserResultSummary]:
|
||||||
|
return [UserResultSummary(**row) for row in video_repository.list_user_results(user.id)]
|
||||||
|
|
||||||
|
@application.get("/api/v1/me/dub-shares", response_model=List[UserDubShareSummary])
|
||||||
|
def my_dub_shares(user: AuthenticatedUser = Depends(current_user)) -> List[UserDubShareSummary]:
|
||||||
|
return [UserDubShareSummary(**row) for row in video_repository.list_user_dub_shares(user.id)]
|
||||||
|
|
||||||
@application.get("/admin", include_in_schema=False)
|
@application.get("/admin", include_in_schema=False)
|
||||||
def admin_page() -> FileResponse:
|
def admin_page() -> FileResponse:
|
||||||
page = static_dir / "admin.html"
|
page = static_dir / "admin.html"
|
||||||
@@ -181,11 +282,13 @@ def create_app(
|
|||||||
@application.get(
|
@application.get(
|
||||||
"/api/v1/videos/{video_hash}/sentence-boundaries",
|
"/api/v1/videos/{video_hash}/sentence-boundaries",
|
||||||
response_model=SentenceBoundaryDocument,
|
response_model=SentenceBoundaryDocument,
|
||||||
|
dependencies=[Depends(current_user)],
|
||||||
)
|
)
|
||||||
@application.get(
|
@application.get(
|
||||||
"/api/v1/sentence-boundaries/{video_hash}",
|
"/api/v1/sentence-boundaries/{video_hash}",
|
||||||
response_model=SentenceBoundaryDocument,
|
response_model=SentenceBoundaryDocument,
|
||||||
include_in_schema=False,
|
include_in_schema=False,
|
||||||
|
dependencies=[Depends(current_user)],
|
||||||
)
|
)
|
||||||
def get_sentence_boundaries(video_hash: str = SHA256_PATH) -> SentenceBoundaryDocument:
|
def get_sentence_boundaries(video_hash: str = SHA256_PATH) -> SentenceBoundaryDocument:
|
||||||
document = find_document(video_hash)
|
document = find_document(video_hash)
|
||||||
@@ -401,13 +504,13 @@ def create_app(
|
|||||||
@application.post(
|
@application.post(
|
||||||
"/api/v1/videos/{video_hash}/sentences/{sentence_index}/assessments",
|
"/api/v1/videos/{video_hash}/sentences/{sentence_index}/assessments",
|
||||||
response_model=AssessmentResult,
|
response_model=AssessmentResult,
|
||||||
dependencies=[Depends(require_client)],
|
|
||||||
)
|
)
|
||||||
async def assess_sentence(
|
async def assess_sentence(
|
||||||
video_hash: str = SHA256_PATH,
|
video_hash: str = SHA256_PATH,
|
||||||
sentence_index: int = ApiPath(ge=0),
|
sentence_index: int = ApiPath(ge=0),
|
||||||
audio: UploadFile = File(...),
|
audio: UploadFile = File(...),
|
||||||
language: Optional[str] = Form(default=None),
|
language: Optional[str] = Form(default=None),
|
||||||
|
user: AuthenticatedUser = Depends(current_user),
|
||||||
) -> AssessmentResult:
|
) -> AssessmentResult:
|
||||||
document = find_document(video_hash)
|
document = find_document(video_hash)
|
||||||
if document is None:
|
if document is None:
|
||||||
@@ -433,6 +536,7 @@ def create_app(
|
|||||||
audio_path=temporary_path,
|
audio_path=temporary_path,
|
||||||
language=language,
|
language=language,
|
||||||
retained_audio_filename=temporary_name if service_settings.keep_attempt_audio else None,
|
retained_audio_filename=temporary_name if service_settings.keep_attempt_audio else None,
|
||||||
|
user_id=user.id,
|
||||||
)
|
)
|
||||||
completed = True
|
completed = True
|
||||||
return result
|
return result
|
||||||
@@ -457,6 +561,7 @@ def create_app(
|
|||||||
segments: str = Form(...),
|
segments: str = Form(...),
|
||||||
scores: str = Form(default="[]"),
|
scores: str = Form(default="[]"),
|
||||||
files: List[UploadFile] = File(...),
|
files: List[UploadFile] = File(...),
|
||||||
|
user: AuthenticatedUser = Depends(current_user),
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
try:
|
try:
|
||||||
segment_items = json.loads(segments)
|
segment_items = json.loads(segments)
|
||||||
@@ -526,6 +631,7 @@ def create_app(
|
|||||||
video_hash=video_hash.lower(),
|
video_hash=video_hash.lower(),
|
||||||
title=title,
|
title=title,
|
||||||
segments=prepared_segments,
|
segments=prepared_segments,
|
||||||
|
user_id=user.id,
|
||||||
)
|
)
|
||||||
if share is None:
|
if share is None:
|
||||||
raise HTTPException(status_code=404, detail="Video was not found.")
|
raise HTTPException(status_code=404, detail="Video was not found.")
|
||||||
|
|||||||
@@ -131,3 +131,53 @@ class AssessmentResult(BaseModel):
|
|||||||
substitutions: List[TextSubstitution]
|
substitutions: List[TextSubstitution]
|
||||||
feedback: str
|
feedback: str
|
||||||
details: Dict[str, str] = Field(default_factory=dict)
|
details: Dict[str, str] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class AuthRequest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
username: str = Field(min_length=3, max_length=50, pattern=r"^[A-Za-z0-9_.-]+$")
|
||||||
|
password: str = Field(min_length=8, max_length=128)
|
||||||
|
|
||||||
|
|
||||||
|
class UserPublic(BaseModel):
|
||||||
|
id: str
|
||||||
|
username: str
|
||||||
|
nickname: str
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class AuthResponse(BaseModel):
|
||||||
|
token: str
|
||||||
|
user: UserPublic
|
||||||
|
|
||||||
|
|
||||||
|
class CourseSummary(BaseModel):
|
||||||
|
video_hash: str
|
||||||
|
title: str
|
||||||
|
duration_ms: Optional[int] = None
|
||||||
|
language: Optional[str] = None
|
||||||
|
sentence_count: int = Field(ge=0)
|
||||||
|
stream_url: str
|
||||||
|
enrolled: bool
|
||||||
|
|
||||||
|
|
||||||
|
class UserResultSummary(BaseModel):
|
||||||
|
attempt_id: str
|
||||||
|
video_hash: str
|
||||||
|
course_title: str
|
||||||
|
sentence_index: int
|
||||||
|
sentence_text: str
|
||||||
|
overall_score: float = Field(ge=0, le=100)
|
||||||
|
passed: bool
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class UserDubShareSummary(BaseModel):
|
||||||
|
share_id: str
|
||||||
|
video_hash: str
|
||||||
|
course_title: str
|
||||||
|
title: str
|
||||||
|
segment_count: int = Field(ge=0)
|
||||||
|
average_score: float = Field(ge=0, le=100)
|
||||||
|
created_at: datetime
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import json
|
import json
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
|
from .auth import hash_password, hash_token, new_token, verify_password
|
||||||
from .models import SentenceBoundary, SentenceBoundaryDocument
|
from .models import SentenceBoundary, SentenceBoundaryDocument
|
||||||
from .store import normalize_video_hash
|
from .store import normalize_video_hash
|
||||||
|
|
||||||
@@ -70,6 +71,31 @@ class VideoRepository:
|
|||||||
FOREIGN KEY (video_hash) REFERENCES videos(video_hash) ON DELETE CASCADE
|
FOREIGN KEY (video_hash) REFERENCES videos(video_hash) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
username TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
||||||
|
nickname TEXT NOT NULL,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS auth_tokens (
|
||||||
|
token_hash TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
expires_at TEXT NOT NULL,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS enrollments (
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
video_hash TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (user_id, video_hash),
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (video_hash) REFERENCES videos(video_hash) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS dub_shares (
|
CREATE TABLE IF NOT EXISTS dub_shares (
|
||||||
share_id TEXT PRIMARY KEY,
|
share_id TEXT PRIMARY KEY,
|
||||||
video_hash TEXT NOT NULL,
|
video_hash TEXT NOT NULL,
|
||||||
@@ -114,6 +140,24 @@ class VideoRepository:
|
|||||||
"ALTER TABLE dub_share_segments ADD COLUMN score_details TEXT NOT NULL DEFAULT '{}'"
|
"ALTER TABLE dub_share_segments ADD COLUMN score_details TEXT NOT NULL DEFAULT '{}'"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
with self._connect() as connection:
|
||||||
|
attempt_columns = {
|
||||||
|
row["name"]
|
||||||
|
for row in connection.execute("PRAGMA table_info(attempts)")
|
||||||
|
}
|
||||||
|
if "user_id" not in attempt_columns:
|
||||||
|
connection.execute("ALTER TABLE attempts ADD COLUMN user_id TEXT NOT NULL DEFAULT ''")
|
||||||
|
connection.execute("CREATE INDEX IF NOT EXISTS idx_attempts_user ON attempts(user_id)")
|
||||||
|
|
||||||
|
with self._connect() as connection:
|
||||||
|
dub_columns = {
|
||||||
|
row["name"]
|
||||||
|
for row in connection.execute("PRAGMA table_info(dub_shares)")
|
||||||
|
}
|
||||||
|
if "user_id" not in dub_columns:
|
||||||
|
connection.execute("ALTER TABLE dub_shares ADD COLUMN user_id TEXT NOT NULL DEFAULT ''")
|
||||||
|
connection.execute("CREATE INDEX IF NOT EXISTS idx_dub_shares_user ON dub_shares(user_id)")
|
||||||
|
|
||||||
with self._connect() as connection:
|
with self._connect() as connection:
|
||||||
video_columns = {
|
video_columns = {
|
||||||
row["name"]
|
row["name"]
|
||||||
@@ -612,6 +656,7 @@ class VideoRepository:
|
|||||||
video_hash: str,
|
video_hash: str,
|
||||||
title: str,
|
title: str,
|
||||||
segments: List[Dict[str, Any]],
|
segments: List[Dict[str, Any]],
|
||||||
|
user_id: str,
|
||||||
) -> Optional[Dict[str, Any]]:
|
) -> Optional[Dict[str, Any]]:
|
||||||
normalized_hash = normalize_video_hash(video_hash)
|
normalized_hash = normalize_video_hash(video_hash)
|
||||||
if self.get_video(normalized_hash) is None:
|
if self.get_video(normalized_hash) is None:
|
||||||
@@ -621,10 +666,10 @@ class VideoRepository:
|
|||||||
with self._connect() as connection:
|
with self._connect() as connection:
|
||||||
connection.execute(
|
connection.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO dub_shares (share_id, video_hash, title, created_at)
|
INSERT INTO dub_shares (share_id, video_hash, title, user_id, created_at)
|
||||||
VALUES (?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?)
|
||||||
""",
|
""",
|
||||||
(share_id, normalized_hash, title[:200], now),
|
(share_id, normalized_hash, title[:200], user_id, now),
|
||||||
)
|
)
|
||||||
connection.executemany(
|
connection.executemany(
|
||||||
"""
|
"""
|
||||||
@@ -684,14 +729,15 @@ class VideoRepository:
|
|||||||
sentence_index: int,
|
sentence_index: int,
|
||||||
result: Dict[str, Any],
|
result: Dict[str, Any],
|
||||||
audio_filename: Optional[str],
|
audio_filename: Optional[str],
|
||||||
|
user_id: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
with self._connect() as connection:
|
with self._connect() as connection:
|
||||||
connection.execute(
|
connection.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO attempts (
|
INSERT INTO attempts (
|
||||||
attempt_id, video_hash, sentence_index, result_json,
|
attempt_id, video_hash, sentence_index, result_json,
|
||||||
audio_filename, created_at
|
audio_filename, user_id, created_at
|
||||||
) VALUES (?, ?, ?, ?, ?, ?)
|
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
attempt_id,
|
attempt_id,
|
||||||
@@ -699,10 +745,193 @@ class VideoRepository:
|
|||||||
sentence_index,
|
sentence_index,
|
||||||
json.dumps(result, ensure_ascii=False),
|
json.dumps(result, ensure_ascii=False),
|
||||||
audio_filename,
|
audio_filename,
|
||||||
|
user_id,
|
||||||
utc_now(),
|
utc_now(),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def create_user(self, *, username: str, password: str, nickname: Optional[str]) -> Optional[Dict[str, Any]]:
|
||||||
|
user_id = uuid.uuid4().hex
|
||||||
|
now = utc_now()
|
||||||
|
try:
|
||||||
|
with self._connect() as connection:
|
||||||
|
row = connection.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO users (id, username, nickname, password_hash, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
user_id,
|
||||||
|
username.strip().lower(),
|
||||||
|
(nickname or username).strip()[:80],
|
||||||
|
hash_password(password),
|
||||||
|
now,
|
||||||
|
),
|
||||||
|
).fetchone()
|
||||||
|
except sqlite3.IntegrityError:
|
||||||
|
return None
|
||||||
|
return self.get_user_by_id(user_id)
|
||||||
|
|
||||||
|
def get_user_by_username(self, username: str) -> Optional[Dict[str, Any]]:
|
||||||
|
with self._connect() as connection:
|
||||||
|
row = connection.execute(
|
||||||
|
"SELECT * FROM users WHERE username = ? COLLATE NOCASE",
|
||||||
|
(username.strip(),),
|
||||||
|
).fetchone()
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
def get_user_by_id(self, user_id: str) -> Optional[Dict[str, Any]]:
|
||||||
|
with self._connect() as connection:
|
||||||
|
row = connection.execute(
|
||||||
|
"SELECT id, username, nickname, created_at FROM users WHERE id = ?",
|
||||||
|
(user_id,),
|
||||||
|
).fetchone()
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
def authenticate_user(self, username: str, password: str) -> Optional[Dict[str, Any]]:
|
||||||
|
with self._connect() as connection:
|
||||||
|
row = connection.execute(
|
||||||
|
"SELECT * FROM users WHERE username = ? COLLATE NOCASE",
|
||||||
|
(username.strip(),),
|
||||||
|
).fetchone()
|
||||||
|
if row is None or not verify_password(password, row["password_hash"]):
|
||||||
|
return None
|
||||||
|
return self.get_user_by_id(row["id"])
|
||||||
|
|
||||||
|
def create_session(self, user_id: str) -> str:
|
||||||
|
token = new_token()
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
with self._connect() as connection:
|
||||||
|
connection.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO auth_tokens (token_hash, user_id, created_at, expires_at)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
hash_token(token),
|
||||||
|
user_id,
|
||||||
|
now.isoformat(),
|
||||||
|
(now + timedelta(days=30)).isoformat(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return token
|
||||||
|
|
||||||
|
def resolve_session(self, token: str) -> Optional[Dict[str, Any]]:
|
||||||
|
with self._connect() as connection:
|
||||||
|
row = connection.execute(
|
||||||
|
"""
|
||||||
|
SELECT u.id, u.username, u.nickname, u.created_at
|
||||||
|
FROM auth_tokens t JOIN users u ON u.id = t.user_id
|
||||||
|
WHERE t.token_hash = ? AND t.expires_at > ?
|
||||||
|
""",
|
||||||
|
(hash_token(token), datetime.now(timezone.utc).isoformat()),
|
||||||
|
).fetchone()
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
def delete_session(self, token: str) -> None:
|
||||||
|
with self._connect() as connection:
|
||||||
|
connection.execute(
|
||||||
|
"DELETE FROM auth_tokens WHERE token_hash = ?",
|
||||||
|
(hash_token(token),),
|
||||||
|
)
|
||||||
|
|
||||||
|
def list_courses(self, user_id: str) -> List[Dict[str, Any]]:
|
||||||
|
with self._connect() as connection:
|
||||||
|
rows = connection.execute(
|
||||||
|
"""
|
||||||
|
SELECT v.*, COUNT(s.sentence_index) AS sentence_count,
|
||||||
|
CASE WHEN e.user_id IS NULL THEN 0 ELSE 1 END AS enrolled
|
||||||
|
FROM videos v
|
||||||
|
LEFT JOIN sentences s ON s.video_hash = v.video_hash
|
||||||
|
LEFT JOIN enrollments e ON e.video_hash = v.video_hash AND e.user_id = ?
|
||||||
|
WHERE v.status = 'ready'
|
||||||
|
GROUP BY v.video_hash
|
||||||
|
ORDER BY e.created_at DESC, v.created_at DESC
|
||||||
|
""",
|
||||||
|
(user_id,),
|
||||||
|
).fetchall()
|
||||||
|
return [dict(row) for row in rows]
|
||||||
|
|
||||||
|
def enroll_course(self, user_id: str, video_hash: str) -> bool:
|
||||||
|
normalized_hash = normalize_video_hash(video_hash)
|
||||||
|
with self._connect() as connection:
|
||||||
|
video = connection.execute(
|
||||||
|
"SELECT 1 FROM videos WHERE video_hash = ? AND status = 'ready'",
|
||||||
|
(normalized_hash,),
|
||||||
|
).fetchone()
|
||||||
|
if video is None:
|
||||||
|
return False
|
||||||
|
connection.execute(
|
||||||
|
"""
|
||||||
|
INSERT OR IGNORE INTO enrollments (user_id, video_hash, created_at)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
""",
|
||||||
|
(user_id, normalized_hash, utc_now()),
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def unenroll_course(self, user_id: str, video_hash: str) -> bool:
|
||||||
|
normalized_hash = normalize_video_hash(video_hash)
|
||||||
|
with self._connect() as connection:
|
||||||
|
cursor = connection.execute(
|
||||||
|
"DELETE FROM enrollments WHERE user_id = ? AND video_hash = ?",
|
||||||
|
(user_id, normalized_hash),
|
||||||
|
)
|
||||||
|
return cursor.rowcount > 0
|
||||||
|
|
||||||
|
def list_user_results(self, user_id: str) -> List[Dict[str, Any]]:
|
||||||
|
with self._connect() as connection:
|
||||||
|
rows = connection.execute(
|
||||||
|
"""
|
||||||
|
SELECT a.attempt_id, a.video_hash, a.sentence_index, a.result_json, a.created_at,
|
||||||
|
v.title AS course_title, s.text AS sentence_text
|
||||||
|
FROM attempts a
|
||||||
|
JOIN videos v ON v.video_hash = a.video_hash
|
||||||
|
LEFT JOIN sentences s
|
||||||
|
ON s.video_hash = a.video_hash AND s.sentence_index = a.sentence_index
|
||||||
|
WHERE a.user_id = ?
|
||||||
|
ORDER BY a.created_at DESC
|
||||||
|
LIMIT 500
|
||||||
|
""",
|
||||||
|
(user_id,),
|
||||||
|
).fetchall()
|
||||||
|
results = []
|
||||||
|
for row in rows:
|
||||||
|
payload = json.loads(row["result_json"] or "{}")
|
||||||
|
results.append(
|
||||||
|
{
|
||||||
|
"attempt_id": row["attempt_id"],
|
||||||
|
"video_hash": row["video_hash"],
|
||||||
|
"course_title": row["course_title"],
|
||||||
|
"sentence_index": row["sentence_index"],
|
||||||
|
"sentence_text": row["sentence_text"] or "",
|
||||||
|
"overall_score": float(payload.get("overall_score", 0)),
|
||||||
|
"passed": bool(payload.get("passed", False)),
|
||||||
|
"created_at": row["created_at"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return results
|
||||||
|
|
||||||
|
def list_user_dub_shares(self, user_id: str) -> List[Dict[str, Any]]:
|
||||||
|
with self._connect() as connection:
|
||||||
|
rows = connection.execute(
|
||||||
|
"""
|
||||||
|
SELECT d.share_id, d.video_hash, d.title, d.created_at,
|
||||||
|
v.title AS course_title,
|
||||||
|
COUNT(g.id) AS segment_count,
|
||||||
|
COALESCE(AVG(NULLIF(g.overall_score, 0)), 0) AS average_score
|
||||||
|
FROM dub_shares d
|
||||||
|
JOIN videos v ON v.video_hash = d.video_hash
|
||||||
|
LEFT JOIN dub_share_segments g ON g.share_id = d.share_id
|
||||||
|
WHERE d.user_id = ?
|
||||||
|
GROUP BY d.share_id
|
||||||
|
ORDER BY d.created_at DESC
|
||||||
|
LIMIT 500
|
||||||
|
""",
|
||||||
|
(user_id,),
|
||||||
|
).fetchall()
|
||||||
|
return [dict(row) for row in rows]
|
||||||
|
|
||||||
def delete_video(self, video_hash: str) -> Optional[Dict[str, Any]]:
|
def delete_video(self, video_hash: str) -> Optional[Dict[str, Any]]:
|
||||||
video = self.get_video(video_hash)
|
video = self.get_video(video_hash)
|
||||||
if video is None:
|
if video is None:
|
||||||
|
|||||||
@@ -38,13 +38,20 @@ def make_client(tmp_path):
|
|||||||
legacy_boundaries_file=index_path,
|
legacy_boundaries_file=index_path,
|
||||||
)
|
)
|
||||||
settings.ensure_directories()
|
settings.ensure_directories()
|
||||||
return TestClient(
|
client = TestClient(
|
||||||
create_app(
|
create_app(
|
||||||
BoundaryStore(index_path),
|
BoundaryStore(index_path),
|
||||||
settings=settings,
|
settings=settings,
|
||||||
repository=VideoRepository(settings.database_path),
|
repository=VideoRepository(settings.database_path),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1/auth/register",
|
||||||
|
json={"username": "tester", "password": "password-123"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 201, response.text
|
||||||
|
client.headers.update({"Authorization": f"Bearer {response.json()['token']}"})
|
||||||
|
return client
|
||||||
|
|
||||||
|
|
||||||
def test_lookup_returns_boundaries(tmp_path):
|
def test_lookup_returns_boundaries(tmp_path):
|
||||||
|
|||||||
@@ -90,7 +90,14 @@ def make_client(tmp_path, client_api_key="", transcriber=None):
|
|||||||
repository=repository,
|
repository=repository,
|
||||||
transcriber=transcriber or FakeTranscriber(),
|
transcriber=transcriber or FakeTranscriber(),
|
||||||
)
|
)
|
||||||
return TestClient(app)
|
client = TestClient(app)
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1/auth/register",
|
||||||
|
json={"username": "tester", "password": "password-123"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 201, response.text
|
||||||
|
client.headers.update({"Authorization": f"Bearer {response.json()['token']}"})
|
||||||
|
return client
|
||||||
|
|
||||||
|
|
||||||
def test_assessment_returns_duration_and_content_breakdown(tmp_path):
|
def test_assessment_returns_duration_and_content_breakdown(tmp_path):
|
||||||
@@ -202,19 +209,59 @@ def test_create_dub_share_aligns_audio_with_whisper_boundaries(tmp_path):
|
|||||||
assert calls[0].read_bytes() == b"aligned-audio"
|
assert calls[0].read_bytes() == b"aligned-audio"
|
||||||
|
|
||||||
|
|
||||||
def test_assessment_client_key_is_enforced_when_configured(tmp_path):
|
def test_assessment_requires_a_valid_user_session(tmp_path):
|
||||||
client = make_client(tmp_path, client_api_key="tablet-key")
|
client = make_client(tmp_path, client_api_key="tablet-key")
|
||||||
endpoint = f"/api/v1/videos/{VIDEO_HASH}/sentences/0/assessments"
|
endpoint = f"/api/v1/videos/{VIDEO_HASH}/sentences/0/assessments"
|
||||||
|
client.headers.clear()
|
||||||
|
|
||||||
unauthorized = client.post(
|
unauthorized = client.post(
|
||||||
endpoint,
|
endpoint,
|
||||||
files={"audio": ("reading.wav", make_wav(), "audio/wav")},
|
files={"audio": ("reading.wav", make_wav(), "audio/wav")},
|
||||||
)
|
)
|
||||||
|
login = client.post(
|
||||||
|
"/api/v1/auth/login",
|
||||||
|
json={"username": "tester", "password": "password-123"},
|
||||||
|
)
|
||||||
|
assert login.status_code == 200
|
||||||
authorized = client.post(
|
authorized = client.post(
|
||||||
endpoint,
|
endpoint,
|
||||||
headers={"X-Client-Key": "tablet-key"},
|
headers={"Authorization": f"Bearer {login.json()['token']}"},
|
||||||
files={"audio": ("reading.wav", make_wav(), "audio/wav")},
|
files={"audio": ("reading.wav", make_wav(), "audio/wav")},
|
||||||
)
|
)
|
||||||
|
|
||||||
assert unauthorized.status_code == 401
|
assert unauthorized.status_code == 401
|
||||||
assert authorized.status_code == 200
|
assert authorized.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_enrollment_results_and_dub_shares_are_scoped(tmp_path):
|
||||||
|
client = make_client(tmp_path)
|
||||||
|
|
||||||
|
assert client.get("/api/v1/courses").status_code == 200
|
||||||
|
assert client.post(f"/api/v1/courses/{VIDEO_HASH}/enroll").status_code == 204
|
||||||
|
courses = client.get("/api/v1/courses").json()
|
||||||
|
assert courses[0]["enrolled"] is True
|
||||||
|
|
||||||
|
assessed = client.post(
|
||||||
|
f"/api/v1/videos/{VIDEO_HASH}/sentences/0/assessments",
|
||||||
|
files={"audio": ("reading.wav", make_wav(), "audio/wav")},
|
||||||
|
data={"language": "en"},
|
||||||
|
)
|
||||||
|
assert assessed.status_code == 200
|
||||||
|
results = client.get("/api/v1/me/results").json()
|
||||||
|
assert len(results) == 1
|
||||||
|
assert results[0]["course_title"] == "Lesson"
|
||||||
|
|
||||||
|
shared = client.post(
|
||||||
|
"/api/v1/dub-shares",
|
||||||
|
data={
|
||||||
|
"video_hash": VIDEO_HASH,
|
||||||
|
"segments": json.dumps([{"sentence_index": 0}]),
|
||||||
|
"scores": json.dumps([{"sentence_index": 0, "overall_score": 88}]),
|
||||||
|
},
|
||||||
|
files={"files": ("dub.wav", make_wav(), "audio/wav")},
|
||||||
|
)
|
||||||
|
assert shared.status_code == 201, shared.text
|
||||||
|
shares = client.get("/api/v1/me/dub-shares").json()
|
||||||
|
assert len(shares) == 1
|
||||||
|
assert shares[0]["segment_count"] == 1
|
||||||
|
assert shares[0]["average_score"] == 88
|
||||||
|
|||||||
Reference in New Issue
Block a user