use string tag in domain layer

This commit is contained in:
2026-06-21 21:38:56 +03:00
parent 21a768edd5
commit b66fe60e48
25 changed files with 195 additions and 169 deletions
@@ -1,11 +1,13 @@
package ru.shadowsparky.vbox.backend.data package ru.shadowsparky.vbox.backend.data
import app.cash.sqldelight.coroutines.asFlow
import app.cash.sqldelight.coroutines.mapToList
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import ru.shadowsparky.backend.data.Logger import ru.shadowsparky.backend.data.Logger
import ru.shadowsparky.vbox.backend.AppDatabase import ru.shadowsparky.vbox.backend.AppDatabase
import ru.shadowsparky.vbox.server.Saved_movie
import ru.shadowsparky.vbox.shared.di.factory.DispatcherProvider import ru.shadowsparky.vbox.shared.di.factory.DispatcherProvider
import ru.shadowsparky.vbox.shared.domain.RemoteEvent import ru.shadowsparky.vbox.shared.domain.RemoteEvent
import ru.shadowsparky.vbox.shared.domain.RemoteEventHandler import ru.shadowsparky.vbox.shared.domain.RemoteEventHandler
@@ -23,26 +25,22 @@ class BackendSavedMovieRepository(
) : SavedMovieRepository { ) : SavedMovieRepository {
override fun getAll(): Flow<List<VideoDetails>> { override fun getAll(): Flow<List<VideoDetails>> {
val request = db.saved_movieQueries.selectByUserId(userId) return db.saved_movieQueries
return flow { .selectDetailsByUserId(userId)
emit(request.executeAsList().parse().reversed()) .asFlow()
} .mapToList(dispatcherProvider.io)
} .map { list ->
list.map { movie ->
private suspend fun List<Saved_movie>.parse(): List<VideoDetails> =
withContext(dispatcherProvider.io) {
mapNotNull {
val movie = db.movieQueries.selectMovie(it.movie_id)
.executeAsOneOrNull() ?: return@mapNotNull null
VideoDetails( VideoDetails(
movie.movie_id, id = movie.movie_id,
movie.poster_url, poster = movie.poster_url,
movie.description, desc = movie.description,
movie.title, title = movie.title,
(movie.flags and SERIAL_FLAG) != 0 isSerial = (movie.flags and SERIAL_FLAG) != 0
) )
} }
} }
}
override suspend fun save(details: VideoDetails) { override suspend fun save(details: VideoDetails) {
logger.debug(TAG, "save(${details.id}) ${details.title}") logger.debug(TAG, "save(${details.id}) ${details.title}")
@@ -3,10 +3,14 @@ package ru.shadowsparky.vbox.backend.data.tags
import app.cash.sqldelight.coroutines.asFlow import app.cash.sqldelight.coroutines.asFlow
import app.cash.sqldelight.coroutines.mapToList import app.cash.sqldelight.coroutines.mapToList
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
import org.koin.core.annotation.Factory import org.koin.core.annotation.Factory
import org.koin.core.annotation.Named import org.koin.core.annotation.Named
import ru.shadowsparky.domain.DispatcherProvider import ru.shadowsparky.domain.DispatcherProvider
import ru.shadowsparky.http.domain.BadRequestException
import ru.shadowsparky.vbox.backend.AppDatabase import ru.shadowsparky.vbox.backend.AppDatabase
import ru.shadowsparky.vbox.shared.domain.EventType import ru.shadowsparky.vbox.shared.domain.EventType
import ru.shadowsparky.vbox.shared.domain.RemoteEvent import ru.shadowsparky.vbox.shared.domain.RemoteEvent
@@ -46,19 +50,30 @@ class BackendMovieTagRepository(
list.map { row -> TagInfo(id = row.id, tag = row.tag) } list.map { row -> TagInfo(id = row.id, tag = row.tag) }
} }
override fun queryMovies(tagId: Long): Flow<Set<Long>> = dbQueries override fun queryMovies(tag: String): Flow<Set<Long>> {
.getMoviesByTagId(tagId, userId) return flow {
val id = findTagId(tag)
emitAll(
dbQueries.getMoviesByTagId(id, userId)
.asFlow() .asFlow()
.mapToList(dispatcherProvider.io) .mapToList(dispatcherProvider.io)
.map { list -> list.toSet() } .map { list -> list.toSet() }
)
}
}
override suspend fun link(movieId: Long, tagId: Long) { override suspend fun link(movieId: Long, tag: String) {
dbQueries.linkMovieTag(movieId, tagId, userId).await() dbQueries.linkMovieTag(movieId, findTagId(tag), userId).await()
movieEventHandler.notify(RemoteEvent.OnMovieTag(userId, movieId)) movieEventHandler.notify(RemoteEvent.OnMovieTag(userId, movieId))
} }
override suspend fun unlink(movieId: Long, tagId: Long) { override suspend fun unlink(movieId: Long, tag: String) {
dbQueries.unlinkMovieTag(movieId, tagId, userId).await() dbQueries.unlinkMovieTag(movieId, findTagId(tag), userId).await()
movieEventHandler.notify(RemoteEvent.OnMovieTag(userId, movieId)) movieEventHandler.notify(RemoteEvent.OnMovieTag(userId, movieId))
} }
private suspend fun findTagId(tag: String): Long = withContext(dispatcherProvider.io) {
db.tagsQueries.findTagId(tag, userId).executeAsOneOrNull()
?: throw BadRequestException("tag $tag not found for user with id $userId")
}
} }
@@ -4,7 +4,6 @@ import app.cash.sqldelight.coroutines.asFlow
import app.cash.sqldelight.coroutines.mapToList import app.cash.sqldelight.coroutines.mapToList
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
import org.koin.core.annotation.Factory import org.koin.core.annotation.Factory
import org.koin.core.annotation.Named import org.koin.core.annotation.Named
import ru.shadowsparky.vbox.backend.AppDatabase import ru.shadowsparky.vbox.backend.AppDatabase
@@ -53,20 +52,13 @@ class BackendUserTagRepository(
userTagEventHandler.notify(RemoteEvent.OnUserTag(userId)) userTagEventHandler.notify(RemoteEvent.OnUserTag(userId))
} }
override suspend fun edit(id: Long, newTag: String) { override suspend fun edit(oldTag: String, newTag: String) {
checkOwner(id) queries.updateTagText(newTag, oldTag, userId).await()
queries.updateTagText(newTag, id).await()
userTagEventHandler.notify(RemoteEvent.OnUserTag(userId)) userTagEventHandler.notify(RemoteEvent.OnUserTag(userId))
} }
override suspend fun delete(id: Long) { override suspend fun delete(tag: String) {
checkOwner(id) queries.deleteTagById(tag, userId).await()
queries.deleteTagById(id).await()
userTagEventHandler.notify(RemoteEvent.OnUserTag(userId)) userTagEventHandler.notify(RemoteEvent.OnUserTag(userId))
} }
private suspend fun checkOwner(id: Long) = withContext(dispatcherProvider.io) {
val ownerId = queries.getUserIdByTagId(id).executeAsOneOrNull()
check(ownerId == userId) { "tag owner mismatch. owner: $ownerId, current user: $userId" }
}
} }
@@ -7,6 +7,7 @@ import io.ktor.server.routing.Route
import io.ktor.server.routing.delete import io.ktor.server.routing.delete
import io.ktor.server.routing.get import io.ktor.server.routing.get
import io.ktor.server.routing.post import io.ktor.server.routing.post
import io.ktor.server.util.getOrFail
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import ru.shadowsparky.vbox.backend.data.tags.MovieTagRepositoryFactory import ru.shadowsparky.vbox.backend.data.tags.MovieTagRepositoryFactory
import ru.shadowsparky.vbox.backend.data.tags.UserTagRepositoryFactory import ru.shadowsparky.vbox.backend.data.tags.UserTagRepositoryFactory
@@ -41,12 +42,12 @@ private fun Route.setupUserTag(userTagFactory: UserTagRepositoryFactory) {
post(UserTagRepository.EDIT) { post(UserTagRepository.EDIT) {
val userTag = userTagFactory.create(call.obtainUserId()) val userTag = userTagFactory.create(call.obtainUserId())
val edit = call.receive<EditRequest>() val edit = call.receive<EditRequest>()
userTag.edit(edit.id, edit.newTag) userTag.edit(edit.oldTag, edit.newTag)
call.respond(HttpStatusCode.OK) call.respond(HttpStatusCode.OK)
} }
delete(UserTagRepository.DELETE) { delete(UserTagRepository.DELETE) {
val userTag = userTagFactory.create(call.obtainUserId()) val userTag = userTagFactory.create(call.obtainUserId())
userTag.delete(call.receive<DeleteRequest>().id) userTag.delete(call.receive<DeleteRequest>().tag)
call.respond(HttpStatusCode.OK) call.respond(HttpStatusCode.OK)
} }
} }
@@ -54,26 +55,26 @@ private fun Route.setupUserTag(userTagFactory: UserTagRepositoryFactory) {
private fun Route.setupMovieTag(movieTagFactory: MovieTagRepositoryFactory) { private fun Route.setupMovieTag(movieTagFactory: MovieTagRepositoryFactory) {
get(MovieTagRepository.QUERY) { get(MovieTagRepository.QUERY) {
val movieTag = movieTagFactory.create(call.obtainUserId()) val movieTag = movieTagFactory.create(call.obtainUserId())
val movieId = call.parameters[MovieTagRepository.MOVIE_ID]?.toLong() val movieId = call.parameters.getOrFail<Long>(MovieTagRepository.MOVIE_ID)
val tags = movieTag.query(checkNotNull(movieId) { "movieId required" }).first() val tags = movieTag.query(movieId).first()
call.respond(TagResponse(tags)) call.respond(TagResponse(tags))
} }
get(MovieTagRepository.QUERY_MOVIES) { get(MovieTagRepository.QUERY_MOVIES) {
val movieTag = movieTagFactory.create(call.obtainUserId()) val movieTag = movieTagFactory.create(call.obtainUserId())
val tagId = call.parameters[MovieTagRepository.TAG_ID]?.toLong() val tagName = call.parameters.getOrFail(MovieTagRepository.TAG_NAME)
val ids = movieTag.queryMovies(checkNotNull(tagId) { "tagId required" }).first() val ids = movieTag.queryMovies(tagName).first()
call.respond(LinkedMovies(ids)) call.respond(LinkedMovies(ids))
} }
post(MovieTagRepository.LINK) { post(MovieTagRepository.LINK) {
val movieTag = movieTagFactory.create(call.obtainUserId()) val movieTag = movieTagFactory.create(call.obtainUserId())
val link = call.receive<LinkRequest>() val link = call.receive<LinkRequest>()
movieTag.link(link.movieId, link.tagId) movieTag.link(link.movieId, link.tag)
call.respond(HttpStatusCode.OK) call.respond(HttpStatusCode.OK)
} }
delete(MovieTagRepository.UNLINK) { delete(MovieTagRepository.UNLINK) {
val movieTag = movieTagFactory.create(call.obtainUserId()) val movieTag = movieTagFactory.create(call.obtainUserId())
val link = call.receive<LinkRequest>() val link = call.receive<LinkRequest>()
movieTag.unlink(link.movieId, link.tagId) movieTag.unlink(link.movieId, link.tag)
call.respond(HttpStatusCode.OK) call.respond(HttpStatusCode.OK)
} }
} }
@@ -11,3 +11,15 @@ addSavedMovie:
removeSavedMovie: removeSavedMovie:
DELETE FROM "saved_movie" WHERE user_id = ? AND movie_id = ?; DELETE FROM "saved_movie" WHERE user_id = ? AND movie_id = ?;
selectDetailsByUserId:
SELECT
m.movie_id,
m.poster_url,
m.description,
m.title,
m.flags
FROM saved_movie AS sm
JOIN movie AS m ON sm.movie_id = m.movie_id
WHERE sm.user_id = ?
ORDER BY sm.saved_movie_id DESC;
@@ -3,11 +3,6 @@ SELECT id, user_id, tag
FROM user_tag FROM user_tag
WHERE user_id = ?; WHERE user_id = ?;
getUserIdByTagId:
SELECT user_id
FROM user_tag
WHERE id = ?;
insertUserTag: insertUserTag:
INSERT INTO user_tag (user_id, tag) INSERT INTO user_tag (user_id, tag)
VALUES (?, ?); VALUES (?, ?);
@@ -15,11 +10,15 @@ VALUES (?, ?);
updateTagText: updateTagText:
UPDATE user_tag UPDATE user_tag
SET tag = ? SET tag = ?
WHERE id = ?; WHERE tag = ? AND user_id = ?;
deleteTagById: deleteTagById:
DELETE FROM user_tag DELETE FROM user_tag
WHERE id = ?; WHERE tag = ? AND user_id = ?;
findTagId:
SELECT id FROM user_tag
WHERE tag = ? AND user_id = ?;
getTagsForMovie: getTagsForMovie:
SELECT ut.id, ut.tag SELECT ut.id, ut.tag
@@ -71,7 +71,7 @@ class SyncClientServerRecentlyWatchedRepository(
OperationInfo( OperationInfo(
type = AggregateType.RECENTLY_WATCHED, type = AggregateType.RECENTLY_WATCHED,
actionType = ACTION_WRITE, actionType = ACTION_WRITE,
payload = json.encodeToJsonElement(RecentlyWatchedInfo.serializer(), info) payload = json.encodeToJsonElement(info)
) )
) )
} }
@@ -88,7 +88,7 @@ class SyncClientServerRecentlyWatchedRepository(
OperationInfo( OperationInfo(
type = AggregateType.RECENTLY_WATCHED, type = AggregateType.RECENTLY_WATCHED,
actionType = ACTION_REMOVE, actionType = ACTION_REMOVE,
payload = json.encodeToJsonElement(RecentlyWatchedInfo.serializer(), info) payload = json.encodeToJsonElement(info)
) )
) )
} }
@@ -42,7 +42,7 @@ class SyncClientSavedMovieRepository(
OperationInfo( OperationInfo(
type = AggregateType.SAVED_MOVIE, type = AggregateType.SAVED_MOVIE,
actionType = ACTION_SAVE, actionType = ACTION_SAVE,
payload = json.encodeToJsonElement(VideoDetails.serializer(), details) payload = json.encodeToJsonElement(details)
) )
) )
} }
@@ -22,11 +22,11 @@ class MovieTagSyncStrategy(
val payloadObj = payload.jsonObject val payloadObj = payload.jsonObject
val movieId = payloadObj[SyncMovieTagRepository.KEY_MOVIE_ID]?.jsonPrimitive?.long val movieId = payloadObj[SyncMovieTagRepository.KEY_MOVIE_ID]?.jsonPrimitive?.long
?: error("Missing ${SyncMovieTagRepository.KEY_MOVIE_ID}") ?: error("Missing ${SyncMovieTagRepository.KEY_MOVIE_ID}")
val tagId = payloadObj[SyncMovieTagRepository.KEY_TAG_ID]?.jsonPrimitive?.long val tagName = payloadObj[SyncMovieTagRepository.KEY_TAG_NAME]?.jsonPrimitive?.content
?: error("Missing ${SyncMovieTagRepository.KEY_TAG_ID}") ?: error("Missing ${SyncMovieTagRepository.KEY_TAG_NAME}")
when (actionType) { when (actionType) {
SyncMovieTagRepository.ACTION_LINK -> remote.link(movieId, tagId) SyncMovieTagRepository.ACTION_LINK -> remote.link(movieId, tagName)
SyncMovieTagRepository.ACTION_UNLINK -> remote.unlink(movieId, tagId) SyncMovieTagRepository.ACTION_UNLINK -> remote.unlink(movieId, tagName)
else -> error("Unknown action type: $actionType") else -> error("Unknown action type: $actionType")
} }
} }
@@ -27,16 +27,16 @@ class UserTagSyncStrategy(
remote.add(tag) remote.add(tag)
} }
SyncUserTagRepository.ACTION_EDIT -> { SyncUserTagRepository.ACTION_EDIT -> {
val id = payloadObj[SyncUserTagRepository.KEY_ID]?.jsonPrimitive?.long val oldTag = payloadObj[SyncUserTagRepository.KEY_OLD_TAG]?.jsonPrimitive?.content
?: error("Missing ${SyncUserTagRepository.KEY_ID}") ?: error("Missing ${SyncUserTagRepository.KEY_OLD_TAG}")
val newTag = payloadObj[SyncUserTagRepository.KEY_NEW_TAG]?.jsonPrimitive?.content val newTag = payloadObj[SyncUserTagRepository.KEY_NEW_TAG]?.jsonPrimitive?.content
?: error("Missing ${SyncUserTagRepository.KEY_NEW_TAG}") ?: error("Missing ${SyncUserTagRepository.KEY_NEW_TAG}")
remote.edit(id, newTag) remote.edit(oldTag, newTag)
} }
SyncUserTagRepository.ACTION_DELETE -> { SyncUserTagRepository.ACTION_DELETE -> {
val id = payloadObj[SyncUserTagRepository.KEY_ID]?.jsonPrimitive?.long val oldTag = payloadObj[SyncUserTagRepository.KEY_OLD_TAG]?.jsonPrimitive?.content
?: error("Missing ${SyncUserTagRepository.KEY_ID}") ?: error("Missing ${SyncUserTagRepository.KEY_OLD_TAG}")
remote.delete(id) remote.delete(oldTag)
} }
else -> error("Unknown action type: $actionType") else -> error("Unknown action type: $actionType")
} }
@@ -52,34 +52,34 @@ class RemoteMovieTagRepository(
).tags ).tags
} }
override fun queryMovies(tagId: Long): Flow<Set<Long>> { override fun queryMovies(tag: String): Flow<Set<Long>> {
return movieTagListener.event.filterIsInstance<RemoteEvent.OnMovieTag>() return movieTagListener.event.filterIsInstance<RemoteEvent.OnMovieTag>()
.map { queryMoviesSingle(tagId) } .map { queryMoviesSingle(tag) }
.onStart { emit(queryMoviesSingle(tagId)) } .onStart { emit(queryMoviesSingle(tag)) }
.flowOn(dispatcherProvider.io) .flowOn(dispatcherProvider.io)
} }
private suspend fun queryMoviesSingle(tagId: Long): Set<Long> { private suspend fun queryMoviesSingle(tag: String): Set<Long> {
return httpClient.get<LinkedMovies>( return httpClient.get<LinkedMovies>(
MovieTagRepository.QUERY_MOVIES, MovieTagRepository.QUERY_MOVIES,
hashMapOf(MovieTagRepository.TAG_ID to tagId.toString()), hashMapOf(MovieTagRepository.TAG_NAME to tag),
serverConfigurationProvider.getServerConfiguration() serverConfigurationProvider.getServerConfiguration()
).ids ).ids
} }
override suspend fun link(movieId: Long, tagId: Long) { override suspend fun link(movieId: Long, tag: String) {
httpClient.post { httpClient.post {
serverConfigurationProvider.getServerConfiguration() serverConfigurationProvider.getServerConfiguration()
.apply(this, MovieTagRepository.LINK) .apply(this, MovieTagRepository.LINK)
setJsonBody(LinkRequest(movieId, tagId)) setJsonBody(LinkRequest(movieId, tag))
} }
} }
override suspend fun unlink(movieId: Long, tagId: Long) { override suspend fun unlink(movieId: Long, tag: String) {
httpClient.delete { httpClient.delete {
serverConfigurationProvider.getServerConfiguration() serverConfigurationProvider.getServerConfiguration()
.apply(this, MovieTagRepository.UNLINK) .apply(this, MovieTagRepository.UNLINK)
setJsonBody(LinkRequest(movieId, tagId)) setJsonBody(LinkRequest(movieId, tag))
} }
} }
} }
@@ -57,19 +57,19 @@ class RemoteUserTagRepository(
} }
} }
override suspend fun edit(id: Long, newTag: String) { override suspend fun edit(oldTag: String, newTag: String) {
httpClient.post { httpClient.post {
serverConfigurationProvider.getServerConfiguration() serverConfigurationProvider.getServerConfiguration()
.apply(this, UserTagRepository.EDIT) .apply(this, UserTagRepository.EDIT)
setJsonBody(EditRequest(id, newTag)) setJsonBody(EditRequest(oldTag, newTag))
} }
} }
override suspend fun delete(id: Long) { override suspend fun delete(tag: String) {
httpClient.delete { httpClient.delete {
serverConfigurationProvider.getServerConfiguration() serverConfigurationProvider.getServerConfiguration()
.apply(this, UserTagRepository.DELETE) .apply(this, UserTagRepository.DELETE)
setJsonBody(DeleteRequest(id)) setJsonBody(DeleteRequest(tag))
} }
} }
} }
@@ -6,10 +6,12 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emitAll import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
import org.koin.core.annotation.Factory import org.koin.core.annotation.Factory
import org.koin.core.annotation.Named import org.koin.core.annotation.Named
import ru.shadowsparky.domain.DispatcherProvider import ru.shadowsparky.domain.DispatcherProvider
import ru.shadowsparky.vbox.domain.MovieTagQueries import ru.shadowsparky.vbox.domain.MovieTagQueries
import ru.shadowsparky.vbox.domain.UserTagQueries
import ru.shadowsparky.vbox.shared.data.AppDatabaseProvider import ru.shadowsparky.vbox.shared.data.AppDatabaseProvider
import ru.shadowsparky.vbox.shared.di.DbType import ru.shadowsparky.vbox.shared.di.DbType
import ru.shadowsparky.vbox.shared.domain.tag.MovieTagRepository import ru.shadowsparky.vbox.shared.domain.tag.MovieTagRepository
@@ -38,26 +40,29 @@ class SqlMovieTagRepository(
) )
} }
override fun queryMovies(tagId: Long): Flow<Set<Long>> = flow { override fun queryMovies(tag: String): Flow<Set<Long>> = flow {
val id = queryId(tag)
if (id != null) {
emitAll( emitAll(
getQueries().queryMovies(tagId) getQueries().queryMovies(id)
.asFlow() .asFlow()
.mapToList(dispatcherProvider.io) .mapToList(dispatcherProvider.io)
.map { it.toSet() } .map { it.toSet() }
) )
} }
}
override suspend fun link(movieId: Long, tagId: Long) { override suspend fun link(movieId: Long, tag: String) {
getQueries().link( getQueries().link(
movieId = movieId, movieId = movieId,
tagId = tagId tagId = queryId(tag) ?: return
) )
} }
override suspend fun unlink(movieId: Long, tagId: Long) { override suspend fun unlink(movieId: Long, tag: String) {
getQueries().unlink( getQueries().unlink(
movieId = movieId, movieId = movieId,
tagId = tagId tagId = queryId(tag) ?: return
) )
} }
@@ -65,7 +70,15 @@ class SqlMovieTagRepository(
getQueries().clear() getQueries().clear()
} }
private suspend fun queryId(tag: String): Long? = withContext(dispatcherProvider.io) {
getTagsQueries().find(tag).executeAsOneOrNull()?.id
}
private suspend fun getQueries(): MovieTagQueries { private suspend fun getQueries(): MovieTagQueries {
return dbProvider.get().movieTagQueries return dbProvider.get().movieTagQueries
} }
private suspend fun getTagsQueries(): UserTagQueries {
return dbProvider.get().userTagQueries
}
} }
@@ -42,15 +42,12 @@ class SqlUserTagRepository(
runCatching { getQueries().insert(tag) } runCatching { getQueries().insert(tag) }
} }
override suspend fun edit(id: Long, newTag: String) { override suspend fun edit(oldTag: String, newTag: String) {
getQueries().update( getQueries().update(newTag, oldTag)
id = id,
newTag = newTag
)
} }
override suspend fun delete(id: Long) { override suspend fun delete(tag: String) {
runCatching { getQueries().delete(id) } runCatching { getQueries().delete(tag) }
} }
override suspend fun clear() { override suspend fun clear() {
@@ -48,69 +48,63 @@ class SyncMovieTagRepository(
queryRemote = { queryRemote = {
val allTags = userTagRemote.userTags.first() val allTags = userTagRemote.userTags.first()
allTags.flatMap { tag -> allTags.flatMap { tag ->
val tagId = tag.id ?: return@flatMap emptyList() remote.queryMovies(tag.tag).first().map { movieId -> MovieTagLink(movieId, tag.tag) }
remote.queryMovies(tagId).first().map { movieId ->
MovieTagLink(movieId = movieId, tagId = tagId)
}
} }
}, },
queryLocal = { queryLocal = {
val allTags = userTagLocal.userTags.first() val allTags = userTagLocal.userTags.first()
allTags.flatMap { tag -> allTags.flatMap { tag ->
val tagId = tag.id ?: return@flatMap emptyList() local.queryMovies(tag.tag).first().map { movieId -> MovieTagLink(movieId, tag.tag) }
local.queryMovies(tagId).first().map { movieId ->
MovieTagLink(movieId = movieId, tagId = tagId)
}
} }
}, },
writeRemote = { list -> writeRemote = { list ->
list.forEach { link -> remote.link(movieId = link.movieId, tagId = link.tagId) } list.forEach { link -> remote.link(link.movieId, link.tag) }
}, },
writeLocal = { list -> writeLocal = { list ->
list.forEach { link -> local.link(movieId = link.movieId, tagId = link.tagId) } list.forEach { link -> local.link(link.movieId, link.tag) }
}, },
selectKey = { "${it.movieId}|${it.tagId}" } selectKey = { "${it.movieId}|${it.tag}" }
) )
} }
@Serializable @Serializable
data class MovieTagLink( data class MovieTagLink(
val movieId: Long, val movieId: Long,
val tagId: Long val tag: String
) )
override fun queryMovies(tagId: Long): Flow<Set<Long>> { override fun queryMovies(tag: String): Flow<Set<Long>> {
return tokenStorage.queryFlow(remote.queryMovies(tagId), local.queryMovies(tagId)) return tokenStorage.queryFlow(remote.queryMovies(tag), local.queryMovies(tag))
} }
override suspend fun link(movieId: Long, tagId: Long) { override suspend fun link(movieId: Long, tag: String) {
local.link(movieId, tagId) local.link(movieId, tag)
if (tokenStorage.hasAccount()) { if (tokenStorage.hasAccount()) {
try { try {
remote.link(movieId, tagId) remote.link(movieId, tag)
} catch (_: Exception) { } catch (_: Exception) {
pendingRepo.insert( pendingRepo.insert(
OperationInfo( OperationInfo(
type = AggregateType.MOVIE_TAG, type = AggregateType.MOVIE_TAG,
actionType = ACTION_LINK, actionType = ACTION_LINK,
payload = buildPayload(movieId, tagId) payload = buildPayload(movieId, tag)
) )
) )
} }
} }
} }
override suspend fun unlink(movieId: Long, tagId: Long) { override suspend fun unlink(movieId: Long, tag: String) {
local.unlink(movieId, tagId) local.unlink(movieId, tag)
if (tokenStorage.hasAccount()) { if (tokenStorage.hasAccount()) {
try { try {
remote.unlink(movieId, tagId) remote.unlink(movieId, tag)
} catch (_: Exception) { } catch (_: Exception) {
pendingRepo.insert( pendingRepo.insert(
OperationInfo( OperationInfo(
type = AggregateType.MOVIE_TAG, type = AggregateType.MOVIE_TAG,
actionType = ACTION_UNLINK, actionType = ACTION_UNLINK,
payload = buildPayload(movieId, tagId) payload = buildPayload(movieId, tag)
) )
) )
} }
@@ -127,11 +121,11 @@ class SyncMovieTagRepository(
const val ACTION_UNLINK = "UNLINK" const val ACTION_UNLINK = "UNLINK"
const val KEY_MOVIE_ID = "movieId" const val KEY_MOVIE_ID = "movieId"
const val KEY_TAG_ID = "tagId" const val KEY_TAG_NAME = "tagName"
private fun buildPayload(movieId: Long, tagId: Long) = buildJsonObject { private fun buildPayload(movieId: Long, tagName: String) = buildJsonObject {
put(KEY_MOVIE_ID, movieId) put(KEY_MOVIE_ID, movieId)
put(KEY_TAG_ID, tagId) put(KEY_TAG_NAME, tagName)
} }
} }
} }
@@ -72,14 +72,14 @@ class SyncUserTagRepository(
} }
} }
override suspend fun edit(id: Long, newTag: String) { override suspend fun edit(oldTag: String, newTag: String) {
local.edit(id, newTag) local.edit(oldTag, newTag)
if (tokenStorage.hasAccount()) { if (tokenStorage.hasAccount()) {
try { try {
remote.edit(id, newTag) remote.edit(oldTag, newTag)
} catch (_: Exception) { } catch (_: Exception) {
val payload = buildJsonObject { val payload = buildJsonObject {
put(KEY_ID, id) put(KEY_OLD_TAG, oldTag)
put(KEY_NEW_TAG, newTag) put(KEY_NEW_TAG, newTag)
} }
pendingRepo.insert( pendingRepo.insert(
@@ -93,13 +93,13 @@ class SyncUserTagRepository(
} }
} }
override suspend fun delete(id: Long) { override suspend fun delete(tag: String) {
local.delete(id) local.delete(tag)
if (tokenStorage.hasAccount()) { if (tokenStorage.hasAccount()) {
try { try {
remote.delete(id) remote.delete(tag)
} catch (_: Exception) { } catch (_: Exception) {
val payload = buildJsonObject { put(KEY_ID, id) } val payload = buildJsonObject { put(KEY_OLD_TAG, tag) }
pendingRepo.insert( pendingRepo.insert(
OperationInfo( OperationInfo(
type = AggregateType.USER_TAG, type = AggregateType.USER_TAG,
@@ -121,7 +121,7 @@ class SyncUserTagRepository(
const val ACTION_EDIT = "EDIT" const val ACTION_EDIT = "EDIT"
const val ACTION_DELETE = "DELETE" const val ACTION_DELETE = "DELETE"
const val KEY_ID = "id" const val KEY_OLD_TAG = "old_tag"
const val KEY_TAG = "tag" const val KEY_TAG = "tag"
const val KEY_NEW_TAG = "newTag" const val KEY_NEW_TAG = "newTag"
} }
@@ -72,12 +72,12 @@ class OverviewComponent(
doOnStart { init() } doOnStart { init() }
} }
fun updateTag(tagId: Long, state: Boolean) { fun updateTag(tag: String, state: Boolean) {
exec { exec {
if (state) { if (state) {
movieTagRepository.link(id, tagId) movieTagRepository.link(id, tag)
} else { } else {
movieTagRepository.unlink(id, tagId) movieTagRepository.unlink(id, tag)
} }
} }
} }
@@ -81,7 +81,7 @@ fun SavedMovieContent(comp: SavedMovieScreenComponent, paddingValues: PaddingVal
private fun FilterBottomSheet( private fun FilterBottomSheet(
allTags: List<TagInfo>?, allTags: List<TagInfo>?,
onDismiss: () -> Unit, onDismiss: () -> Unit,
onFilterBy: (Long?) -> Unit onFilterBy: (String?) -> Unit
) { ) {
allTags ?: return allTags ?: return
ModalBottomSheet(onDismissRequest = onDismiss) { ModalBottomSheet(onDismissRequest = onDismiss) {
@@ -113,7 +113,7 @@ private fun FilterBottomSheet(
TextPreference( TextPreference(
text = tag.tag, text = tag.tag,
modifier = Modifier.clickable { modifier = Modifier.clickable {
onFilterBy(tag.id) onFilterBy(tag.tag)
onDismiss() onDismiss()
} }
) )
@@ -29,11 +29,11 @@ class SavedMovieScreenComponent(
) : ComponentContext by context { ) : ComponentContext by context {
val userTags = userTagRepository.userTags val userTags = userTagRepository.userTags
private val filterIdFlow = MutableStateFlow<Long?>(null) private val filterTagFlow = MutableStateFlow<String?>(null)
private val allMovies = repo.getAll().map { it.map() } private val allMovies = repo.getAll().map { it.map() }
private val filterMovieIds = filterIdFlow.flatMapLatest { private val filterMovieIds = filterTagFlow.flatMapLatest {
if (it == null) { if (it == null) {
flowOf(null) flowOf(null)
} else { } else {
@@ -49,8 +49,8 @@ class SavedMovieScreenComponent(
} }
} }
fun filterByTag(tagId: Long?) { fun filterByTag(tagId: String?) {
filterIdFlow.tryEmit(tagId) filterTagFlow.tryEmit(tagId)
} }
private fun List<VideoDetails>.map(): List<VideoItem> { private fun List<VideoDetails>.map(): List<VideoItem> {
@@ -33,7 +33,7 @@ fun LinkTagBottomSheet(
linkedTags: List<TagInfo>?, linkedTags: List<TagInfo>?,
onDismiss: () -> Unit, onDismiss: () -> Unit,
requestTagsScreen: () -> Unit, requestTagsScreen: () -> Unit,
updateTag: (Long, Boolean) -> Unit updateTag: (String, Boolean) -> Unit
) { ) {
ModalBottomSheet(onDismissRequest = onDismiss) { ModalBottomSheet(onDismissRequest = onDismiss) {
Column( Column(
@@ -70,7 +70,7 @@ fun LinkTagBottomSheet(
private fun ShowTags( private fun ShowTags(
allTags: List<TagInfo>, allTags: List<TagInfo>,
linkedTags: List<TagInfo>, linkedTags: List<TagInfo>,
updateTag: (Long, Boolean) -> Unit updateTag: (String, Boolean) -> Unit
) { ) {
val linkedTagIds = remember(linkedTags) { val linkedTagIds = remember(linkedTags) {
linkedTags.mapNotNull { it.id }.toSet() linkedTags.mapNotNull { it.id }.toSet()
@@ -90,7 +90,7 @@ private fun ShowTags(
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clickable { updateTag(tagId, !isChecked) } .clickable { updateTag(tag.tag, !isChecked) }
.padding(vertical = 8.dp), .padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
@@ -102,7 +102,7 @@ private fun ShowTags(
) )
Checkbox( Checkbox(
checked = isChecked, checked = isChecked,
onCheckedChange = { checked -> updateTag(tagId, checked) } onCheckedChange = { checked -> updateTag(tag.tag, checked) }
) )
} }
} }
@@ -27,15 +27,15 @@ class TagComponent(
} }
} }
fun edit(id: Long, newTag: String) { fun edit(oldTag: String, newTag: String) {
scope.launch { scope.launch {
tagRepository.edit(id, newTag) tagRepository.edit(oldTag, newTag)
} }
} }
fun delete(id: Long) { fun delete(tag: String) {
scope.launch { scope.launch {
tagRepository.delete(id) tagRepository.delete(tag)
} }
} }
} }
@@ -89,8 +89,8 @@ fun TagScreen(component: TagComponent) {
EditTagBottomSheet( EditTagBottomSheet(
activeTag, activeTag,
onDismiss = { activeTag = null }, onDismiss = { activeTag = null },
onEdit = { id, tag -> component.edit(id, tag.trim()) }, onEdit = { oldTag, tag -> component.edit(oldTag, tag.trim()) },
onDelete = { id -> component.delete(id) }, onDelete = { oldTag -> component.delete(oldTag) },
canSave = canSave canSave = canSave
) )
} else if (isAddSheetVisible) { } else if (isAddSheetVisible) {
@@ -155,8 +155,8 @@ private fun AddTagBottomSheet(
private fun EditTagBottomSheet( private fun EditTagBottomSheet(
tag: TagInfo? = null, tag: TagInfo? = null,
onDismiss: () -> Unit, onDismiss: () -> Unit,
onEdit: (Long, String) -> Unit, onEdit: (String, String) -> Unit,
onDelete: (Long) -> Unit, onDelete: (String) -> Unit,
canSave: (String) -> Boolean canSave: (String) -> Boolean
) { ) {
tag ?: return tag ?: return
@@ -184,7 +184,7 @@ private fun EditTagBottomSheet(
Button( Button(
onClick = { onClick = {
if (tagId != null && newTag.isNotEmpty()) { if (tagId != null && newTag.isNotEmpty()) {
onEdit(tagId, newTag) onEdit(tag.tag, newTag)
onDismiss() onDismiss()
} }
}, },
@@ -194,7 +194,7 @@ private fun EditTagBottomSheet(
OutlinedButton( OutlinedButton(
onClick = { onClick = {
if (tagId != null) { if (tagId != null) {
onDelete(tagId) onDelete(tag.tag)
onDismiss() onDismiss()
} }
}, },
@@ -5,8 +5,12 @@ CREATE TABLE UserTag (
selectAll: selectAll:
SELECT id, tag SELECT id, tag
FROM UserTag;
find:
SELECT id, tag
FROM UserTag FROM UserTag
ORDER BY id DESC; WHERE tag = ?;
insert: insert:
INSERT OR IGNORE INTO UserTag(tag) INSERT OR IGNORE INTO UserTag(tag)
@@ -15,11 +19,11 @@ VALUES (?);
update: update:
UPDATE UserTag UPDATE UserTag
SET tag = :newTag SET tag = :newTag
WHERE id = :id; WHERE tag = ?;
delete: delete:
DELETE FROM UserTag DELETE FROM UserTag
WHERE id = ?; WHERE tag = ?;
clear: clear:
DELETE FROM UserTag; DELETE FROM UserTag;
@@ -6,14 +6,14 @@ import ru.shadowsparky.vbox.shared.domain.DYNAMIC_PREFIX
interface MovieTagRepository { interface MovieTagRepository {
fun query(movieId: Long): Flow<List<TagInfo>> fun query(movieId: Long): Flow<List<TagInfo>>
fun queryMovies(tagId: Long): Flow<Set<Long>> fun queryMovies(tag: String): Flow<Set<Long>>
suspend fun link(movieId: Long, tagId: Long) suspend fun link(movieId: Long, tag: String)
suspend fun unlink(movieId: Long, tagId: Long) suspend fun unlink(movieId: Long, tag: String)
suspend fun clear() {} suspend fun clear() {}
companion object { companion object {
const val TAG_ID = "tagId" const val TAG_NAME = "tagName"
const val MOVIE_ID = "movieId" const val MOVIE_ID = "movieId"
const val PREFIX = "$DYNAMIC_PREFIX/movieTag" const val PREFIX = "$DYNAMIC_PREFIX/movieTag"
@@ -25,7 +25,7 @@ interface MovieTagRepository {
} }
@Serializable @Serializable
data class LinkRequest(val movieId: Long, val tagId: Long) data class LinkRequest(val movieId: Long, val tag: String)
@Serializable @Serializable
data class LinkedMovies(val ids: Set<Long>) data class LinkedMovies(val ids: Set<Long>)
@@ -8,8 +8,8 @@ interface UserTagRepository {
val userTags: Flow<List<TagInfo>> val userTags: Flow<List<TagInfo>>
suspend fun add(tag: String) suspend fun add(tag: String)
suspend fun edit(id: Long, newTag: String) suspend fun edit(oldTag: String, newTag: String)
suspend fun delete(id: Long) suspend fun delete(tag: String)
suspend fun clear() {} suspend fun clear() {}
@@ -18,6 +18,7 @@ interface UserTagRepository {
const val LIST = "$PREFIX/list" const val LIST = "$PREFIX/list"
const val ADD = "$PREFIX/add" const val ADD = "$PREFIX/add"
const val EDIT = "$PREFIX/edit" const val EDIT = "$PREFIX/edit"
const val DELETE = "$PREFIX/delete" const val DELETE = "$PREFIX/delete"
} }
@@ -30,10 +31,10 @@ data class TagResponse(val tags: List<TagInfo>)
data class AddRequest(val tag: String) data class AddRequest(val tag: String)
@Serializable @Serializable
data class EditRequest(val id: Long, val newTag: String) data class EditRequest(val oldTag: String, val newTag: String)
@Serializable @Serializable
data class DeleteRequest(val id: Long) data class DeleteRequest(val tag: String)
@Serializable @Serializable
data class TagInfo( data class TagInfo(