diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 57a45987a7..752faaa1ec 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -276,6 +276,8 @@ dependencies { implementation(libs.seeker) // true type parser implementation(libs.truetypeparser) + // torrserver + implementation(libs.torrentserver) } androidComponents { diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 473a31a9b4..c163f62d39 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -28,6 +28,7 @@ -keep,allowoptimization class eu.kanade.tachiyomi.network.OkHttpExtensionsKt { public protected *; } -keep,allowoptimization class eu.kanade.tachiyomi.network.RequestsKt { public protected *; } -keep,allowoptimization class eu.kanade.tachiyomi.AppInfo { public protected *; } +-keep,allowoptimization class eu.kanade.tachiyomi.torrentutils.** { public protected *; } ##---------------Begin: proguard configuration for RxJava 1.x ---------- -dontwarn sun.misc.** diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 9458212ceb..08f5b84c2f 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -274,6 +274,11 @@ android:foregroundServiceType="dataSync" tools:node="merge" /> + + { val playerPreferences = remember { Injekt.get() } val basePreferences = remember { Injekt.get() } + val torrentServerPreferences = remember { Injekt.get() } val deviceSupportsPip = basePreferences.deviceHasPip() return listOfNotNull( @@ -84,6 +88,7 @@ object SettingsPlayerScreen : SearchableSettings { playerPreferences = playerPreferences, basePreferences = basePreferences, ), + getTorrentServerGroup(torrentServerPreferences), ) } @@ -394,6 +399,55 @@ object SettingsPlayerScreen : SearchableSettings { ) } + @Composable + private fun getTorrentServerGroup( + torrentServerPreferences: TorrentServerPreferences, + ): Preference.PreferenceGroup { + val scope = rememberCoroutineScope() + val trackersPref = torrentServerPreferences.trackers() + val trackers by trackersPref.collectAsState() + + return Preference.PreferenceGroup( + title = stringResource(MR.strings.pref_category_torrentserver), + preferenceItems = persistentListOf( + Preference.PreferenceItem.EditTextPreference( + pref = torrentServerPreferences.port(), + title = stringResource(MR.strings.pref_torrentserver_port), + onValueChanged = { + try { + Integer.parseInt(it) + TorrentServerService.stop() + true + } catch (e: Exception) { + false + } + }, + ), + Preference.PreferenceItem.MultiLineEditTextPreference( + pref = torrentServerPreferences.trackers(), + title = stringResource(MR.strings.pref_torrent_trackers), + subtitle = trackersPref.asState(scope).value + .lines().take(2) + .joinToString( + separator = "\n", + postfix = if (trackersPref.asState(scope).value.lines().size > 2) "\n..." else "", + ), + onValueChanged = { + TorrentServerService.stop() + true + }, + ), + Preference.PreferenceItem.TextPreference( + title = stringResource(MR.strings.pref_reset_torrent_trackers_string), + enabled = remember(trackers) { trackers != trackersPref.defaultValue() }, + onClick = { + trackersPref.delete() + }, + ), + ), + ) + } + @Composable private fun SkipIntroLengthDialog( initialSkipIntroLength: Int, diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/download/anime/AnimeDownloader.kt b/app/src/main/java/eu/kanade/tachiyomi/data/download/anime/AnimeDownloader.kt index ecd27627db..05c37c5def 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/download/anime/AnimeDownloader.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/download/anime/AnimeDownloader.kt @@ -20,6 +20,9 @@ import eu.kanade.tachiyomi.data.download.anime.model.AnimeDownload import eu.kanade.tachiyomi.data.download.anime.model.AnimeDownloadPart import eu.kanade.tachiyomi.data.library.anime.AnimeLibraryUpdateNotifier import eu.kanade.tachiyomi.data.notification.NotificationHandler +import eu.kanade.tachiyomi.data.torrentServer.TorrentServerApi +import eu.kanade.tachiyomi.data.torrentServer.TorrentServerUtils +import eu.kanade.tachiyomi.data.torrentServer.service.TorrentServerService import eu.kanade.tachiyomi.network.ProgressListener import eu.kanade.tachiyomi.util.size import eu.kanade.tachiyomi.util.storage.DiskUtil @@ -483,7 +486,9 @@ class AnimeDownloader( if (downloadScope.isActive) { file = try { - if (isHls(download.video!!) || isMpd(download.video!!)) { + if (isTorrent(download.video!!)) { + torrentDownload(download, tmpDir, filename) + } else if (isHls(download.video!!) || isMpd(download.video!!)) { ffmpegDownload(download, tmpDir, filename) } else { httpDownload(download, tmpDir, filename, newThreads, safe) @@ -503,7 +508,9 @@ class AnimeDownloader( // otherwise we attempt a final try forcing safe mode return if (downloadScope.isActive) { file ?: try { - if (isHls(download.video!!) || isMpd(download.video!!)) { + if (isTorrent(download.video!!)) { + torrentDownload(download, tmpDir, filename) + } else if (isHls(download.video!!) || isMpd(download.video!!)) { ffmpegDownload(download, tmpDir, filename) } else { httpDownload(download, tmpDir, filename, 1, true) @@ -517,6 +524,11 @@ class AnimeDownloader( } } + private fun isTorrent(video: Video): Boolean { + val url = video.videoUrl ?: return false + return url.startsWith("magnet") || url.endsWith(".torrent") || url.startsWith(TorrentServerUtils.hostUrl) + } + private fun isMpd(video: Video): Boolean { return video.videoUrl?.toHttpUrl()?.encodedPath?.endsWith(".mpd") ?: false } @@ -525,6 +537,35 @@ class AnimeDownloader( return video.videoUrl?.toHttpUrl()?.encodedPath?.endsWith(".m3u8") ?: false } + // this start the torrent server and get the url to download the video + private suspend fun torrentDownload( + download: AnimeDownload, + tmpDir: UniFile, + filename: String, + ): UniFile { + val video = download.video!! + TorrentServerService.start() + if (video.videoUrl!!.startsWith(TorrentServerUtils.hostUrl)) { + val hash = video.videoUrl!!.substringAfter("link=").substringBefore("&") + val index = video.videoUrl!!.substringAfter("index=").substringBefore("&").toInt() + val magnet = "magnet:?xt=urn:btih:$hash&index=$index" + video.videoUrl = magnet + } + val currentTorrent = TorrentServerApi.addTorrent(video.videoUrl!!, video.quality, "", "", false) + var index = 0 + if (video.videoUrl!!.contains("index=")) { + index = try { + video.videoUrl?.substringAfter("index=") + ?.substringBefore("&")?.toInt() ?: 0 + } catch (_: Exception) { + 0 + } + } + val torrentUrl = TorrentServerUtils.getTorrentPlayLink(currentTorrent, index) + video.videoUrl = torrentUrl + return ffmpegDownload(download, tmpDir, filename) + } + // ffmpeg is always on safe mode private fun ffmpegDownload( download: AnimeDownload, diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/notification/Notifications.kt b/app/src/main/java/eu/kanade/tachiyomi/data/notification/Notifications.kt index 6a1a14b00d..72d05246d2 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/notification/Notifications.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/notification/Notifications.kt @@ -68,6 +68,12 @@ object Notifications { const val CHANNEL_INCOGNITO_MODE = "incognito_mode_channel" const val ID_INCOGNITO_MODE = -701 + /** + * Notification channel and ids used for torrent server + */ + const val CHANNEL_TORRENT_SERVER = "torrent_server_channel" + const val ID_TORRENT_SERVER = -801 + /** * Notification channel and ids used for app and extension updates. */ @@ -162,6 +168,10 @@ object Notifications { buildNotificationChannel(CHANNEL_INCOGNITO_MODE, IMPORTANCE_LOW) { setName(context.stringResource(MR.strings.pref_incognito_mode)) }, + buildNotificationChannel(CHANNEL_TORRENT_SERVER, IMPORTANCE_LOW) { + setName(context.stringResource(MR.strings.pref_category_torrentserver)) + setShowBadge(false) + }, buildNotificationChannel(CHANNEL_APP_UPDATE, IMPORTANCE_DEFAULT) { setGroup(GROUP_APK_UPDATES) setName(context.stringResource(MR.strings.channel_app_updates)) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/torrentServer/TorrentServerApi.kt b/app/src/main/java/eu/kanade/tachiyomi/data/torrentServer/TorrentServerApi.kt new file mode 100644 index 0000000000..1b346e20d7 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/torrentServer/TorrentServerApi.kt @@ -0,0 +1,107 @@ +package eu.kanade.tachiyomi.data.torrentServer + +import eu.kanade.tachiyomi.BuildConfig +import eu.kanade.tachiyomi.data.torrentServer.model.Torrent +import eu.kanade.tachiyomi.data.torrentServer.model.TorrentRequest +import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.network.NetworkHelper +import eu.kanade.tachiyomi.network.POST +import eu.kanade.tachiyomi.network.awaitSuccess +import kotlinx.serialization.json.Json +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.RequestBody.Companion.toRequestBody +import org.jsoup.Jsoup +import uy.kohesive.injekt.injectLazy +import java.io.InputStream + +object TorrentServerApi { + private val network: NetworkHelper by injectLazy() + private val hostUrl = TorrentServerUtils.hostUrl + + suspend fun echo(): String { + return try { + network.client.newCall(GET("$hostUrl/echo")).awaitSuccess().body.string() + } catch (e: Exception) { + if (BuildConfig.DEBUG) println(e.message) + "" + } + } + + suspend fun shutdown(): String { + return try { + network.client.newCall(GET("$hostUrl/shutdown")).awaitSuccess().body.string() + } catch (e: Exception) { + if (BuildConfig.DEBUG) println(e.message) + "" + } + } + + // / Torrents + suspend fun addTorrent( + link: String, + title: String, + poster: String = "", + data: String = "", + save: Boolean, + ): Torrent { + val req = + TorrentRequest( + "add", + link = link, + title = title, + poster = poster, + data = data, + save_to_db = save, + ).toString() + val resp = + network.client.newCall( + POST("$hostUrl/torrents", body = req.toRequestBody("application/json".toMediaTypeOrNull())), + ).awaitSuccess() + return Json.decodeFromString(Torrent.serializer(), resp.body.string()) + } + + suspend fun getTorrent(hash: String): Torrent { + val req = TorrentRequest("get", hash).toString() + val resp = + network.client.newCall( + POST("$hostUrl/torrents", body = req.toRequestBody("application/json".toMediaTypeOrNull())), + ).awaitSuccess() + return Json.decodeFromString(Torrent.serializer(), resp.body.string()) + } + + suspend fun remTorrent(hash: String) { + val req = TorrentRequest("rem", hash).toString() + network.client.newCall( + POST("$hostUrl/torrents", body = req.toRequestBody("application/json".toMediaTypeOrNull())), + ).awaitSuccess() + } + + suspend fun listTorrent(): List { + val req = TorrentRequest("list").toString() + val resp = + network.client.newCall( + POST("$hostUrl/torrents", body = req.toRequestBody("application/json".toMediaTypeOrNull())), + ).awaitSuccess() + return Json.decodeFromString>(resp.body.string()) + } + + fun uploadTorrent( + file: InputStream, + title: String, + poster: String, + data: String, + save: Boolean, + ): Torrent { + val resp = + Jsoup.connect("$hostUrl/torrent/upload") + .data("title", title) + .data("poster", poster) + .data("data", data) + .data("save", save.toString()) + .data("file1", "filename", file) + .ignoreContentType(true) + .ignoreHttpErrors(true) + .post() + return Json.decodeFromString(Torrent.serializer(), resp.body().text()) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/torrentServer/TorrentServerPreferences.kt b/app/src/main/java/eu/kanade/tachiyomi/data/torrentServer/TorrentServerPreferences.kt new file mode 100644 index 0000000000..42e5bf3950 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/torrentServer/TorrentServerPreferences.kt @@ -0,0 +1,39 @@ +package eu.kanade.tachiyomi.data.torrentServer + +import tachiyomi.core.common.preference.PreferenceStore + +class TorrentServerPreferences( + private val preferenceStore: PreferenceStore, +) { + fun port() = preferenceStore.getString("pref_torrent_port", "8090") + + fun trackers() = preferenceStore.getString( + "pref_torrent_trackers", + """http://nyaa.tracker.wf:7777/announce + http://anidex.moe:6969/announce + http://tracker.anirena.com:80/announce + udp://tracker.uw0.xyz:6969/announce + http://share.camoe.cn:8080/announce + http://t.nyaatracker.com:80/announce + udp://47.ip-51-68-199.eu:6969/announce + udp://9.rarbg.me:2940 + udp://9.rarbg.to:2820 + udp://exodus.desync.com:6969/announce + udp://explodie.org:6969/announce + udp://ipv4.tracker.harry.lu:80/announce + udp://open.stealth.si:80/announce + udp://opentor.org:2710/announce + udp://opentracker.i2p.rocks:6969/announce + udp://retracker.lanta-net.ru:2710/announce + udp://tracker.cyberia.is:6969/announce + udp://tracker.dler.org:6969/announce + udp://tracker.ds.is:6969/announce + udp://tracker.internetwarriors.net:1337 + udp://tracker.openbittorrent.com:6969/announce + udp://tracker.opentrackr.org:1337/announce + udp://tracker.tiny-vps.com:6969/announce + udp://tracker.torrent.eu.org:451/announce + udp://valakas.rollo.dnsabr.com:2710/announce + udp://www.torrent.eu.org:451/announce""".replace(" ", ""), + ) +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/torrentServer/TorrentServerUtils.kt b/app/src/main/java/eu/kanade/tachiyomi/data/torrentServer/TorrentServerUtils.kt new file mode 100644 index 0000000000..358b077d7d --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/torrentServer/TorrentServerUtils.kt @@ -0,0 +1,36 @@ +package eu.kanade.tachiyomi.data.torrentServer + +import eu.kanade.tachiyomi.data.torrentServer.model.FileStat +import eu.kanade.tachiyomi.data.torrentServer.model.Torrent +import uy.kohesive.injekt.injectLazy +import java.io.File +import java.net.URLEncoder + +object TorrentServerUtils { + private val preferences: TorrentServerPreferences by injectLazy() + val hostUrl = "http://127.0.0.1:${preferences.port().get()}" + + // Is necessary separate the trackers by comma because is hardcoded in go-torrent-server + private val animeTrackers = preferences.trackers().get().split("\n").joinToString(",\n") + + fun setTrackersList() { + torrServer.TorrServer.addTrackers(animeTrackers) + } + + fun getTorrentPlayLink(torr: Torrent, index: Int): String { + val file = findFile(torr, index) + val name = file?.let { File(it.path).name } ?: torr.title + return "$hostUrl/stream/${name.urlEncode()}?link=${torr.hash}&index=$index&play" + } + + private fun findFile(torrent: Torrent, index: Int): FileStat? { + torrent.file_stats?.forEach { + if (it.id == index) { + return it + } + } + return null + } + + private fun String.urlEncode(): String = URLEncoder.encode(this, "utf8") +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/torrentServer/model/BTSets.kt b/app/src/main/java/eu/kanade/tachiyomi/data/torrentServer/model/BTSets.kt new file mode 100644 index 0000000000..68b7405a70 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/torrentServer/model/BTSets.kt @@ -0,0 +1,39 @@ +package eu.kanade.tachiyomi.data.torrentServer.model + +import kotlinx.serialization.Serializable + +@Serializable +data class BTSets( + // Cache + var CacheSize: Long, + var PreloadBuffer: Boolean, + var PreloadCache: Int, + var ReaderReadAHead: Int, + // Storage + var UseDisk: Boolean, + var TorrentsSavePath: String, + var RemoveCacheOnDrop: Boolean, + // Torrent + var ForceEncrypt: Boolean, + var RetrackersMode: Int, + var TorrentDisconnectTimeout: Int, + var EnableDebug: Boolean, + // DLNA + var EnableDLNA: Boolean, + var FriendlyName: String, + // Rutor search + var EnableRutorSearch: Boolean, + // BT Config + var EnableIPv6: Boolean, + var DisableTCP: Boolean, + var DisableUTP: Boolean, + var DisableUPNP: Boolean, + var DisableDHT: Boolean, + var DisablePEX: Boolean, + var DisableUpload: Boolean, + var DownloadRateLimit: Int, + var UploadRateLimit: Int, + var ConnectionsLimit: Int, + var DhtConnectionLimit: Int, + var PeersListenPort: Int, +) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/torrentServer/model/TorrServVersion.kt b/app/src/main/java/eu/kanade/tachiyomi/data/torrentServer/model/TorrServVersion.kt new file mode 100644 index 0000000000..ddfaf206cd --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/torrentServer/model/TorrServVersion.kt @@ -0,0 +1,9 @@ +package eu.kanade.tachiyomi.data.torrentServer.model + +import kotlinx.serialization.Serializable + +@Serializable +data class TorrServVersion( + val version: String, + val links: Map, +) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/torrentServer/model/Torrent.kt b/app/src/main/java/eu/kanade/tachiyomi/data/torrentServer/model/Torrent.kt new file mode 100644 index 0000000000..c4380ba96a --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/torrentServer/model/Torrent.kt @@ -0,0 +1,48 @@ +package eu.kanade.tachiyomi.data.torrentServer.model + +import kotlinx.serialization.Serializable + +@Serializable +data class Torrent( + var title: String, + var poster: String? = null, + var data: String? = null, + var timestamp: Long? = null, + var name: String? = null, + var hash: String? = null, + var stat: Int? = null, + var stat_string: String? = null, + var loaded_size: Long? = null, + var torrent_size: Long? = null, + var preloaded_bytes: Long? = null, + var preload_size: Long? = null, + var download_speed: Double? = null, + var upload_speed: Double? = null, + var total_peers: Int? = null, + var pending_peers: Int? = null, + var active_peers: Int? = null, + var connected_seeders: Int? = null, + var half_open_peers: Int? = null, + var bytes_written: Long? = null, + var bytes_written_data: Long? = null, + var bytes_read: Long? = null, + var bytes_read_data: Long? = null, + var bytes_read_useful_data: Long? = null, + var chunks_written: Long? = null, + var chunks_read: Long? = null, + var chunks_read_useful: Long? = null, + var chunks_read_wasted: Long? = null, + var pieces_dirtied_good: Long? = null, + var pieces_dirtied_bad: Long? = null, + var duration_seconds: Double? = null, + var bit_rate: String? = null, + var file_stats: List? = null, + var trackers: List? = null, +) + +@Serializable +data class FileStat( + var id: Int? = null, + var path: String, + var length: Long, +) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/torrentServer/model/TorrentDetails.kt b/app/src/main/java/eu/kanade/tachiyomi/data/torrentServer/model/TorrentDetails.kt new file mode 100644 index 0000000000..14bb41faa1 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/torrentServer/model/TorrentDetails.kt @@ -0,0 +1,23 @@ +package eu.kanade.tachiyomi.data.torrentServer.model + +import kotlinx.serialization.Serializable + +@Serializable +data class TorrentDetails( + val Title: String, + val Name: String, + val Names: List, + val Categories: String, + val Size: String, + val CreateDate: String, + val Tracker: String, + val Link: String, + val Year: Int, + val Peer: Int, + val Seed: Int, + val Magnet: String, + val Hash: String, + val IMDBID: String, + val VideoQuality: Int, + val AudioQuality: Int, +) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/torrentServer/model/TorrentRequest.kt b/app/src/main/java/eu/kanade/tachiyomi/data/torrentServer/model/TorrentRequest.kt new file mode 100644 index 0000000000..09335f7b88 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/torrentServer/model/TorrentRequest.kt @@ -0,0 +1,43 @@ +package eu.kanade.tachiyomi.data.torrentServer.model + +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlinx.serialization.serializer + +@Serializable +data class TorrentRequest( + val action: String, + val hash: String = "", + val link: String = "", + val title: String = "", + val poster: String = "", + val data: String = "", + val save_to_db: Boolean = false, +) { + override fun toString(): String { + return Json.encodeToString(serializer(), this) + } +} + +@Serializable +open class Request(val action: String) { + override fun toString(): String { + return Json.encodeToString(serializer(), this) + } +} + +class SettingsReq( + action: String, + val Sets: BTSets, +) : Request(action) + +class ViewedReq( + action: String, + val hash: String = "", + val file_index: Int = -1, +) : Request(action) + +data class Viewed( + val hash: String, + val file_index: Int, +) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/torrentServer/service/TorrentServerService.kt b/app/src/main/java/eu/kanade/tachiyomi/data/torrentServer/service/TorrentServerService.kt new file mode 100644 index 0000000000..21417cd93c --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/torrentServer/service/TorrentServerService.kt @@ -0,0 +1,171 @@ +package eu.kanade.tachiyomi.data.torrentServer.service + +import android.app.Application +import android.app.PendingIntent +import android.app.Service +import android.content.Context +import android.content.Intent +import android.content.pm.ServiceInfo +import android.os.Build +import android.os.IBinder +import android.util.Log +import eu.kanade.tachiyomi.BuildConfig +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.notification.Notifications +import eu.kanade.tachiyomi.data.torrentServer.TorrentServerApi +import eu.kanade.tachiyomi.data.torrentServer.TorrentServerUtils +import eu.kanade.tachiyomi.util.system.cancelNotification +import eu.kanade.tachiyomi.util.system.notificationBuilder +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import tachiyomi.core.common.i18n.stringResource +import tachiyomi.i18n.MR +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get +import kotlin.coroutines.EmptyCoroutineContext + +class TorrentServerService : Service() { + private val serviceScope = CoroutineScope(EmptyCoroutineContext) + private val applicationContext = Injekt.get() + + override fun onBind(intent: Intent?): IBinder? = null + + override fun onStartCommand( + intent: Intent?, + flags: Int, + startId: Int, + ): Int { + intent?.let { + if (it.action != null) { + when (it.action) { + ACTION_START -> { + startServer() + notification(applicationContext) + return START_STICKY + } + ACTION_STOP -> { + stopServer() + return START_NOT_STICKY + } + } + } + } + return START_NOT_STICKY + } + + private fun startServer() { + serviceScope.launch { + if (TorrentServerApi.echo() == "") { + if (BuildConfig.DEBUG) Log.d("TorrentService", "startServer()") + torrServer.TorrServer.startTorrentServer(filesDir.absolutePath) + wait(10) + TorrentServerUtils.setTrackersList() + } + } + } + + private fun stopServer() { + serviceScope.launch { + if (BuildConfig.DEBUG) Log.d("TorrentService", "stopServer()") + torrServer.TorrServer.stopTorrentServer() + TorrentServerApi.shutdown() + applicationContext.cancelNotification(Notifications.ID_TORRENT_SERVER) + stopSelf() + } + } + + private fun notification(context: Context) { + // fuck android 14 + val startAgainIntent = PendingIntent.getService( + applicationContext, + 0, + Intent(applicationContext, TorrentServerService::class.java).apply { + action = ACTION_START + }, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + val exitPendingIntent = + PendingIntent.getService( + applicationContext, + 0, + Intent(applicationContext, TorrentServerService::class.java).apply { + action = ACTION_STOP + }, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + + val builder = context.notificationBuilder(Notifications.CHANNEL_TORRENT_SERVER) { + setSmallIcon(R.drawable.ic_ani) + setContentText(stringResource(MR.strings.torrentserver_is_running)) + setContentTitle(stringResource(MR.strings.app_name)) + setAutoCancel(false) + setOngoing(true) + setDeleteIntent(startAgainIntent) + setUsesChronometer(true) + addAction( + R.drawable.ic_close_24dp, + "Stop", + exitPendingIntent, + ) + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + startForeground( + Notifications.ID_TORRENT_SERVER, + builder.build(), + ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC, + ) + } else { + startForeground(Notifications.ID_TORRENT_SERVER, builder.build()) + } + } + + companion object { + const val ACTION_START = "start_torrent_server" + const val ACTION_STOP = "stop_torrent_server" + val applicationContext = Injekt.get() + + suspend fun start() { + try { + val intent = + Intent(applicationContext, TorrentServerService::class.java).apply { + action = ACTION_START + } + applicationContext.startService(intent) + wait(10) + } catch (e: Exception) { + if (BuildConfig.DEBUG) Log.d("TorrentService", "start() error: ${e.message}") + e.printStackTrace() + } + } + + fun stop() { + try { + val intent = + Intent(applicationContext, TorrentServerService::class.java).apply { + action = ACTION_STOP + } + applicationContext.startService(intent) + } catch (e: Exception) { + if (BuildConfig.DEBUG) Log.d("TorrentService", "stop() error: ${e.message}") + e.printStackTrace() + } + } + + private suspend fun wait(timeout: Int = -1): Boolean { + var count = 0 + if (timeout < 0) { + count = -20 + } + while (TorrentServerApi.echo() == "") { + delay(1000) + count++ + if (count > timeout) { + return false + } + } + return true + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/di/PreferenceModule.kt b/app/src/main/java/eu/kanade/tachiyomi/di/PreferenceModule.kt index 85172316a4..9372e43b0d 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/di/PreferenceModule.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/di/PreferenceModule.kt @@ -6,6 +6,7 @@ import eu.kanade.domain.source.service.SourcePreferences import eu.kanade.domain.track.service.TrackPreferences import eu.kanade.domain.ui.UiPreferences import eu.kanade.tachiyomi.core.security.SecurityPreferences +import eu.kanade.tachiyomi.data.torrentServer.TorrentServerPreferences import eu.kanade.tachiyomi.network.NetworkPreferences import eu.kanade.tachiyomi.ui.player.settings.PlayerPreferences import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences @@ -69,5 +70,8 @@ class PreferenceModule(val app: Application) : InjektModule { addSingletonFactory { BasePreferences(app, get()) } + addSingletonFactory { + TorrentServerPreferences(get()) + } } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/anime/api/AnimeExtensionApi.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/anime/api/AnimeExtensionApi.kt index eba63e4a4e..88762f7d27 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/extension/anime/api/AnimeExtensionApi.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/extension/anime/api/AnimeExtensionApi.kt @@ -117,6 +117,7 @@ internal class AnimeExtensionApi { libVersion = it.extractLibVersion(), lang = it.lang, isNsfw = it.nsfw == 1, + isTorrent = it.torrent == 1, sources = it.sources?.map(extensionAnimeSourceMapper).orEmpty(), apkName = it.apk, iconUrl = "$repoUrl/icon/${it.pkg}.png", @@ -143,6 +144,7 @@ private data class AnimeExtensionJsonObject( val code: Long, val version: String, val nsfw: Int, + val torrent: Int = 0, val sources: List?, ) diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/anime/model/AnimeExtension.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/anime/model/AnimeExtension.kt index 2d8345cddc..79c4b8943a 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/extension/anime/model/AnimeExtension.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/extension/anime/model/AnimeExtension.kt @@ -13,6 +13,7 @@ sealed class AnimeExtension { abstract val libVersion: Double abstract val lang: String? abstract val isNsfw: Boolean + abstract val isTorrent: Boolean data class Installed( override val name: String, @@ -22,6 +23,7 @@ sealed class AnimeExtension { override val libVersion: Double, override val lang: String, override val isNsfw: Boolean, + override val isTorrent: Boolean, val pkgFactory: String?, val sources: List, val icon: Drawable?, @@ -39,6 +41,7 @@ sealed class AnimeExtension { override val libVersion: Double, override val lang: String, override val isNsfw: Boolean, + override val isTorrent: Boolean, val sources: List, val apkName: String, val iconUrl: String, @@ -70,5 +73,6 @@ sealed class AnimeExtension { val signatureHash: String, override val lang: String? = null, override val isNsfw: Boolean = false, + override val isTorrent: Boolean = false, ) : AnimeExtension() } diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/anime/util/AnimeExtensionLoader.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/anime/util/AnimeExtensionLoader.kt index 17508d589e..a088d25acb 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/extension/anime/util/AnimeExtensionLoader.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/extension/anime/util/AnimeExtensionLoader.kt @@ -43,6 +43,7 @@ internal object AnimeExtensionLoader { private const val METADATA_NSFW = "tachiyomi.animeextension.nsfw" private const val METADATA_HAS_README = "tachiyomi.animeextension.hasReadme" private const val METADATA_HAS_CHANGELOG = "tachiyomi.animeextension.hasChangelog" + private const val METADATA_TORRENT = "tachiyomi.animeextension.torrent" const val LIB_VERSION_MIN = 12 const val LIB_VERSION_MAX = 15 @@ -282,6 +283,8 @@ internal object AnimeExtensionLoader { return AnimeLoadResult.Error } + val isTorrent = appInfo.metaData.getInt(METADATA_TORRENT) == 1 + val classLoader = try { ChildFirstPathClassLoader(appInfo.sourceDir, null, context.classLoader) } catch (e: Exception) { @@ -329,6 +332,7 @@ internal object AnimeExtensionLoader { libVersion = libVersion, lang = lang, isNsfw = isNsfw, + isTorrent = isTorrent, sources = sources, pkgFactory = appInfo.metaData.getString(METADATA_SOURCE_FACTORY), icon = appInfo.loadIcon(pkgManager), diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/anime/AnimeSourceExtensions.kt b/app/src/main/java/eu/kanade/tachiyomi/source/anime/AnimeSourceExtensions.kt index 8d1b087c50..ceb13a7b66 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/source/anime/AnimeSourceExtensions.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/anime/AnimeSourceExtensions.kt @@ -31,3 +31,10 @@ fun AnimeSource.getNameForAnimeInfo(): String { } fun AnimeSource.isLocalOrStub(): Boolean = isLocal() || this is StubAnimeSource + +fun AnimeSource?.isSourceForTorrents(): Boolean { + if (this == null || this.isLocalOrStub()) return false + val sourceUsed = Injekt.get().installedExtensionsFlow.value + .find { ext -> ext.sources.any { it.id == this.id } }!! + return sourceUsed.isTorrent +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/torrentutils/TorrentUtils.kt b/app/src/main/java/eu/kanade/tachiyomi/torrentutils/TorrentUtils.kt new file mode 100644 index 0000000000..dc676b16ca --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/torrentutils/TorrentUtils.kt @@ -0,0 +1,34 @@ +package eu.kanade.tachiyomi.torrentutils + +import eu.kanade.tachiyomi.data.torrentServer.TorrentServerApi +import eu.kanade.tachiyomi.torrentutils.model.DeadTorrentException +import eu.kanade.tachiyomi.torrentutils.model.TorrentFile +import eu.kanade.tachiyomi.torrentutils.model.TorrentInfo +import java.net.SocketTimeoutException + +/** + * Used by extensions. + */ +@Suppress("UNUSED") +object TorrentUtils { + suspend fun getTorrentInfo( + url: String, + title: String, + ): TorrentInfo { + try { + val torrent = TorrentServerApi.addTorrent(url, title, "", "", false) + return TorrentInfo( + torrent.title, + torrent.file_stats?.map { file -> + TorrentFile(file.path, file.id ?: 0, file.length, torrent.hash!!, torrent.trackers ?: emptyList()) + } ?: emptyList(), + torrent.hash!!, + torrent.torrent_size!!, + torrent.trackers ?: emptyList(), + ) + } catch (e: SocketTimeoutException) { + throw DeadTorrentException() + } + + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/torrentutils/model/TorrentFile.kt b/app/src/main/java/eu/kanade/tachiyomi/torrentutils/model/TorrentFile.kt new file mode 100644 index 0000000000..d2ab0c6bba --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/torrentutils/model/TorrentFile.kt @@ -0,0 +1,18 @@ +package eu.kanade.tachiyomi.torrentutils.model + +import eu.kanade.tachiyomi.data.torrentServer.TorrentServerUtils +import java.io.File +import java.net.URLEncoder + +data class TorrentFile( + val path: String, + val indexFile: Int, + val size: Long, + private val torrentHash: String, + private val trackers : List = emptyList(), +) { + fun toMagnetURI(): String { + val trackers = trackers.joinToString("&tr=") { URLEncoder.encode(it, "UTF-8") } + return "magnet:?xt=urn:btih:$torrentHash${if (trackers.isNotEmpty()) "&tr=$trackers" else ""}&index=$indexFile" + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/torrentutils/model/TorrentInfo.kt b/app/src/main/java/eu/kanade/tachiyomi/torrentutils/model/TorrentInfo.kt new file mode 100644 index 0000000000..f06bdf3772 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/torrentutils/model/TorrentInfo.kt @@ -0,0 +1,15 @@ +package eu.kanade.tachiyomi.torrentutils.model + +data class TorrentInfo( + val title: String, + val files: List, + val hash: String, + val size: Long, + val trackers: List = emptyList(), +){ + fun setTrackers(trackers: List): TorrentInfo { + return TorrentInfo(title, files, hash, size, trackers) + } +} + +class DeadTorrentException : Exception() diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/entries/anime/AnimeScreen.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/entries/anime/AnimeScreen.kt index f2067a38fa..5a3565c2b0 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/entries/anime/AnimeScreen.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/entries/anime/AnimeScreen.kt @@ -38,7 +38,9 @@ import eu.kanade.presentation.util.formatEpisodeNumber import eu.kanade.presentation.util.isTabletUi import eu.kanade.tachiyomi.animesource.AnimeSource import eu.kanade.tachiyomi.animesource.online.AnimeHttpSource +import eu.kanade.tachiyomi.data.torrentServer.service.TorrentServerService import eu.kanade.tachiyomi.source.anime.isLocalOrStub +import eu.kanade.tachiyomi.source.anime.isSourceForTorrents import eu.kanade.tachiyomi.ui.browse.anime.migration.search.MigrateAnimeSearchScreen import eu.kanade.tachiyomi.ui.browse.anime.source.browse.BrowseAnimeSourceScreen import eu.kanade.tachiyomi.ui.browse.anime.source.globalsearch.GlobalAnimeSearchScreen @@ -116,6 +118,9 @@ class AnimeScreen( onBackClicked = navigator::pop, onEpisodeClicked = { episode, alt -> scope.launchIO { + if (successState.source.isSourceForTorrents()) { + TorrentServerService.start() + } val extPlayer = screenModel.alwaysUseExternalPlayer != alt openEpisode(context, episode, extPlayer) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/entries/anime/AnimeScreenModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/entries/anime/AnimeScreenModel.kt index b217c438f8..66066a5c22 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/entries/anime/AnimeScreenModel.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/entries/anime/AnimeScreenModel.kt @@ -25,10 +25,12 @@ import eu.kanade.tachiyomi.animesource.model.Video import eu.kanade.tachiyomi.data.download.anime.AnimeDownloadCache import eu.kanade.tachiyomi.data.download.anime.AnimeDownloadManager import eu.kanade.tachiyomi.data.download.anime.model.AnimeDownload +import eu.kanade.tachiyomi.data.torrentServer.service.TorrentServerService import eu.kanade.tachiyomi.data.track.AnimeTracker import eu.kanade.tachiyomi.data.track.EnhancedAnimeTracker import eu.kanade.tachiyomi.data.track.TrackerManager import eu.kanade.tachiyomi.network.HttpException +import eu.kanade.tachiyomi.source.anime.isSourceForTorrents import eu.kanade.tachiyomi.ui.entries.anime.track.AnimeTrackItem import eu.kanade.tachiyomi.ui.player.settings.PlayerPreferences import eu.kanade.tachiyomi.util.AniChartApi @@ -191,11 +193,17 @@ class AnimeScreenModel( val needRefreshInfo = !anime.initialized val needRefreshEpisode = episodes.isEmpty() + val animeSource = Injekt.get().getOrStub(anime.source) + + if (animeSource.isSourceForTorrents()) { + TorrentServerService.start() + } + // Show what we have earlier mutableState.update { State.Success( anime = anime, - source = Injekt.get().getOrStub(anime.source), + source = animeSource, isFromSource = isFromSource, episodes = episodes, isRefreshingData = needRefreshInfo || needRefreshEpisode, diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/player/PlayerActivity.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/player/PlayerActivity.kt index edc3fd2729..a5489c3893 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/player/PlayerActivity.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/player/PlayerActivity.kt @@ -49,6 +49,9 @@ import eu.kanade.tachiyomi.animesource.model.Video import eu.kanade.tachiyomi.animesource.online.AnimeHttpSource import eu.kanade.tachiyomi.data.notification.NotificationReceiver import eu.kanade.tachiyomi.data.notification.Notifications +import eu.kanade.tachiyomi.data.torrentServer.TorrentServerApi +import eu.kanade.tachiyomi.data.torrentServer.TorrentServerUtils +import eu.kanade.tachiyomi.data.torrentServer.service.TorrentServerService import eu.kanade.tachiyomi.databinding.PlayerActivityBinding import eu.kanade.tachiyomi.ui.base.activity.BaseActivity import eu.kanade.tachiyomi.ui.player.settings.PlayerPreferences @@ -1619,11 +1622,49 @@ class PlayerActivity : BaseActivity() { } streams.subtitle.tracks = arrayOf(Track("nothing", "None")) + it.subtitleTracks.toTypedArray() streams.audio.tracks = arrayOf(Track("nothing", "None")) + it.audioTracks.toTypedArray() - MPVLib.command(arrayOf("loadfile", parseVideoUrl(it.videoUrl))) + if (it.videoUrl?.startsWith(TorrentServerUtils.hostUrl) == true || + it.videoUrl?.startsWith("magnet") == true || + it.videoUrl?.endsWith(".torrent") == true + ) { + launchIO { + TorrentServerService.start() + torrentLinkHandler(it.videoUrl!!, it.quality) + } + } else { + MPVLib.command(arrayOf("loadfile", parseVideoUrl(it.videoUrl))) + } } refreshUi() } + private suspend fun torrentLinkHandler(videoUrl: String, quality: String) { + var index = 0 + + // check if link is from localSource + if (videoUrl.startsWith("content://")) { + val videoInputStream = applicationContext.contentResolver.openInputStream(Uri.parse(videoUrl)) + val torrent = TorrentServerApi.uploadTorrent(videoInputStream!!, quality, "", "", false) + val torrentUrl = TorrentServerUtils.getTorrentPlayLink(torrent, 0) + MPVLib.command(arrayOf("loadfile", torrentUrl)) + return + } + + // check if link is from magnet, in that check if index is present + if (videoUrl.startsWith("magnet")) { + if (videoUrl.contains("index=")) { + index = try { + videoUrl.substringAfter("index=").substringBefore("&").toInt() + } catch (e: NumberFormatException) { + 0 + } + } + } + + val currentTorrent = TorrentServerApi.addTorrent(videoUrl, quality, "", "", false) + val videoTorrentUrl = TorrentServerUtils.getTorrentPlayLink(currentTorrent, index) + MPVLib.command(arrayOf("loadfile", videoTorrentUrl)) + } + private fun parseVideoUrl(videoUrl: String?): String? { val uri = Uri.parse(videoUrl) return openContentFd(uri) ?: videoUrl diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 71ca302197..9efbeddb1f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -106,6 +106,8 @@ seeker = "io.github.2307vivek:seeker:1.1.1" truetypeparser = "io.github.yubyf:truetypeparser-light:2.1.4" +torrentserver = "com.github.Diegopyl1209:torrentserver-aniyomi:c18f58e51b" + [bundles] archive = ["common-compress", "junrar"] acra = ["acra-http", "acra-scheduler"] diff --git a/i18n/src/commonMain/resources/MR/base/strings.xml b/i18n/src/commonMain/resources/MR/base/strings.xml index 65d7ca309a..dcf53fff52 100644 --- a/i18n/src/commonMain/resources/MR/base/strings.xml +++ b/i18n/src/commonMain/resources/MR/base/strings.xml @@ -1140,4 +1140,9 @@ Set to 0 to disable the speed limit. Enable MPV scripts Needs external storage permission. + Torrent Server is running + Torrent Server Preferences + Torrent Server Port + Torrent Trackers + Reset default torrent trackers string \ No newline at end of file diff --git a/source-local/src/commonMain/kotlin/tachiyomi/source/local/io/Archive.kt b/source-local/src/commonMain/kotlin/tachiyomi/source/local/io/Archive.kt index 3a19de5a3a..debe15f759 100644 --- a/source-local/src/commonMain/kotlin/tachiyomi/source/local/io/Archive.kt +++ b/source-local/src/commonMain/kotlin/tachiyomi/source/local/io/Archive.kt @@ -5,7 +5,7 @@ import tachiyomi.core.common.storage.extension object ArchiveAnime { - private val SUPPORTED_ARCHIVE_TYPES = listOf("avi", "flv", "mkv", "mov", "mp4", "webm", "wmv") + private val SUPPORTED_ARCHIVE_TYPES = listOf("avi", "flv", "mkv", "mov", "mp4", "webm", "wmv", "torrent") fun isSupported(file: UniFile): Boolean = with(file) { return file.extension in SUPPORTED_ARCHIVE_TYPES