commit ac962a91fed7143223b01f08bbf2c1f1db3751e4 Author: dsp Date: Thu Sep 10 20:42:49 2026 +0800 DSP 双端短视频平台:Android + Web(Django/DRF + Vue3 + Channels) - 后端 12 模块:用户/视频/评论/消息/搜索/历史/访客/日志/音乐/通知/审核/创作者 - JWT 双 token + /accounts/refresh 无感续期(双端共用) - Web 端 Vue3+TS+Vite:沉浸 feed/评论/搜索/私信 WS/上传/通知中心/个人主页 - Android Kotlin+Compose:平台标识头 + 401 单飞刷新 - 部署:docker compose(nginx 网关 SPA+API+WS+媒体),已上线 192.168.5.7:19000 - 测试:pytest 19 用例(含 WS 回归)+ Playwright 冒烟脚本 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..a0b1331 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,40 @@ +name: CI + +on: + push: + pull_request: + +jobs: + backend: + runs-on: ubuntu-latest + defaults: + run: + working-directory: dsp/backend + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Install deps + run: | + pip install "Django>=5.2,<5.3" "djangorestframework>=3.15,<3.16" "adrf>=0.1.9" \ + "psycopg[binary]>=3.2,<4.0" "redis>=5.0,<6.0" "channels>=4.1,<5.0" "channels-redis>=4.2" \ + "daphne>=4.1" "django-cors-headers>=4.4" "django-redis>=5.4" "Pillow>=10.4" \ + "python-decouple>=3.8" "argon2-cffi>=23.1" "PyJWT>=2.9" \ + "pytest" "pytest-asyncio" "pytest-django" "httpx" + - name: Tests (DSP_TEST=1, zero external deps) + run: DSP_TEST=1 python -m pytest tests/ -q + + web: + runs-on: ubuntu-latest + defaults: + run: + working-directory: dsp/web + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + - run: npm ci + - run: npx vue-tsc -b + - run: npm run build diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..28f4470 --- /dev/null +++ b/.gitignore @@ -0,0 +1,40 @@ +# ---- 依赖与构建产物 ---- +node_modules/ +dist/ +.vite/ +build/ +.gradle/ +.gradle-dist/ +app/build/ + +# ---- Python ---- +.venv/ +__pycache__/ +*.pyc +.pytest_cache/ +test_db.sqlite3 + +# ---- 运行时数据 ---- +backend/media/ +*.log + +# ---- 本地环境 ---- +.env +local.properties + +# ---- IDE / 工具 ---- +.idea/ +.vscode/ +.kotlin/ +.playwright-mcp/ +*.tar.gz + +# ---- Android ---- +android/.gradle/ +android/build/ +android/app/build/ +android/.kotlin/ +android/app/release/ +*.apk +*.aab +dsp/.test/ diff --git a/dsp/android/app/build.gradle.kts b/dsp/android/app/build.gradle.kts new file mode 100644 index 0000000..39a3aa1 --- /dev/null +++ b/dsp/android/app/build.gradle.kts @@ -0,0 +1,91 @@ +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") + id("org.jetbrains.kotlin.plugin.compose") + id("org.jetbrains.kotlin.plugin.serialization") +} + +android { + namespace = "com.dsp.app" + compileSdk = 34 + + defaultConfig { + applicationId = "com.dsp.app" + minSdk = 24 + targetSdk = 34 + versionCode = 1 + versionName = "1.0.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + vectorDrawables { + useSupportLibrary = true + } + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + kotlinOptions { + jvmTarget = "17" + freeCompilerArgs += listOf( + "-opt-in=androidx.compose.foundation.ExperimentalFoundationApi", + "-opt-in=androidx.compose.foundation.layout.ExperimentalLayoutApi" + ) + } + buildFeatures { + compose = true + } + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + } +} + +dependencies { + // Core & Lifecycle + implementation("androidx.core:core-ktx:1.13.1") + implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.4") + implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.4") + implementation("androidx.activity:activity-compose:1.9.1") + + // Jetpack Compose + implementation(platform("androidx.compose:compose-bom:2024.08.00")) + implementation("androidx.compose.ui:ui") + implementation("androidx.compose.ui:ui-graphics") + implementation("androidx.compose.ui:ui-tooling-preview") + implementation("androidx.compose.material3:material3") + implementation("androidx.compose.material:material-icons-extended") + implementation("androidx.navigation:navigation-compose:2.7.7") + + // Media3 (ExoPlayer) + implementation("androidx.media3:media3-exoplayer:1.4.0") + implementation("androidx.media3:media3-ui:1.4.0") + implementation("androidx.media3:media3-common:1.4.0") + + // Image Loading + implementation("io.coil-kt:coil-compose:2.7.0") + + // Network & Serialization + implementation("com.squareup.retrofit2:retrofit:2.11.0") + implementation("com.squareup.okhttp3:okhttp:4.12.0") + implementation("com.squareup.okhttp3:logging-interceptor:4.12.0") + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.1") + implementation("com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter:1.0.0") + + // Local Storage + implementation("androidx.datastore:datastore-preferences:1.1.1") + + // Coroutines + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1") +} diff --git a/dsp/android/app/src/main/AndroidManifest.xml b/dsp/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..cdba9f8 --- /dev/null +++ b/dsp/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + diff --git a/dsp/android/app/src/main/java/com/dsp/app/DspApp.kt b/dsp/android/app/src/main/java/com/dsp/app/DspApp.kt new file mode 100644 index 0000000..0870c89 --- /dev/null +++ b/dsp/android/app/src/main/java/com/dsp/app/DspApp.kt @@ -0,0 +1,12 @@ +package com.dsp.app + +import android.app.Application +import com.dsp.app.data.log.ClientLogReporter + +class DspApp : Application() { + override fun onCreate() { + super.onCreate() + // 全局未捕获异常日志捕获与上报(日志系统) + ClientLogReporter.initCrashHandler(this) + } +} diff --git a/dsp/android/app/src/main/java/com/dsp/app/MainActivity.kt b/dsp/android/app/src/main/java/com/dsp/app/MainActivity.kt new file mode 100644 index 0000000..3488eed --- /dev/null +++ b/dsp/android/app/src/main/java/com/dsp/app/MainActivity.kt @@ -0,0 +1,329 @@ +package com.dsp.app + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.navigation.NavType +import androidx.navigation.compose.* +import androidx.navigation.navArgument +import com.dsp.app.data.api.ApiClient +import com.dsp.app.data.local.TokenManager +import com.dsp.app.ui.auth.LoginScreen +import com.dsp.app.ui.auth.RegisterScreen +import com.dsp.app.ui.components.DspBottomNavBar +import com.dsp.app.ui.creator.CreatorStudioScreen +import com.dsp.app.ui.feed.FeedScreen +import com.dsp.app.ui.history.WatchHistoryScreen +import com.dsp.app.ui.message.ChatDetailScreen +import com.dsp.app.ui.message.MessageScreen +import com.dsp.app.ui.message.SystemMessageScreen +import com.dsp.app.ui.music.MusicDetailScreen +import com.dsp.app.ui.navigation.NavRoutes +import com.dsp.app.ui.notifications.NotificationListScreen +import com.dsp.app.ui.profile.EditProfileScreen +import com.dsp.app.ui.profile.ProfileScreen +import com.dsp.app.ui.search.SearchScreen +import com.dsp.app.ui.theme.DspTheme +import com.dsp.app.ui.upload.VideoUploadScreen +import com.dsp.app.ui.visitor.VisitorScreen +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch + +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + DspTheme { + MainAppContent() + } + } + } +} + +@Composable +fun MainAppContent() { + val context = LocalContext.current + val tokenManager = remember { TokenManager(context) } + val api = remember { ApiClient.getService(context) } + val scope = rememberCoroutineScope() + val navController = rememberNavController() + + val navBackStackEntry by navController.currentBackStackEntryAsState() + val currentRoute = navBackStackEntry?.destination?.route + + val isLoggedIn = remember { tokenManager.isLoggedIn() } + val startDestination = if (isLoggedIn) NavRoutes.Feed.route else NavRoutes.Login.route + + var unreadCount by remember { mutableIntStateOf(0) } + + // Poll unread message count + LaunchedEffect(isLoggedIn) { + if (isLoggedIn) { + while (isActive) { + try { + val resp = api.getUnreadCount() + if (resp.isSuccessful) { + val body = resp.body() + if (body != null) { + unreadCount = body.systemUnread + body.dmUnread + } + } + } catch (e: Exception) { + // ignore + } + delay(10000) + } + } + } + + val showBottomBar = currentRoute in listOf( + NavRoutes.Feed.route, + NavRoutes.Search.route, + NavRoutes.Messages.route, + NavRoutes.Profile.route + ) + + Scaffold( + modifier = Modifier.fillMaxSize(), + bottomBar = { + if (showBottomBar) { + DspBottomNavBar( + currentRoute = currentRoute, + unreadCount = unreadCount, + onNavigate = { route -> + navController.navigate(route) { + popUpTo(NavRoutes.Feed.route) { saveState = true } + launchSingleTop = true + restoreState = true + } + }, + onUploadClick = { + navController.navigate(NavRoutes.VideoUpload.createRoute()) + } + ) + } + } + ) { innerPadding -> + NavHost( + navController = navController, + startDestination = startDestination, + modifier = Modifier.padding(innerPadding) + ) { + composable(NavRoutes.Login.route) { + LoginScreen( + onLoginSuccess = { + navController.navigate(NavRoutes.Feed.route) { + popUpTo(NavRoutes.Login.route) { inclusive = true } + } + }, + onNavigateToRegister = { + navController.navigate(NavRoutes.Register.route) + } + ) + } + + composable(NavRoutes.Register.route) { + RegisterScreen( + onRegisterSuccess = { + navController.navigate(NavRoutes.Feed.route) { + popUpTo(NavRoutes.Login.route) { inclusive = true } + } + }, + onNavigateToLogin = { + navController.popBackStack() + } + ) + } + + composable(NavRoutes.Feed.route) { + FeedScreen( + onNavigateToProfile = { userId -> + navController.navigate(NavRoutes.UserProfile.createRoute(userId)) + }, + onNavigateToSearch = { + navController.navigate(NavRoutes.Search.route) + }, + onNavigateToMusic = { musicId -> + navController.navigate(NavRoutes.MusicDetail.createRoute(musicId)) + } + ) + } + + composable(NavRoutes.Search.route) { + SearchScreen( + onBack = { navController.popBackStack() }, + onNavigateToUserProfile = { userId -> + navController.navigate(NavRoutes.UserProfile.createRoute(userId)) + } + ) + } + + composable(NavRoutes.Messages.route) { + MessageScreen( + onNavigateToChat = { convId, peerId, peerName -> + navController.navigate(NavRoutes.ChatDetail.createRoute(convId, peerId, peerName)) + }, + onNavigateToSystemMessages = { + navController.navigate(NavRoutes.SystemMessages.route) + }, + onNavigateToNotifications = { type -> + navController.navigate(NavRoutes.Notifications.createRoute(type)) + } + ) + } + + composable( + route = NavRoutes.Notifications.route, + arguments = listOf(navArgument("type") { type = NavType.StringType }) + ) { backStackEntry -> + val type = backStackEntry.arguments?.getString("type") ?: "all" + NotificationListScreen( + initialType = if (type == "all") null else type, + onBack = { navController.popBackStack() }, + onUserClick = { userId -> + navController.navigate(NavRoutes.UserProfile.createRoute(userId)) + }, + onVideoClick = { } + ) + } + + composable( + route = NavRoutes.MusicDetail.route, + arguments = listOf(navArgument("musicId") { type = NavType.LongType }) + ) { backStackEntry -> + val musicId = backStackEntry.arguments?.getLong("musicId") ?: 0L + MusicDetailScreen( + musicId = musicId, + onBack = { navController.popBackStack() }, + onShootSameSoundtrack = { mId, mTitle -> + navController.navigate(NavRoutes.VideoUpload.createRoute(musicId = mId, musicTitle = mTitle)) + }, + onVideoClick = { } + ) + } + + composable( + route = NavRoutes.ChatDetail.route, + arguments = listOf( + navArgument("convId") { type = NavType.LongType }, + navArgument("peerId") { type = NavType.LongType }, + navArgument("peerName") { type = NavType.StringType } + ) + ) { backStackEntry -> + val convId = backStackEntry.arguments?.getLong("convId") ?: 0L + val peerId = backStackEntry.arguments?.getLong("peerId") ?: 0L + val peerName = backStackEntry.arguments?.getString("peerName") ?: "Chat" + ChatDetailScreen( + convId = convId, + peerId = peerId, + peerName = peerName, + onBack = { navController.popBackStack() } + ) + } + + composable(NavRoutes.SystemMessages.route) { + SystemMessageScreen(onBack = { navController.popBackStack() }) + } + + composable(NavRoutes.Profile.route) { + ProfileScreen( + targetUserId = null, + onNavigateToEditProfile = { navController.navigate(NavRoutes.EditProfile.route) }, + onNavigateToWatchHistory = { navController.navigate(NavRoutes.WatchHistory.route) }, + onNavigateToVisitors = { navController.navigate(NavRoutes.Visitors.route) }, + onNavigateToCreatorStudio = { navController.navigate(NavRoutes.CreatorStudio.route) }, + onNavigateToChat = { convId, peerId, peerName -> + navController.navigate(NavRoutes.ChatDetail.createRoute(convId, peerId, peerName)) + }, + onLogout = { + tokenManager.clear() + navController.navigate(NavRoutes.Login.route) { + popUpTo(0) { inclusive = true } + } + } + ) + } + + composable( + route = NavRoutes.UserProfile.route, + arguments = listOf(navArgument("userId") { type = NavType.LongType }) + ) { backStackEntry -> + val userId = backStackEntry.arguments?.getLong("userId") ?: 0L + ProfileScreen( + targetUserId = userId, + onNavigateToEditProfile = {}, + onNavigateToWatchHistory = {}, + onNavigateToVisitors = {}, + onNavigateToCreatorStudio = {}, + onNavigateToChat = { convId, peerId, peerName -> + navController.navigate(NavRoutes.ChatDetail.createRoute(convId, peerId, peerName)) + }, + onLogout = {} + ) + } + + composable(NavRoutes.EditProfile.route) { + EditProfileScreen(onBack = { navController.popBackStack() }) + } + + composable(NavRoutes.WatchHistory.route) { + WatchHistoryScreen( + onBack = { navController.popBackStack() }, + onVideoClick = { } + ) + } + + composable(NavRoutes.Visitors.route) { + VisitorScreen( + onBack = { navController.popBackStack() }, + onUserClick = { userId -> + navController.navigate(NavRoutes.UserProfile.createRoute(userId)) + } + ) + } + + composable( + route = NavRoutes.VideoUpload.route, + arguments = listOf( + navArgument("musicId") { + type = NavType.StringType + nullable = true + defaultValue = null + }, + navArgument("musicTitle") { + type = NavType.StringType + nullable = true + defaultValue = null + } + ) + ) { backStackEntry -> + val musicIdStr = backStackEntry.arguments?.getString("musicId") + val musicTitle = backStackEntry.arguments?.getString("musicTitle") + VideoUploadScreen( + musicId = musicIdStr?.toLongOrNull(), + musicTitle = musicTitle, + onBack = { navController.popBackStack() }, + onUploadSuccess = { + navController.navigate(NavRoutes.Feed.route) { + popUpTo(NavRoutes.Feed.route) { inclusive = true } + } + } + ) + } + + composable(NavRoutes.CreatorStudio.route) { + CreatorStudioScreen( + onBack = { navController.popBackStack() }, + onVideoClick = { } + ) + } + } + } +} diff --git a/dsp/android/app/src/main/java/com/dsp/app/data/api/ApiClient.kt b/dsp/android/app/src/main/java/com/dsp/app/data/api/ApiClient.kt new file mode 100644 index 0000000..fcc7e03 --- /dev/null +++ b/dsp/android/app/src/main/java/com/dsp/app/data/api/ApiClient.kt @@ -0,0 +1,135 @@ +package com.dsp.app.data.api + +import android.content.Context +import com.dsp.app.data.local.TokenManager +import com.dsp.app.data.model.AuthResponse +import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory +import kotlinx.serialization.json.Json +import okhttp3.Authenticator +import okhttp3.Interceptor +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.Response +import okhttp3.Route +import okhttp3.logging.HttpLoggingInterceptor +import retrofit2.Retrofit +import java.util.concurrent.TimeUnit + +const val CLIENT_PLATFORM = "android" +const val CLIENT_VERSION = "1.0.0" + +class AuthInterceptor(private val tokenManager: TokenManager) : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + val requestBuilder = chain.request().newBuilder() + val token = tokenManager.getAccessToken() + if (!token.isNullOrEmpty()) { + requestBuilder.addHeader("Authorization", "Bearer $token") + } + requestBuilder.addHeader("Accept", "application/json") + // 双端平台标识,后端按此写入 logs_request_log.platform + requestBuilder.addHeader("X-Client-Platform", CLIENT_PLATFORM) + requestBuilder.addHeader("X-Client-Version", CLIENT_VERSION) + return chain.proceed(requestBuilder.build()) + } +} + +/** + * 401 时用 refresh token 无感续期(与 Web 端单飞刷新对齐): + * refresh 失败(过期/无效)返回 null 停止重试,由上层走重新登录。 + */ +class RefreshAuthenticator(private val tokenManager: TokenManager) : Authenticator { + private val refreshClient = OkHttpClient.Builder() + .connectTimeout(15, TimeUnit.SECONDS) + .readTimeout(20, TimeUnit.SECONDS) + .build() + private val json = Json { ignoreUnknownKeys = true; coerceInputValues = true } + + override fun authenticate(route: Route?, response: Response): Request? { + if (responseCount(response) >= 2) return null + val refresh = tokenManager.getRefreshToken() ?: return null + + val body = """{"refresh":"$refresh"}""" + .toRequestBody("application/json".toMediaType()) + val request = Request.Builder() + .url(ApiClient.BASE_URL + "api/v1/accounts/refresh") + .post(body) + .header("X-Client-Platform", CLIENT_PLATFORM) + .header("X-Client-Version", CLIENT_VERSION) + .build() + + val parsed = try { + refreshClient.newCall(request).execute().use { resp -> + if (!resp.isSuccessful) return null + json.decodeFromString(resp.body!!.string()) + } + } catch (e: Exception) { + return null + } + tokenManager.saveTokens(parsed.access, parsed.refresh) + return response.request.newBuilder() + .header("Authorization", "Bearer ${parsed.access}") + .build() + } + + private fun responseCount(response: Response): Int { + var count = 1 + var prior = response.priorResponse + while (prior != null) { + count++ + prior = prior.priorResponse + } + return count + } +} + +object ApiClient { + // 默认指向 LAN 部署的 nginx 网关(Retrofit 路径自带 api/v1 前缀,基址为站点根) + @Volatile + var BASE_URL = "http://192.168.5.7:19000/" + + private val json = Json { + ignoreUnknownKeys = true + coerceInputValues = true + encodeDefaults = true + } + + private var retrofit: Retrofit? = null + private var service: DspApiService? = null + + fun getService(context: Context): DspApiService { + return service ?: synchronized(this) { + val tokenManager = TokenManager(context.applicationContext) + val logging = HttpLoggingInterceptor().apply { + level = HttpLoggingInterceptor.Level.BODY + } + + val okHttpClient = OkHttpClient.Builder() + .connectTimeout(15, TimeUnit.SECONDS) + .readTimeout(20, TimeUnit.SECONDS) + .writeTimeout(30, TimeUnit.SECONDS) + .addInterceptor(AuthInterceptor(tokenManager)) + .authenticator(RefreshAuthenticator(tokenManager)) + .addInterceptor(logging) + .build() + + val newRetrofit = Retrofit.Builder() + .baseUrl(BASE_URL) + .client(okHttpClient) + .addConverterFactory(json.asConverterFactory("application/json".toMediaType())) + .build() + + retrofit = newRetrofit + val newService = newRetrofit.create(DspApiService::class.java) + service = newService + newService + } + } + + fun updateBaseUrl(newUrl: String) { + BASE_URL = if (newUrl.endsWith("/")) newUrl else "$newUrl/" + service = null + retrofit = null + } +} diff --git a/dsp/android/app/src/main/java/com/dsp/app/data/api/DspApiService.kt b/dsp/android/app/src/main/java/com/dsp/app/data/api/DspApiService.kt new file mode 100644 index 0000000..01b9bee --- /dev/null +++ b/dsp/android/app/src/main/java/com/dsp/app/data/api/DspApiService.kt @@ -0,0 +1,216 @@ +package com.dsp.app.data.api + +import com.dsp.app.data.model.* +import okhttp3.MultipartBody +import okhttp3.RequestBody +import retrofit2.Response +import retrofit2.http.* + +interface DspApiService { + + // ==================== 1. 用户系统 ==================== + @POST("api/v1/accounts/register") + suspend fun register(@Body request: RegisterRequest): Response + + @POST("api/v1/accounts/login") + suspend fun login(@Body request: LoginRequest): Response + + @GET("api/v1/accounts/me") + suspend fun getMe(): Response + + @PATCH("api/v1/accounts/me") + suspend fun updateProfile(@Body request: ProfileUpdateRequest): Response + + @GET("api/v1/accounts/{user_id}") + suspend fun getUserDetail(@Path("user_id") userId: Long): Response + + @POST("api/v1/accounts/{user_id}/follow") + suspend fun followUser(@Path("user_id") userId: Long): Response + + @DELETE("api/v1/accounts/{user_id}/follow") + suspend fun unfollowUser(@Path("user_id") userId: Long): Response + + // ==================== 模块 1: 音乐/原声系统 ==================== + @GET("api/v1/music/") + suspend fun getMusics( + @Query("q") query: String? = null, + @Query("page_size") pageSize: Int = 20 + ): Response + + @GET("api/v1/music/{music_id}") + suspend fun getMusicDetail(@Path("music_id") musicId: Long): Response + + @GET("api/v1/music/{music_id}/videos") + suspend fun getMusicVideos( + @Path("music_id") musicId: Long, + @Query("cursor") cursor: String? = null, + @Query("page_size") pageSize: Int = 20 + ): Response + + // ==================== 2. 短视频系统 ==================== + @GET("api/v1/videos/feed") + suspend fun getFeed( + @Query("cursor") cursor: String? = null, + @Query("page_size") pageSize: Int = 20 + ): Response + + @Multipart + @POST("api/v1/videos/") + suspend fun uploadVideo( + @Part("title") title: RequestBody, + @Part("description") description: RequestBody?, + @Part videoFile: MultipartBody.Part, + @Part coverImage: MultipartBody.Part? = null, + @Part("music_id") musicId: RequestBody? = null + ): Response