added a user system

This commit is contained in:
2026-08-30 17:07:55 +08:00
parent 0b85a2ed57
commit 637101bcdb
13 changed files with 1082 additions and 20 deletions

View File

@@ -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<RemoteImitationQualityAssessor>()

View File

@@ -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,
)

View File

@@ -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)

View File

@@ -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

View File

@@ -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"),
),
)
}
}

View File

@@ -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<TrainingVideoSummary> = emptyList()
private val courseEnrollment = mutableMapOf<String, Boolean>()
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<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 {
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))
}
}
@@ -346,6 +479,9 @@ class MainActivity : Activity() {
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<TrainingVideoSummary>) {
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)

View File

@@ -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

49
sentence_api/auth.py Normal file
View 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()

View File

@@ -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.")

View File

@@ -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

View File

@@ -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:

View File

@@ -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):

View File

@@ -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