Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions .github/workflows/code-compile.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Development-only feedback while features are still being implemented.
# This workflow never runs tests, emulators, release signing or APK publishing.
name: Android code compilation

on:
pull_request:
branches:
- devel
workflow_dispatch:

permissions:
contents: read

concurrency:
group: compile-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true

jobs:
compile:
runs-on: ubuntu-24.04
timeout-minutes: 35
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v5
with:
distribution: temurin
java-version: 21
- name: Configure extension repositories
env:
MONOGATARI_REPOS: ${{ secrets.MONOGATARI_REPOS }}
shell: bash
run: printf 'monogatari.repos=%s\n' "$MONOGATARI_REPOS" >> local.properties
- uses: gradle/actions/setup-gradle@v4
- name: Compile app, resources, and JVM test sources (do not execute tests)
run: ./gradlew :app:compileDebugKotlin :app:compileDebugUnitTestKotlin --stacktrace --no-daemon
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,16 @@ This is a **signed beta prerelease**, not the first official v1.0.0. The develop

Active development; some third-party sources and video extractors are unsupported.

The Android finishing work is in [draft PR #13](https://github.com/coolchan03/Monogatari/pull/13) (branch `finish-83-20261015`). The [14-item code ledger](docs/CODE_FINISHING_14.md) distinguishes implemented code from final provider and device verification. A dedicated **code-compilation-only** GitHub Action checks draft PRs without running feature tests or publishing APKs. Official v1.0.0 remains unreleased.

## Source repositories

[Curated extensions](https://github.com/coolchan03/Best-BL-Mangayomi-Extensions)

## Credits

Based on [Kotatsu-Redo](https://github.com/Kotatsu-Redo/Kotatsu-Redo) and inspired by [Mangayomi](https://github.com/kodjodevf/mangayomi). Original contributors retain their credits.

### Unified Books search development

Draft [PR #13](https://github.com/coolchan03/Monogatari/pull/13) now shares the full eleven-provider public Books registry with the global search, displays partial results as providers answer, and provides explicit source-availability notices. Where a publisher supplies a compatible free EPUB, global search can save it into the local reader library. Features remain **in development, not device-verified**.
2 changes: 2 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,8 @@
<activity android:name="org.koitharu.kotatsu.core.media.LocalPdfReaderActivity" android:exported="false" />
<activity android:name="org.koitharu.kotatsu.core.media.LocalTextReaderActivity" android:exported="false" />
<activity android:name="org.koitharu.kotatsu.core.media.BookLibraryActivity" android:exported="false" />
<activity android:name="org.koitharu.kotatsu.core.media.StoryWeaverReaderActivity" android:exported="false" />
<activity android:name="org.koitharu.kotatsu.core.media.OfficialFictionBrowserActivity" android:exported="false" />
<activity android:name="org.koitharu.kotatsu.core.media.MediaFeedActivity" android:exported="false" />
<activity android:name="org.koitharu.kotatsu.core.media.MediaFeedPlayerActivity" android:exported="false" />
<activity android:name="org.koitharu.kotatsu.core.media.UnifiedLibraryActivity" android:exported="false" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.RATING_UNKNOWN
import org.koitharu.kotatsu.parsers.model.SortOrder
import java.io.IOException
import java.util.UUID

/**
* The user's own Plex Media Server through its HTTP API with their Plex token (More > API keys & accounts). Movies and
Expand Down Expand Up @@ -56,15 +57,17 @@ class PlexRepository(
parseEpisodes(fetch("$server/library/metadata/$id/allLeaves", token))
} else {
val part = partKey(fetch("$server/library/metadata/$id", token)) ?: throw IOException("This Plex item has no playable file")
listOf(Episode(manga.title, part, 1f))
listOf(Episode(manga.title, part, 1f, id))
}
return manga.copy(chapters = episodes.map { ep ->
MangaChapter(
id = ArchiveMapping.uid(source, "/part${ep.partKey}"),
title = ep.title,
number = ep.number,
volume = 0,
url = ep.partKey,
// The metadata id is carried in the chapter URL fragment for optional server-side transcoding.
// Fragments are never sent to the part endpoint or persisted in a token-bearing URL.
url = if (ep.metadataId != null) "${ep.partKey}#item=${ep.metadataId}" else ep.partKey,
scanlator = null,
uploadDate = 0L,
branch = null,
Expand All @@ -75,8 +78,16 @@ class PlexRepository(

override suspend fun getStreams(chapter: MangaChapter): List<MediaStream> {
val (server, token) = credentials()
require(chapter.url.startsWith("/library/parts/")) { "Invalid Plex file" }
return listOf(MediaStream("$server${chapter.url}", "Direct play", headers = mapOf("X-Plex-Token" to token)))
val path = chapter.url.substringBefore('#')
require(path.startsWith("/library/parts/") && !path.contains("..") && '?' !in path) { "Invalid Plex file" }
val headers = mapOf("X-Plex-Token" to token, "X-Plex-Product" to "Monogatari",
"X-Plex-Client-Identifier" to "monogatari-android")
val direct = MediaStream("$server$path", "Direct play", headers = headers)
val id = chapter.url.substringAfter("#item=", "").takeIf { ID.matches(it) } ?: return listOf(direct)
// Server-side HLS is only offered for an explicitly identified item on the user's own Plex server.
val transcoded = MediaStream(transcodeUrl(server, id, UUID.randomUUID().toString()),
"Server transcode (720p)", headers = headers, mimeType = "application/x-mpegURL")
return listOf(direct, transcoded)
}

override suspend fun getChapterHtml(title: String, chapter: MangaChapter) = ""
Expand Down Expand Up @@ -124,7 +135,7 @@ class PlexRepository(

data class Item(val id: String, val type: String, val title: String, val year: Int?, val summary: String?)

data class Episode(val title: String, val partKey: String, val number: Float)
data class Episode(val title: String, val partKey: String, val number: Float, val metadataId: String? = null)

companion object {

Expand All @@ -138,6 +149,17 @@ class PlexRepository(
).joinToString(" - ").ifBlank { null }
private val TYPES = setOf("movie", "show")

/** Plex universal transcoder: session-specific, header-authenticated, never embeds user tokens in URLs. */
fun transcodeUrl(server: String, metadataId: String, session: String): String {
require(ID.matches(metadataId)) { "Invalid Plex metadata id" }
require(UUID_PATTERN.matches(session)) { "Invalid Plex session" }
return "$server/video/:/transcode/universal/start.m3u8?path=${ArchiveMapping.encode("/library/metadata/$metadataId")}" +
"&mediaIndex=0&partIndex=0&protocol=hls&directPlay=0&directStream=1" +
"&videoQuality=60&videoResolution=1280x720&maxVideoBitrate=4000&session=$session"
}

private val UUID_PATTERN = Regex("[a-fA-F0-9-]{36}")

fun recentUrl(server: String) = "$server/library/recentlyAdded?X-Plex-Container-Start=0&X-Plex-Container-Size=40"

fun searchUrl(server: String, term: String) = "$server/hubs/search?limit=30&query=${ArchiveMapping.encode(term)}"
Expand Down Expand Up @@ -184,12 +206,13 @@ class PlexRepository(
val parsed = (0 until items.length()).mapNotNull { i ->
val item = items.optJSONObject(i) ?: return@mapNotNull null
val part = firstPart(item) ?: return@mapNotNull null
Triple(item.optInt("parentIndex", 0), item.optInt("index", 0), item.optString("title") to part)
val ratingKey = item.optString("ratingKey").takeIf { ID.matches(it) }
Triple(item.optInt("parentIndex", 0), item.optInt("index", 0), Triple(item.optString("title"), part, ratingKey))
}.sortedWith(compareBy({ it.first }, { it.second }))
return parsed.mapIndexed { i, (season, number, titlePart) ->
val label = if (season > 0 && number > 0) "S$season E$number" else "Episode ${i + 1}"
val name = titlePart.first.takeIf { it.isNotBlank() }
Episode(if (name != null) "$label · $name" else label, titlePart.second, (i + 1).toFloat())
Episode(if (name != null) "$label · $name" else label, titlePart.second, (i + 1).toFloat(), titlePart.third)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ abstract class PreferencesDao {
@Query("SELECT * FROM preferences WHERE manga_id = :mangaId")
abstract fun observe(mangaId: Long): Flow<MangaPrefsEntity?>

/** Reader direction is known only for titles where the user saved an explicit reading mode. */
@Query("SELECT * FROM preferences WHERE mode IN (1, 2, 3, 4)")
abstract suspend fun getExplicitReaderModes(): List<MangaPrefsEntity>

@Query("SELECT * FROM preferences WHERE title_override IS NOT NULL OR cover_override IS NOT NULL OR content_rating_override IS NOT NULL")
abstract suspend fun getOverrides(): List<MangaPrefsEntity>

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package org.koitharu.kotatsu.core.media

/** Official catalog ebook-access codes are advisory; they do not guarantee an available loan. */
internal object BookAccessMetadata {
fun openLibraryAccess(code: String?): String? = when (code?.lowercase()) {
"public" -> "Public online text"
"borrowable" -> "Borrowing may be required"
"printdisabled" -> "Restricted accessible edition"
"no_ebook" -> "No digital copy listed"
else -> null
}
}
Loading
Loading