diff --git a/android/oral-trainer-sdk/src/main/java/cn/learningpad/oraltrainer/sdk/OralTrainerSdk.kt b/android/oral-trainer-sdk/src/main/java/cn/learningpad/oraltrainer/sdk/OralTrainerSdk.kt index 26e4a95..0fdb65d 100644 --- a/android/oral-trainer-sdk/src/main/java/cn/learningpad/oraltrainer/sdk/OralTrainerSdk.kt +++ b/android/oral-trainer-sdk/src/main/java/cn/learningpad/oraltrainer/sdk/OralTrainerSdk.kt @@ -11,6 +11,7 @@ class OralTrainerSdk private constructor( val cache: OralTrainerCache = OralTrainerCache(appContext, config) val sentenceBoundaryApi: SentenceBoundaryApi = SentenceBoundaryApi(appContext, config) + val userApi: UserApi = UserApi(appContext, config) val videoCatalogApi: VideoCatalogApi = VideoCatalogApi(appContext, config) private val remoteAssessors = CopyOnWriteArrayList() diff --git a/android/oral-trainer-sdk/src/main/java/cn/learningpad/oraltrainer/sdk/OralTrainerSdkConfig.kt b/android/oral-trainer-sdk/src/main/java/cn/learningpad/oraltrainer/sdk/OralTrainerSdkConfig.kt index 71b84c0..3090a69 100644 --- a/android/oral-trainer-sdk/src/main/java/cn/learningpad/oraltrainer/sdk/OralTrainerSdkConfig.kt +++ b/android/oral-trainer-sdk/src/main/java/cn/learningpad/oraltrainer/sdk/OralTrainerSdkConfig.kt @@ -10,5 +10,6 @@ data class OralTrainerSdkConfig @JvmOverloads constructor( val readTimeoutMs: Int = 30_000, val sentenceBoundaryApiBaseUrl: String = "https://videoservice.d1kt.cn", val assessmentApiKey: String? = null, + var userToken: String? = null, val allowHttpFallback: Boolean = false, ) diff --git a/android/oral-trainer-sdk/src/main/java/cn/learningpad/oraltrainer/sdk/RemoteImitationQualityAssessor.kt b/android/oral-trainer-sdk/src/main/java/cn/learningpad/oraltrainer/sdk/RemoteImitationQualityAssessor.kt index 07d4262..4b0fc2f 100644 --- a/android/oral-trainer-sdk/src/main/java/cn/learningpad/oraltrainer/sdk/RemoteImitationQualityAssessor.kt +++ b/android/oral-trainer-sdk/src/main/java/cn/learningpad/oraltrainer/sdk/RemoteImitationQualityAssessor.kt @@ -89,6 +89,9 @@ class RemoteImitationQualityAssessor internal constructor( config.assessmentApiKey?.takeIf { it.isNotBlank() }?.let { connection.setRequestProperty("X-Client-Key", it) } + config.userToken?.takeIf { it.isNotBlank() }?.let { + connection.setRequestProperty("Authorization", "Bearer $it") + } BufferedOutputStream(connection.outputStream).use { output -> writeFormField(output, boundary, "language", request.locale ?: request.sentence.language.orEmpty()) writeFilePart(output, boundary, request.recordingUri) diff --git a/android/oral-trainer-sdk/src/main/java/cn/learningpad/oraltrainer/sdk/SentenceBoundaryApi.kt b/android/oral-trainer-sdk/src/main/java/cn/learningpad/oraltrainer/sdk/SentenceBoundaryApi.kt index 88eb1c9..f7252d0 100644 --- a/android/oral-trainer-sdk/src/main/java/cn/learningpad/oraltrainer/sdk/SentenceBoundaryApi.kt +++ b/android/oral-trainer-sdk/src/main/java/cn/learningpad/oraltrainer/sdk/SentenceBoundaryApi.kt @@ -114,6 +114,9 @@ class SentenceBoundaryApi internal constructor( connection.useCaches = false connection.setRequestProperty("Accept", "application/json") connection.setRequestProperty("User-Agent", config.userAgent) + config.userToken?.takeIf { it.isNotBlank() }?.let { + connection.setRequestProperty("Authorization", "Bearer $it") + } val statusCode = connection.responseCode val stream = if (statusCode in 200..299) { connection.inputStream diff --git a/android/oral-trainer-sdk/src/main/java/cn/learningpad/oraltrainer/sdk/UserApi.kt b/android/oral-trainer-sdk/src/main/java/cn/learningpad/oraltrainer/sdk/UserApi.kt new file mode 100644 index 0000000..d10cc1e --- /dev/null +++ b/android/oral-trainer-sdk/src/main/java/cn/learningpad/oraltrainer/sdk/UserApi.kt @@ -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) -> Unit, + ): CancellableRequest = submit(onComplete) { requestBlocking("auth/register", username, password, auth = false) } + + fun login( + username: String, + password: String, + onComplete: (Result) -> Unit, + ): CancellableRequest = submit(onComplete) { requestBlocking("auth/login", username, password, auth = false) } + + fun courses( + token: String, + onComplete: (Result>) -> Unit, + ): CancellableRequest = submitObject(onComplete, token) { parseCourses(getBlocking(it, "courses")) } + + fun enroll( + token: String, + videoHash: String, + onComplete: (Result) -> Unit, + ): CancellableRequest = submitUnit(onComplete, token) { + request("courses/$videoHash/enroll", token, method = "POST") + } + + fun results( + token: String, + onComplete: (Result>) -> Unit, + ): CancellableRequest = submitObject(onComplete, token) { parseResults(getBlocking(it, "me/results")) } + + fun dubShares( + token: String, + onComplete: (Result>) -> Unit, + ): CancellableRequest = submitObject(onComplete, token) { parseDubShares(getBlocking(it, "me/dub-shares")) } + + fun release() { + executor.shutdownNow() + } + + private fun submit( + onComplete: (Result) -> Unit, + operation: () -> T, + ): CancellableRequest { + val future: Future<*> = executor.submit { + post(onComplete, runCatching(operation)) + } + return CancellableRequest(future) + } + + private fun submitObject( + onComplete: (Result) -> Unit, + token: String, + operation: (String) -> T, + ): CancellableRequest = submit(onComplete) { operation(token) } + + private fun submitUnit( + onComplete: (Result) -> Unit, + token: String, + operation: (String) -> Unit, + ): CancellableRequest = submit(onComplete) { + operation(token) + Unit + } + + private fun post(onComplete: (Result) -> Unit, result: Result) { + 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 = 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 = 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 = 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"), + ), + ) + } +} diff --git a/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/MainActivity.kt b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/MainActivity.kt index 25a90c9..dbc1e6e 100644 --- a/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/MainActivity.kt +++ b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/MainActivity.kt @@ -32,16 +32,19 @@ import android.view.ViewGroup import android.view.animation.Animation import android.view.animation.TranslateAnimation import android.widget.Button +import android.widget.EditText import android.widget.FrameLayout import android.widget.ImageButton import android.widget.ImageView import android.widget.HorizontalScrollView import android.widget.LinearLayout +import android.widget.ScrollView import android.widget.SeekBar import android.widget.Switch import android.widget.TextView import cn.learningpad.oraltrainer.sdk.GestureEvent import cn.learningpad.oraltrainer.sdk.GestureKind +import cn.learningpad.oraltrainer.sdk.AuthSession import cn.learningpad.oraltrainer.sdk.ImitationAssessmentCallback import cn.learningpad.oraltrainer.sdk.ImitationAssessmentResult import cn.learningpad.oraltrainer.sdk.LoopMode @@ -107,8 +110,15 @@ class MainActivity : Activity() { private var activeItemId: String = "" 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 catalogVideos: List = emptyList() + private val courseEnrollment = mutableMapOf() private var catalogStatusTextValue = "正在同步" private var activeModule = Module.TRAIN private var continuousPlaybackEnabled = false @@ -131,11 +141,16 @@ class MainActivity : Activity() { super.onCreate(savedInstanceState) configureWindow() + val authPreferences = getSharedPreferences("oral_trainer_auth", MODE_PRIVATE) + authToken = authPreferences.getString("token", "").orEmpty() + authNickname = authPreferences.getString("nickname", "").orEmpty() + sdk = OralTrainerSdk.init( this, OralTrainerSdkConfig( sentenceBoundaryApiBaseUrl = SERVER_BASE_URL, assessmentApiKey = ASSESSMENT_API_KEY, + userToken = authToken.takeIf { it.isNotBlank() }, allowHttpFallback = true, ) ) @@ -152,9 +167,9 @@ class MainActivity : Activity() { controllerListener = createControllerListener() controller.addListener(controllerListener) - setContentView(createContentView()) + setRootView() loadCatalog() - refreshCurrentUi() + if (isLoggedIn()) refreshCurrentUi() registerReceiver( screenActionReceiver, @@ -164,7 +179,7 @@ class MainActivity : Activity() { override fun onConfigurationChanged(newConfig: Configuration) { super.onConfigurationChanged(newConfig) - setContentView(createContentView()) + setRootView() renderCatalog() refreshCurrentUi() if (activeModule == Module.TEST) { @@ -281,6 +296,22 @@ class MainActivity : Activity() { private fun createContentView(): View { 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) { createLandscapeContentView() } 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 -> + 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 { return LinearLayout(this).apply { orientation = LinearLayout.VERTICAL @@ -322,8 +453,10 @@ class MainActivity : Activity() { return LinearLayout(this).apply { orientation = LinearLayout.HORIZONTAL setPadding(16.dp, 40.dp, 16.dp, 0) + addView(moduleTab("课程", Module.COURSE)) addView(moduleTab("训练", Module.TRAIN)) addView(moduleTab("测试", Module.TEST)) + addView(moduleTab("我的", Module.MINE)) } } @@ -343,10 +476,13 @@ class MainActivity : Activity() { } layoutParams = LinearLayout.LayoutParams(0, 44.dp, 1f).withMargins(0, 0, 8.dp, 0) setOnClickListener { - if (activeModule == module) { - return@setOnClickListener - } - activeModule = module + if (activeModule == module) { + return@setOnClickListener + } + if (module == Module.COURSE || module == Module.MINE) { + loadUserData() + } + activeModule = module controller.setContinuousPlayback( if (activeModule == Module.TRAIN) continuousPlaybackEnabled else false ) @@ -1404,10 +1540,36 @@ class MainActivity : Activity() { } private fun loadCatalog() { + if (!isLoggedIn()) return catalogStatusTextValue = "正在同步" if (::catalogStatusText.isInitialized) { 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 { override fun onSuccess(videos: List) { 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() { if (!::catalogList.isInitialized) { return } catalogList.removeAllViews() catalogVideos.forEach { video -> + val enrolled = courseEnrollment[video.videoHash] == true catalogList.addView( videoCard( id = video.videoHash, 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) { activeItemId = video.videoHash lessonTitleText.text = video.title @@ -1668,8 +1974,10 @@ class MainActivity : Activity() { private val SHA256_PATTERN = Regex("^[0-9a-fA-F]{64}$") enum class Module { + COURSE, TRAIN, TEST, + MINE, } val COLOR_BACKGROUND: Int = Color.rgb(12, 15, 18) diff --git a/sentence_api/assessment.py b/sentence_api/assessment.py index 2bf3f88..75e4563 100644 --- a/sentence_api/assessment.py +++ b/sentence_api/assessment.py @@ -28,6 +28,7 @@ class AssessmentService: audio_path: Path, language: Optional[str] = None, retained_audio_filename: Optional[str] = None, + user_id: str = "", ) -> AssessmentResult: if not self.transcriber.available: raise RuntimeError("MOSS transcription is not configured on this server.") @@ -103,5 +104,6 @@ class AssessmentService: sentence_index=sentence_index, result=result.model_dump(mode="json"), audio_filename=retained_audio_filename, + user_id=user_id, ) return result diff --git a/sentence_api/auth.py b/sentence_api/auth.py new file mode 100644 index 0000000..3b09ba9 --- /dev/null +++ b/sentence_api/auth.py @@ -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() diff --git a/sentence_api/main.py b/sentence_api/main.py index c4bff5f..975ab0c 100644 --- a/sentence_api/main.py +++ b/sentence_api/main.py @@ -28,14 +28,21 @@ from starlette.concurrency import run_in_threadpool from starlette.requests import ClientDisconnect from .assessment import AssessmentService +from .auth import AuthenticatedUser from .audio_metrics import AudioAnalysisError from .config import Settings from .models import ( AssessmentResult, + AuthRequest, + AuthResponse, + CourseSummary, SentenceBoundaryAdjust, SentenceBoundary, SentenceBoundaryDocument, SentenceTextUpdate, + UserDubShareSummary, + UserPublic, + UserResultSummary, VideoDetailResponse, VideoListResponse, VideoSummary, @@ -124,6 +131,20 @@ def create_app( 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.") + 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]: 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", } + @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) def admin_page() -> FileResponse: page = static_dir / "admin.html" @@ -181,11 +282,13 @@ def create_app( @application.get( "/api/v1/videos/{video_hash}/sentence-boundaries", response_model=SentenceBoundaryDocument, + dependencies=[Depends(current_user)], ) @application.get( "/api/v1/sentence-boundaries/{video_hash}", response_model=SentenceBoundaryDocument, include_in_schema=False, + dependencies=[Depends(current_user)], ) def get_sentence_boundaries(video_hash: str = SHA256_PATH) -> SentenceBoundaryDocument: document = find_document(video_hash) @@ -401,13 +504,13 @@ def create_app( @application.post( "/api/v1/videos/{video_hash}/sentences/{sentence_index}/assessments", response_model=AssessmentResult, - dependencies=[Depends(require_client)], ) async def assess_sentence( video_hash: str = SHA256_PATH, sentence_index: int = ApiPath(ge=0), audio: UploadFile = File(...), language: Optional[str] = Form(default=None), + user: AuthenticatedUser = Depends(current_user), ) -> AssessmentResult: document = find_document(video_hash) if document is None: @@ -433,6 +536,7 @@ def create_app( audio_path=temporary_path, language=language, retained_audio_filename=temporary_name if service_settings.keep_attempt_audio else None, + user_id=user.id, ) completed = True return result @@ -457,6 +561,7 @@ def create_app( segments: str = Form(...), scores: str = Form(default="[]"), files: List[UploadFile] = File(...), + user: AuthenticatedUser = Depends(current_user), ) -> Dict[str, Any]: try: segment_items = json.loads(segments) @@ -526,6 +631,7 @@ def create_app( video_hash=video_hash.lower(), title=title, segments=prepared_segments, + user_id=user.id, ) if share is None: raise HTTPException(status_code=404, detail="Video was not found.") diff --git a/sentence_api/models.py b/sentence_api/models.py index e27a670..808fe01 100644 --- a/sentence_api/models.py +++ b/sentence_api/models.py @@ -131,3 +131,53 @@ class AssessmentResult(BaseModel): substitutions: List[TextSubstitution] feedback: str 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 diff --git a/sentence_api/repository.py b/sentence_api/repository.py index b9565e4..8480c15 100644 --- a/sentence_api/repository.py +++ b/sentence_api/repository.py @@ -1,10 +1,11 @@ import json import sqlite3 -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from pathlib import Path from typing import Any, Dict, List, Optional import uuid +from .auth import hash_password, hash_token, new_token, verify_password from .models import SentenceBoundary, SentenceBoundaryDocument from .store import normalize_video_hash @@ -70,6 +71,31 @@ class VideoRepository: 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 ( share_id TEXT PRIMARY KEY, video_hash TEXT NOT NULL, @@ -114,6 +140,24 @@ class VideoRepository: "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: video_columns = { row["name"] @@ -612,6 +656,7 @@ class VideoRepository: video_hash: str, title: str, segments: List[Dict[str, Any]], + user_id: str, ) -> Optional[Dict[str, Any]]: normalized_hash = normalize_video_hash(video_hash) if self.get_video(normalized_hash) is None: @@ -621,10 +666,10 @@ class VideoRepository: with self._connect() as connection: connection.execute( """ - INSERT INTO dub_shares (share_id, video_hash, title, created_at) - VALUES (?, ?, ?, ?) + INSERT INTO dub_shares (share_id, video_hash, title, user_id, created_at) + VALUES (?, ?, ?, ?, ?) """, - (share_id, normalized_hash, title[:200], now), + (share_id, normalized_hash, title[:200], user_id, now), ) connection.executemany( """ @@ -684,14 +729,15 @@ class VideoRepository: sentence_index: int, result: Dict[str, Any], audio_filename: Optional[str], + user_id: str, ) -> None: with self._connect() as connection: connection.execute( """ INSERT INTO attempts ( attempt_id, video_hash, sentence_index, result_json, - audio_filename, created_at - ) VALUES (?, ?, ?, ?, ?, ?) + audio_filename, user_id, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?) """, ( attempt_id, @@ -699,10 +745,193 @@ class VideoRepository: sentence_index, json.dumps(result, ensure_ascii=False), audio_filename, + user_id, 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]]: video = self.get_video(video_hash) if video is None: diff --git a/sentence_api/tests/test_api.py b/sentence_api/tests/test_api.py index bde09f7..066d62d 100644 --- a/sentence_api/tests/test_api.py +++ b/sentence_api/tests/test_api.py @@ -38,13 +38,20 @@ def make_client(tmp_path): legacy_boundaries_file=index_path, ) settings.ensure_directories() - return TestClient( + client = TestClient( create_app( BoundaryStore(index_path), settings=settings, 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): diff --git a/sentence_api/tests/test_assessment_api.py b/sentence_api/tests/test_assessment_api.py index 1d520ea..662b971 100644 --- a/sentence_api/tests/test_assessment_api.py +++ b/sentence_api/tests/test_assessment_api.py @@ -90,7 +90,14 @@ def make_client(tmp_path, client_api_key="", transcriber=None): repository=repository, 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): @@ -202,19 +209,59 @@ def test_create_dub_share_aligns_audio_with_whisper_boundaries(tmp_path): 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") endpoint = f"/api/v1/videos/{VIDEO_HASH}/sentences/0/assessments" + client.headers.clear() unauthorized = client.post( endpoint, 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( endpoint, - headers={"X-Client-Key": "tablet-key"}, + headers={"Authorization": f"Bearer {login.json()['token']}"}, files={"audio": ("reading.wav", make_wav(), "audio/wav")}, ) assert unauthorized.status_code == 401 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