Start implementing the new OpenSubtitle API

This commit is contained in:
Nicolas Pomepuy 2024-10-23 14:00:39 +02:00 committed by Duncan McNamara
parent 072d5fc30d
commit 48e4cecb6b
7 changed files with 293 additions and 151 deletions

View File

@ -84,6 +84,9 @@ dependencies {
api "androidx.leanback:leanback-preference:$rootProject.ext.androidxLeanbackVersion"
// Retrofit
api "com.squareup.okhttp3:okhttp:4.9.3"
api 'com.squareup.okhttp3:logging-interceptor:4.9.3'
api 'com.github.mrmike:ok2curl:0.8.0'
api "com.squareup.retrofit2:retrofit:$rootProject.ext.retrofit"
api "com.squareup.retrofit2:converter-moshi:$rootProject.ext.retrofit"
api "com.squareup.moshi:moshi-adapters:$rootProject.ext.moshi"

View File

@ -2,18 +2,29 @@ package org.videolan.resources.opensubtitles
import retrofit2.http.GET
import retrofit2.http.Path
import retrofit2.http.Query
//Passing 0 for numbers and "" for strings ignores that parameters
interface IOpenSubtitleService {
@GET("episode-{episode}/imdbid-{imdbId}/moviebytesize-{movieByteSize}/moviehash-{movieHash}/query-{name}/season-{season}/sublanguageid-{subLanguageId}/tag_{tag}")
suspend fun query( @Path("movieByteSize") movieByteSize: String = "",
@Path("movieHash") movieHash: String = "",
@Path("name") name: String = "",
@Path("imdbId") imdbId: String = "" ,
@Path("tag") tag: String = "",
@Path("episode") episode: Int = 0,
@Path("season") season: Int = 0,
@Path("subLanguageId") languageId: String = ""): List<OpenSubtitle>
// @GET("episode-{episode}/imdbid-{imdbId}/moviebytesize-{movieByteSize}/moviehash-{movieHash}/query-{name}/season-{season}/sublanguageid-{subLanguageId}/tag_{tag}")
// suspend fun query( @Path("movieByteSize") movieByteSize: String = "",
// @Path("movieHash") movieHash: String = "",
// @Path("name") name: String = "",
// @Path("imdbId") imdbId: String = "" ,
// @Path("tag") tag: String = "",
// @Path("episode") episode: Int = 0,
// @Path("season") season: Int = 0,
// @Path("subLanguageId") languageId: String = ""): List<OpenSubtitle>
@GET("subtitles")
suspend fun query( @Query("languages") languageId: String = "",
@Query("movieHash") movieHash: String? = null,
@Query("query") name: String? = null,
@Query("imdb_id") imdbId: String? = null ,
@Query("episode_number") episode: Int? = null,
@Query("season_number") season: Int? = null,
): OpenSubV1
}

View File

@ -1,5 +1,7 @@
package org.videolan.resources.opensubtitles
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
data class OpenSubtitle(
@field:Json(name = "MatchedBy") val matchedBy: String,
@ -68,3 +70,136 @@ data class QueryParameters(
@field:Json(name = "season") val season: String
)
data class OpenSubV1(
@field:Json(name = "data")
val `data`: List<Data>,
@field:Json(name = "page")
val page: Int,
@field:Json(name = "per_page")
val perPage: Int,
@field:Json(name = "total_count")
val totalCount: Int,
@field:Json(name = "total_pages")
val totalPages: Int
)
data class Data(
@field:Json(name = "attributes")
val attributes: Attributes,
@field:Json(name = "id")
val id: String,
@field:Json(name = "type")
val type: String
)
data class Attributes(
@field:Json(name = "ai_translated")
val aiTranslated: Boolean,
@field:Json(name = "comments")
val comments: String,
@field:Json(name = "download_count")
val downloadCount: Int,
@field:Json(name = "feature_details")
val featureDetails: FeatureDetails,
@field:Json(name = "files")
val files: List<File>,
@field:Json(name = "foreign_parts_only")
val foreignPartsOnly: Boolean,
@field:Json(name = "fps")
val fps: Double,
@field:Json(name = "from_trusted")
val fromTrusted: Boolean,
@field:Json(name = "hd")
val hd: Boolean,
@field:Json(name = "hearing_impaired")
val hearingImpaired: Boolean,
@field:Json(name = "language")
val language: String,
@field:Json(name = "legacy_subtitle_id")
val legacySubtitleId: Int,
@field:Json(name = "legacy_uploader_id")
val legacyUploaderId: Int,
@field:Json(name = "machine_translated")
val machineTranslated: Boolean,
@field:Json(name = "moviehash_match")
val moviehashMatch: Boolean,
@field:Json(name = "nb_cd")
val nbCd: Int,
@field:Json(name = "new_download_count")
val newDownloadCount: Int,
@field:Json(name = "ratings")
val ratings: Int,
@field:Json(name = "related_links")
val relatedLinks: List<RelatedLink>,
@field:Json(name = "release")
val release: String,
@field:Json(name = "slug")
val slug: String,
@field:Json(name = "subtitle_id")
val subtitleId: String,
@field:Json(name = "upload_date")
val uploadDate: String,
@field:Json(name = "uploader")
val uploader: Uploader,
@field:Json(name = "url")
val url: String,
@field:Json(name = "votes")
val votes: Int
)
data class FeatureDetails(
@field:Json(name = "episode_number")
val episodeNumber: Int,
@field:Json(name = "feature_id")
val featureId: Int,
@field:Json(name = "feature_type")
val featureType: String,
@field:Json(name = "imdb_id")
val imdbId: Int,
@field:Json(name = "movie_name")
val movieName: String,
@field:Json(name = "parent_feature_id")
val parentFeatureId: Int,
@field:Json(name = "parent_imdb_id")
val parentImdbId: Int,
@field:Json(name = "parent_title")
val parentTitle: String,
@field:Json(name = "parent_tmdb_id")
val parentTmdbId: Int,
@field:Json(name = "season_number")
val seasonNumber: Int,
@field:Json(name = "title")
val title: String,
@field:Json(name = "tmdb_id")
val tmdbId: Int? = null,
@field:Json(name = "year")
val year: Int
)
data class File(
@field:Json(name = "cd_number")
val cdNumber: Int,
@field:Json(name = "file_id")
val fileId: Int,
@field:Json(name = "file_name")
val fileName: String
)
data class RelatedLink(
@field:Json(name = "img_url")
val imgUrl: String,
@field:Json(name = "label")
val label: String,
@field:Json(name = "url")
val url: String
)
data class Uploader(
@field:Json(name = "name")
val name: String,
@field:Json(name = "rank")
val rank: String,
@field:Json(name = "uploader_id")
val uploaderId: Int? = null
)

View File

@ -9,73 +9,24 @@ class OpenSubtitleRepository(private val openSubtitleService: IOpenSubtitleServi
3) precedence: (movieBytesize and moviehash) > imdbid > name
*/
suspend fun queryWithImdbid(imdbId: Int, tag: String?, episode: Int? , season: Int?, languageId: String? ): List<OpenSubtitle> {
val actualEpisode = episode ?: 0
val actualSeason = season ?: 0
val actualLanguageId = languageId ?: ""
val actualTag = tag ?: ""
return openSubtitleService.query(
imdbId = String.format("%07d", imdbId),
tag = actualTag,
episode = actualEpisode,
season = actualSeason,
languageId = actualLanguageId)
}
suspend fun queryWithHash(movieByteSize: Long, movieHash: String, languageId: String?): List<OpenSubtitle> {
val actualLanguageId = languageId ?: ""
return openSubtitleService.query(
movieByteSize = movieByteSize.toString(),
movieHash = movieHash,
languageId = actualLanguageId)
}
suspend fun queryWithName(name: String, episode: Int?, season: Int?, languageId: String?): List<OpenSubtitle> {
val actualEpisode = episode ?: 0
val actualSeason = season ?: 0
val actualLanguageId = languageId ?: ""
return openSubtitleService.query(
name = name,
episode = actualEpisode,
season = actualSeason,
languageId = actualLanguageId)
}
suspend fun queryWithImdbid(imdbId: Int, tag: String?, episode: Int? , season: Int?, languageIds: List<String>? ): List<OpenSubtitle> {
val actualEpisode = episode ?: 0
val actualSeason = season ?: 0
suspend fun queryWithHash(movieByteSize: Long, movieHash: String?, languageIds: List<String>?): OpenSubV1 {
val actualLanguageIds = languageIds?.toSet()?.run { if (contains("") || isEmpty()) setOf("") else this } ?: setOf("")
val actualTag = tag ?: ""
return actualLanguageIds.flatMap {
openSubtitleService.query(
imdbId = String.format("%07d", imdbId),
tag = actualTag,
episode = actualEpisode,
season = actualSeason,
languageId = it) }
}
suspend fun queryWithHash(movieByteSize: Long, movieHash: String?, languageIds: List<String>?): List<OpenSubtitle> {
val actualLanguageIds = languageIds?.toSet()?.run { if (contains("") || isEmpty()) setOf("") else this } ?: setOf("")
return actualLanguageIds.flatMap {
openSubtitleService.query(
movieByteSize = movieByteSize.toString(),
return openSubtitleService.query(
// movieByteSize = movieByteSize.toString(),
movieHash = movieHash ?: "",
languageId = it)
}
languageId = actualLanguageIds.joinToString(","))
}
suspend fun queryWithName(name: String, episode: Int?, season: Int?, languageIds: List<String>?): List<OpenSubtitle> {
suspend fun queryWithName(name: String, episode: Int?, season: Int?, languageIds: List<String>?): OpenSubV1 {
val actualEpisode = episode ?: 0
val actualSeason = season ?: 0
val actualLanguageIds = languageIds?.toSet()?.run { if (contains("") || isEmpty()) setOf("") else this } ?: setOf("")
return actualLanguageIds.flatMap {
openSubtitleService.query(
return openSubtitleService.query(
name = name,
episode = actualEpisode,
season = actualSeason,
languageId = it)
}
languageId = actualLanguageIds.joinToString(","))
}
companion object {

View File

@ -1,24 +1,38 @@
package org.videolan.resources.opensubtitles
import android.util.Log
import com.moczul.ok2curl.CurlInterceptor
import com.moczul.ok2curl.logger.Logger
import com.squareup.moshi.Moshi
import okhttp3.Interceptor
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import okhttp3.logging.HttpLoggingInterceptor
import org.videolan.resources.AppContextProvider
import org.videolan.resources.BuildConfig
import org.videolan.resources.util.ConnectivityInterceptor
import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory
import java.util.concurrent.TimeUnit
private const val BASE_URL = "https://rest.opensubtitles.org/search/"
private const val USER_AGENT = "VLSub 0.9"
private const val BASE_URL = "https://api.opensubtitles.com/api/v1/"
private const val USER_AGENT = "VLSub v0.9"
private fun buildClient() = Retrofit.Builder()
.baseUrl(BASE_URL)
.client(OkHttpClient.Builder()
.addInterceptor(HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BODY
})
.addInterceptor(UserAgentInterceptor(USER_AGENT))
.addInterceptor(ConnectivityInterceptor(AppContextProvider.appContext))
.addInterceptor(CurlInterceptor(object : Logger {
override fun log(message: String) {
Log.v("Ok2Curl", message)
}
}))
.readTimeout(10, TimeUnit.SECONDS)
.connectTimeout(5, TimeUnit.SECONDS)
.build())
@ -30,11 +44,17 @@ private class UserAgentInterceptor(val userAgent: String): Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val request: Request = chain.request()
val userAgentRequest: Request = request.newBuilder().header("User-Agent", userAgent).build()
val userAgentRequest: Request = request.newBuilder()
.header("User-Agent", userAgent)
.header("Api-Key", BuildConfig.VLC_OPEN_SUBTITLES_API_KEY)
.build()
return chain.proceed(userAgentRequest)
}
}
interface OpenSubtitleClient {
companion object { val instance: IOpenSubtitleService by lazy { buildClient() } }
companion object {
val instance: IOpenSubtitleService by lazy { buildClient() }
fun getDownloadLink(id:Int) = "${BASE_URL}download?file_id=$id"
}
}

View File

@ -12,18 +12,21 @@
</string-array>
<string-array name="language_entries">
<item>Afrikaans</item>
<item>Albanian</item>
<item>Arabic</item>
<item>Aragonese</item>
<item>Armenian</item>
<item>Asturian</item>
<item>Basque</item>
<item>Belarusian</item>
<item>Bengali</item>
<item>Bosnian</item>
<item>Breton</item>
<item>Bulgarian</item>
<item>Burmese</item>
<item>Catalan</item>
<item>Chinese</item>
<item>Croatian</item>
<item>Chinese (simplified)</item>
<item>Czech</item>
<item>Danish</item>
<item>Dutch</item>
@ -32,12 +35,13 @@
<item>Estonian</item>
<item>Finnish</item>
<item>French</item>
<item>Galician</item>
<item>Georgian</item>
<item>German</item>
<item>Galician</item>
<item>Greek</item>
<item>Hebrew</item>
<item>Hindi</item>
<item>Croatian</item>
<item>Hungarian</item>
<item>Icelandic</item>
<item>Indonesian</item>
@ -50,16 +54,15 @@
<item>Lithuanian</item>
<item>Luxembourgish</item>
<item>Macedonian</item>
<item>Malay</item>
<item>Malayalam</item>
<item>Malay</item>
<item>Manipuri</item>
<item>Mongolian</item>
<item>Norwegian</item>
<item>Occitan</item>
<item>Persian</item>
<item>Polish</item>
<item>Portuguese</item>
<item>Brazilian Portuguese</item>
<item>Romanian</item>
<item>Russian</item>
<item>Serbian</item>
<item>Sinhalese</item>
@ -69,82 +72,97 @@
<item>Swahili</item>
<item>Swedish</item>
<item>Syriac</item>
<item>Tagalog</item>
<item>Tamil</item>
<item>Telugu</item>
<item>Tagalog</item>
<item>Thai</item>
<item>Turkish</item>
<item>Ukrainian</item>
<item>Urdu</item>
<item>Uzbek</item>
<item>Vietnamese</item>
<item>Romanian</item>
<item>Portuguese (Brazilian)</item>
<item>Montenegrin</item>
<item>Chinese (traditional)</item>
<item>Chinese bilingual</item>
</string-array>
<string-array name="language_values">
<item>alb</item>
<item>ara</item>
<item>arm</item>
<item>baq</item>
<item>ben</item>
<item>bos</item>
<item>bre</item>
<item>bul</item>
<item>bur</item>
<item>cat</item>
<item>chi</item>
<item>hrv</item>
<item>cze</item>
<item>dan</item>
<item>dut</item>
<item>eng</item>
<item>epo</item>
<item>est</item>
<item>fin</item>
<item>fre</item>
<item>glg</item>
<item>geo</item>
<item>ger</item>
<item>ell</item>
<item>heb</item>
<item>hin</item>
<item>hun</item>
<item>ice</item>
<item>ind</item>
<item>ita</item>
<item>jpn</item>
<item>kaz</item>
<item>khm</item>
<item>kor</item>
<item>lav</item>
<item>lit</item>
<item>ltz</item>
<item>mac</item>
<item>may</item>
<item>mal</item>
<item>mon</item>
<item>nor</item>
<item>oci</item>
<item>per</item>
<item>pol</item>
<item>por</item>
<item>pob</item>
<item>rum</item>
<item>rus</item>
<item>scc</item>
<item>sin</item>
<item>slo</item>
<item>slv</item>
<item>spa</item>
<item>swa</item>
<item>swe</item>
<item>syr</item>
<item>tgl</item>
<item>tam</item>
<item>tel</item>
<item>tha</item>
<item>tur</item>
<item>ukr</item>
<item>urd</item>
<item>vie</item>
<item>af</item>
<item>sq</item>
<item>ar</item>
<item>an</item>
<item>hy</item>
<item>at</item>
<item>eu</item>
<item>be</item>
<item>bn</item>
<item>bs</item>
<item>br</item>
<item>bg</item>
<item>my</item>
<item>ca</item>
<item>zh-cn</item>
<item>cs</item>
<item>da</item>
<item>nl</item>
<item>en</item>
<item>eo</item>
<item>et</item>
<item>fi</item>
<item>fr</item>
<item>ka</item>
<item>de</item>
<item>gl</item>
<item>el</item>
<item>he</item>
<item>hi</item>
<item>hr</item>
<item>hu</item>
<item>is</item>
<item>id</item>
<item>it</item>
<item>ja</item>
<item>kk</item>
<item>km</item>
<item>ko</item>
<item>lv</item>
<item>lt</item>
<item>lb</item>
<item>mk</item>
<item>ml</item>
<item>ms</item>
<item>ma</item>
<item>mn</item>
<item>no</item>
<item>oc</item>
<item>fa</item>
<item>pl</item>
<item>pt-pt</item>
<item>ru</item>
<item>sr</item>
<item>si</item>
<item>sk</item>
<item>sl</item>
<item>es</item>
<item>sw</item>
<item>sv</item>
<item>sy</item>
<item>ta</item>
<item>te</item>
<item>tl</item>
<item>th</item>
<item>tr</item>
<item>uk</item>
<item>ur</item>
<item>uz</item>
<item>vi</item>
<item>ro</item>
<item>pt-br</item>
<item>me</item>
<item>zh-tw</item>
<item>ze</item>
</string-array>
<string-array name="subtitles_size_entries">

View File

@ -14,7 +14,10 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.videolan.resources.opensubtitles.Data
import org.videolan.resources.opensubtitles.OpenSubV1
import org.videolan.resources.opensubtitles.OpenSubtitle
import org.videolan.resources.opensubtitles.OpenSubtitleClient
import org.videolan.resources.opensubtitles.OpenSubtitleRepository
import org.videolan.resources.util.NoConnectivityException
import org.videolan.tools.CoroutineContextProvider
@ -44,7 +47,7 @@ class SubtitlesModel(private val context: Context, private val mediaUri: Uri, pr
val observableError = ObservableField<Boolean>()
val observableResultDescription = ObservableField<Spanned>()
private val apiResultLiveData: MutableLiveData<List<OpenSubtitle>> = MutableLiveData()
private val apiResultLiveData: MutableLiveData<List<Data>> = MutableLiveData()
private val downloadedLiveData = ExternalSubRepository.getInstance(context).getDownloadedSubtitles(mediaUri).map { list ->
list.map { SubtitleItem(it.idSubtitle, mediaUri, it.subLanguageID, it.movieReleaseName, State.Downloaded, "") }
}
@ -100,17 +103,18 @@ class SubtitlesModel(private val context: Context, private val mediaUri: Uri, pr
downloadedResult.orEmpty() + downloadingResult?.toList().orEmpty()
}
private suspend fun updateListState(apiResultLiveData: List<OpenSubtitle>?, history: List<SubtitleItem>?): MutableList<SubtitleItem> = withContext(coroutineContextProvider.Default) {
private suspend fun updateListState(apiResultLiveData: List<Data>?, history: List<SubtitleItem>?): MutableList<SubtitleItem> = withContext(coroutineContextProvider.Default) {
val list = mutableListOf<SubtitleItem>()
apiResultLiveData?.forEach { openSubtitle ->
val exist = history?.find { it.idSubtitle == openSubtitle.idSubtitle }
val exist = history?.find { it.idSubtitle == openSubtitle.attributes.subtitleId }
val state = exist?.state ?: State.NotDownloaded
list.add(SubtitleItem(openSubtitle.idSubtitle, mediaUri, openSubtitle.subLanguageID, openSubtitle.movieReleaseName, state, openSubtitle.zipDownloadLink))
if (openSubtitle.attributes.files.isNotEmpty())
list.add(SubtitleItem(openSubtitle.attributes.subtitleId, mediaUri, openSubtitle.attributes.language, openSubtitle.attributes.featureDetails.movieName, state, OpenSubtitleClient.getDownloadLink(openSubtitle.attributes.files.first().fileId)))
}
list
}
private suspend fun getSubtitleByName(name: String, episode: Int?, season: Int?, languageIds: List<String>?): List<OpenSubtitle> {
private suspend fun getSubtitleByName(name: String, episode: Int?, season: Int?, languageIds: List<String>?): OpenSubV1 {
if (BuildConfig.DEBUG) Log.d(this::class.java.simpleName, "Getting subs by name with $name")
val builder = StringBuilder(context.getString(R.string.sub_result_by_name, "<i>$name</i>"))
season?.let { builder.append(" - ").append(context.getString(R.string.sub_result_by_name_season, "<i>$it</i>")) }
@ -120,7 +124,7 @@ class SubtitlesModel(private val context: Context, private val mediaUri: Uri, pr
return OpenSubtitleRepository.getInstance().queryWithName(name, episode, season, languageIds)
}
private suspend fun getSubtitleByHash(movieByteSize: Long, movieHash: String?, languageIds: List<String>?): List<OpenSubtitle> {
private suspend fun getSubtitleByHash(movieByteSize: Long, movieHash: String?, languageIds: List<String>?): OpenSubV1 {
if (BuildConfig.DEBUG) Log.d(this::class.java.simpleName, "Getting subs by hash with $movieHash")
manualSearchEnabled.set(false)
observableResultDescription.set(context.getString(R.string.sub_result_by_file).toSpanned())
@ -151,17 +155,17 @@ class SubtitlesModel(private val context: Context, private val mediaUri: Uri, pr
if (videoFile.exists()) {
val hash = FileUtils.computeHash(videoFile)
val fileLength = videoFile.length()
val hashSubs = getSubtitleByHash(fileLength, hash, observableSearchLanguage.get())
val hashSubs = getSubtitleByHash(fileLength, hash, observableSearchLanguage.get()).data
// No result for hash. Falling back to name search
if (hashSubs.isEmpty()) getSubtitleByName(videoFile.name, null, null, observableSearchLanguage.get()) else hashSubs
if (hashSubs.isEmpty()) getSubtitleByName(videoFile.name, null, null, observableSearchLanguage.get()).data else hashSubs
} else {
getSubtitleByName(name, null, null, observableSearchLanguage.get())
getSubtitleByName(name, null, null, observableSearchLanguage.get()).data
}
}
} else {
observableSearchName.get()?.let {
getSubtitleByName(it, observableSearchEpisode.get()?.toInt(), observableSearchSeason.get()?.toInt(), observableSearchLanguage.get())
getSubtitleByName(it, observableSearchEpisode.get()?.toInt(), observableSearchSeason.get()?.toInt(), observableSearchLanguage.get()).data
} ?: listOf()
}
if (isActive) apiResultLiveData.postValue(subs)