Compare commits

..

3 Commits

Author SHA1 Message Date
lance chant
cd5300e4ba Merge branch 'develop' into fix/android-tv-issues 2026-06-05 13:26:15 +02:00
Lance Chant
326956dfda fix: search page crash on android
Ensured the search module is only apple specific to stop android
crashing

Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-06-05 08:24:40 +02:00
Lance Chant
7528274249 fix: the home recommendations
there was an issue where the home recommendations was deleted and then
not shown again because of google TV policies

Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-06-05 08:08:03 +02:00
9 changed files with 386 additions and 435 deletions

View File

@@ -1,5 +1,5 @@
name: "🐛 Bug Report" name: "🐛 Bug Report"
description: Create a report to help Streamyfin improve description: Create a report to help us improve
title: "[Bug]: " title: "[Bug]: "
labels: labels:
- "🐛 bug" - "🐛 bug"
@@ -36,7 +36,7 @@ body:
attributes: attributes:
label: What happened? label: What happened?
description: A clear and concise description of what the bug is. description: A clear and concise description of what the bug is.
placeholder: Describe what happened in detail, the more precise the better. placeholder: Describe what happened in detail.
validations: validations:
required: true required: true
@@ -67,7 +67,7 @@ body:
attributes: attributes:
label: Which device and operating system are you using? label: Which device and operating system are you using?
description: Please provide your device model and OS version description: Please provide your device model and OS version
placeholder: e.g. iPhone 17 Pro / iOS 26.5.1, Samsung Galaxy S25 / Android 16, Apple TV / tvOS 26.5 placeholder: e.g. iPhone 15 Pro, iOS 18.1.1 or Samsung Galaxy S24, Android 14
validations: validations:
required: true required: true
@@ -75,11 +75,11 @@ body:
id: version id: version
attributes: attributes:
label: Streamyfin Version label: Streamyfin Version
description: What version of Streamyfin are you using? description: What version of Streamyfin are you running?
options: options:
- 0.54.1 - 0.47.1
- 0.51.0 - 0.30.2
- Older - older
- TestFlight/Development build - TestFlight/Development build
validations: validations:
required: true required: true
@@ -90,9 +90,9 @@ body:
label: Jellyfin Server Information label: Jellyfin Server Information
description: Please provide details about your Jellyfin server description: Please provide details about your Jellyfin server
placeholder: | placeholder: |
- Jellyfin Server Version: e.g. 10.11.10 - Jellyfin Server Version: e.g. 10.10.7
- Server OS: e.g. Ubuntu 26.04, Windows 11, Docker, Proxmox - Server OS: e.g. Ubuntu 22.04, Windows 11, Docker
- Connection: e.g. Local network, remote via domain, VPN - Connection: e.g. Local network, Remote via domain, VPN
- type: textarea - type: textarea
id: screenshots id: screenshots
@@ -104,7 +104,7 @@ body:
id: logs id: logs
attributes: attributes:
label: Relevant logs (if available) label: Relevant logs (if available)
description: If you have access to app logs or crash reports, please include them here. **Remember to remove any personal information like server URL, API keys or usernames.** description: If you have access to app logs or crash reports, please include them here. **Remember to remove any personal information like server URLs or usernames.**
render: shell render: shell
- type: textarea - type: textarea

View File

@@ -1,38 +0,0 @@
name: 🔁 Detect Duplicate Issues
on:
issues:
types: [opened]
permissions:
contents: read
concurrency:
group: detect-duplicate-${{ github.event.issue.number }}
cancel-in-progress: true
jobs:
detect:
name: 🔍 Find similar issues
if: github.actor != 'github-actions[bot]'
runs-on: ubuntu-24.04
permissions:
issues: write
contents: read
steps:
- name: 📥 Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: latest
- name: 🔍 Detect duplicate issues
run: bun scripts/detect-duplicate-issue.mjs
env:
GH_TOKEN: ${{ github.token }}
GITHUB_REPOSITORY: ${{ github.repository }}
ISSUE_NUMBER: ${{ github.event.issue.number }}
ISSUE_TITLE: ${{ github.event.issue.title }}
ISSUE_BODY: ${{ github.event.issue.body }}

View File

@@ -105,7 +105,7 @@
"@react-native-tvos/config-tv": "0.1.6", "@react-native-tvos/config-tv": "0.1.6",
"@types/jest": "29.5.14", "@types/jest": "29.5.14",
"@types/lodash": "4.17.24", "@types/lodash": "4.17.24",
"@types/react": "19.2.17", "@types/react": "~19.2.10",
"@types/react-test-renderer": "19.1.0", "@types/react-test-renderer": "19.1.0",
"cross-env": "10.1.0", "cross-env": "10.1.0",
"expo-doctor": "1.19.7", "expo-doctor": "1.19.7",
@@ -605,7 +605,7 @@
"@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="], "@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="],
"@types/react": ["@types/react@19.2.17", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw=="], "@types/react": ["@types/react@19.2.15", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q=="],
"@types/react-test-renderer": ["@types/react-test-renderer@19.1.0", "", { "dependencies": { "@types/react": "*" } }, "sha512-XD0WZrHqjNrxA/MaR9O22w/RNidWR9YZmBdRGI7wcnWGrv/3dA8wKCJ8m63Sn+tLJhcjmuhOi629N66W6kgWzQ=="], "@types/react-test-renderer": ["@types/react-test-renderer@19.1.0", "", { "dependencies": { "@types/react": "*" } }, "sha512-XD0WZrHqjNrxA/MaR9O22w/RNidWR9YZmBdRGI7wcnWGrv/3dA8wKCJ8m63Sn+tLJhcjmuhOi629N66W6kgWzQ=="],
@@ -1599,7 +1599,7 @@
"react-native-text-ticker": ["react-native-text-ticker@1.15.0", "", {}, "sha512-d/uK+PIOhsYMy1r8h825iq/nADiHsabz3WMbRJSnkpQYn+K9aykUAXRRhu8ZbTAzk4CgnUWajJEFxS5ZDygsdg=="], "react-native-text-ticker": ["react-native-text-ticker@1.15.0", "", {}, "sha512-d/uK+PIOhsYMy1r8h825iq/nADiHsabz3WMbRJSnkpQYn+K9aykUAXRRhu8ZbTAzk4CgnUWajJEFxS5ZDygsdg=="],
"react-native-track-player": ["react-native-track-player@github:lovegaoshi/react-native-track-player#33a3ecd", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-windows": "*", "shaka-player": "^4.7.9" }, "optionalPeers": ["react-native-windows", "shaka-player"] }, "lovegaoshi-react-native-track-player-33a3ecd", "sha512-vfkld2jUj7EPkAjIc/Vbx4Q4MtOOLmYtCYCE2dWJsyLnPqgj1f0xVzBxbeVP7dfT+eSh4KIXfdxESXaHgrXIlw=="], "react-native-track-player": ["react-native-track-player@github:lovegaoshi/react-native-track-player#33a3ecd", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-windows": "*", "shaka-player": "^4.7.9" }, "optionalPeers": ["react-native-windows", "shaka-player"] }, "lovegaoshi-react-native-track-player-33a3ecd"],
"react-native-udp": ["react-native-udp@4.1.7", "", { "dependencies": { "buffer": "^5.6.0", "events": "^3.1.0" } }, "sha512-NUE3zewu61NCdSsLlj+l0ad6qojcVEZPT4hVG/x6DU9U4iCzwtfZSASh9vm7teAcVzLkdD+cO3411LHshAi/wA=="], "react-native-udp": ["react-native-udp@4.1.7", "", { "dependencies": { "buffer": "^5.6.0", "events": "^3.1.0" } }, "sha512-NUE3zewu61NCdSsLlj+l0ad6qojcVEZPT4hVG/x6DU9U4iCzwtfZSASh9vm7teAcVzLkdD+cO3411LHshAi/wA=="],
@@ -2019,8 +2019,6 @@
"@testing-library/dom/pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="], "@testing-library/dom/pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="],
"@types/react-test-renderer/@types/react": ["@types/react@19.2.15", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q=="],
"accepts/negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="], "accepts/negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="],
"ansi-fragments/slice-ansi": ["slice-ansi@2.1.0", "", { "dependencies": { "ansi-styles": "^3.2.0", "astral-regex": "^1.0.0", "is-fullwidth-code-point": "^2.0.0" } }, "sha512-Qu+VC3EwYLldKa1fCxuuvULvSJOKEgk9pi8dZeCVK7TqBfUNTH4sFkk4joj8afVSfAYgJoSOetjx9QWOJ5mYoQ=="], "ansi-fragments/slice-ansi": ["slice-ansi@2.1.0", "", { "dependencies": { "ansi-styles": "^3.2.0", "astral-regex": "^1.0.0", "is-fullwidth-code-point": "^2.0.0" } }, "sha512-Qu+VC3EwYLldKa1fCxuuvULvSJOKEgk9pi8dZeCVK7TqBfUNTH4sFkk4joj8afVSfAYgJoSOetjx9QWOJ5mYoQ=="],

View File

@@ -2,7 +2,7 @@ import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { useMemo } from "react"; import { useMemo } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ScrollView, View } from "react-native"; import { Platform, ScrollView, TextInput, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { TVDiscover } from "@/components/jellyseerr/discover/TVDiscover"; import { TVDiscover } from "@/components/jellyseerr/discover/TVDiscover";
@@ -231,26 +231,48 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
paddingTop: insets.top + TOP_PADDING, paddingTop: insets.top + TOP_PADDING,
}} }}
> >
{/* Native tvOS search field (SwiftUI `.searchable`, our `tv-search` {/* Search bar: native tvOS SwiftUI `.searchable` on Apple TV, standard
module). It renders the native search bar + grid keyboard and TextInput fallback on Android TV (the native module is Apple-only). */}
forwards typed text into the existing query pipeline via setSearch; {Platform.OS === "ios" ? (
our own results grid renders below. */}
{/* No horizontal margin here: the native tvOS search bar centers itself
and renders a trailing "Hold to Dictate in <Language>" hint. Extra
margins squeeze the bar's width and clip that trailing hint, so let
the native view span the full width and own its own insets. */}
<View <View
style={{ style={{
marginBottom: 24, marginBottom: 24,
height: SEARCH_AREA_HEIGHT, height: SEARCH_AREA_HEIGHT,
}} }}
> >
{/* No horizontal margin here: the native tvOS search bar centers
itself and renders a trailing "Hold to Dictate" hint. */}
<TvSearchView <TvSearchView
style={{ width: "100%", height: "100%" }} style={{ width: "100%", height: "100%" }}
placeholder={t("search.search")} placeholder={t("search.search")}
onChangeText={(e) => setSearch(e.nativeEvent.text)} onChangeText={(e) => setSearch(e.nativeEvent.text)}
/> />
</View> </View>
) : (
<View
style={{
marginHorizontal: HORIZONTAL_PADDING,
marginBottom: 24,
}}
>
<TextInput
style={{
height: 56,
width: "100%",
backgroundColor: "#262626",
borderRadius: 12,
paddingHorizontal: 20,
fontSize: 28,
color: "#fff",
}}
placeholder={t("search.search")}
placeholderTextColor='rgba(255,255,255,0.4)'
onChangeText={setSearch}
defaultValue=''
autoFocus={false}
/>
</View>
)}
</View> </View>
<ScrollView <ScrollView

View File

@@ -1,10 +1,13 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="com.android.providers.tv.permission.WRITE_EPG_DATA" />
<application> <application>
<receiver <receiver android:name=".TvRecommendationsReceiver" android:exported="true">
android:name=".TvRecommendationsReceiver"
android:exported="true">
<intent-filter> <intent-filter>
<action android:name="android.media.tv.action.INITIALIZE_PROGRAMS" /> <action android:name="android.media.tv.action.INITIALIZE_PROGRAMS" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
<intent-filter>
<action android:name="android.media.tv.action.PREVIEW_PROGRAM_BROWSABLE_DISABLED" />
</intent-filter> </intent-filter>
</receiver> </receiver>
</application> </application>

View File

@@ -61,31 +61,61 @@ internal object TvRecommendationsPublisher {
fun clear(context: Context): Boolean { fun clear(context: Context): Boolean {
val prefs = preferences(context) val prefs = preferences(context)
val channelId = prefs.getLong(KEY_CHANNEL_ID, -1L)
val programIds = prefs.getString(KEY_PROGRAM_IDS, null)?.let(::JSONObject)
val contentResolver = context.contentResolver val contentResolver = context.contentResolver
if (programIds != null) { // KEY_PROGRAM_IDS is now { channelId: "{ providerId: programId }" }
val allProgramIds = prefs.getString(KEY_PROGRAM_IDS, null)?.let(::JSONObject)
if (allProgramIds != null) {
var deletedPrograms = 0 var deletedPrograms = 0
val channelKeys = allProgramIds.keys()
while (channelKeys.hasNext()) {
val channelIdStr = channelKeys.next()
val programIdsJson = allProgramIds.optString(channelIdStr)
if (programIdsJson.isBlank()) continue
try {
val programIds = JSONObject(programIdsJson)
val keys = programIds.keys() val keys = programIds.keys()
while (keys.hasNext()) { while (keys.hasNext()) {
val key = keys.next() val providerId = keys.next()
val programId = programIds.optLong(key, -1L) val programId = programIds.optLong(providerId, -1L)
if (programId > 0L) { if (programId > 0L) {
contentResolver.delete( deletePreviewProgram(contentResolver, programId)
TvContractCompat.buildPreviewProgramUri(programId),
null,
null
)
deletedPrograms += 1 deletedPrograms += 1
} }
} }
} catch (e: Exception) {
Log.w(TAG, "clear(): failed to parse programIds for channel $channelIdStr", e)
}
// Notify the channel
val channelId = channelIdStr.toLongOrNull() ?: -1L
if (channelId > 0L) {
try {
contentResolver.notifyChange(TvContractCompat.buildChannelUri(channelId), null)
} catch (e: SecurityException) {
Log.w(TAG, "clear(): lost provider permission, cannot notify channel $channelId", e)
}
}
// Remove per-channel pref
prefs.edit().remove("programIds_$channelIdStr").apply()
}
Log.d(TAG, "clear(): deleted $deletedPrograms preview program(s)") Log.d(TAG, "clear(): deleted $deletedPrograms preview program(s)")
} }
if (channelId > 0L) { // Also handle legacy format (flat { providerId: programId }) for migration
contentResolver.notifyChange(TvContractCompat.buildChannelUri(channelId), null) val legacyProgramIds = prefs.getString("legacy_" + KEY_PROGRAM_IDS, null)?.let(::JSONObject)
Log.d(TAG, "clear(): notified channel $channelId") if (legacyProgramIds != null) {
val keys = legacyProgramIds.keys()
while (keys.hasNext()) {
val key = keys.next()
val programId = legacyProgramIds.optLong(key, -1L)
if (programId > 0L) {
deletePreviewProgram(contentResolver, programId)
}
}
prefs.edit().remove("legacy_" + KEY_PROGRAM_IDS).apply()
} }
prefs.edit() prefs.edit()
@@ -96,27 +126,93 @@ internal object TvRecommendationsPublisher {
return true return true
} }
/**
* Delete a single preview program from the TvProvider.
* Called by clear(), synchronize() (stale programs), and TvRecommendationsReceiver (user removed).
*/
fun deletePreviewProgram(context: Context, programId: Long) {
try {
context.contentResolver.delete(
TvContractCompat.buildPreviewProgramUri(programId),
null,
null
)
Log.d(TAG, "deletePreviewProgram(): deleted programId=$programId")
// Also remove from stored programIds prefs
removeProgramFromPrefs(context, programId)
} catch (e: SecurityException) {
Log.w(TAG, "deletePreviewProgram(): lost provider permission for programId=$programId", e)
}
}
private fun deletePreviewProgram(contentResolver: android.content.ContentResolver, programId: Long) {
try {
contentResolver.delete(
TvContractCompat.buildPreviewProgramUri(programId),
null,
null
)
} catch (e: SecurityException) {
Log.w(TAG, "deletePreviewProgram(): lost provider permission for programId=$programId", e)
}
}
private fun removeProgramFromPrefs(context: Context, programId: Long) {
val prefs = preferences(context)
val programIdsJson = prefs.getString(KEY_PROGRAM_IDS, null) ?: return
try {
val programIds = JSONObject(programIdsJson)
val keys = programIds.keys()
while (keys.hasNext()) {
val key = keys.next()
if (programIds.optLong(key, -1L) == programId) {
programIds.remove(key)
break
}
}
prefs.edit().putString(KEY_PROGRAM_IDS, programIds.toString()).apply()
} catch (e: Exception) {
Log.w(TAG, "removeProgramFromPrefs(): failed to update prefs for programId=$programId", e)
}
}
private fun synchronize(context: Context, payload: JSONObject): Boolean { private fun synchronize(context: Context, payload: JSONObject): Boolean {
val sections = payload.optJSONArray("sections") ?: JSONArray() val sections = payload.optJSONArray("sections") ?: JSONArray()
val firstSection = if (sections.length() > 0) sections.optJSONObject(0) else null if (sections.length() == 0) {
val sectionTitle = firstSection?.optString("title")?.takeIf { it.isNotBlank() } ?: DEFAULT_CHANNEL_NAME Log.w(TAG, "synchronize(): no sections in payload")
val items = firstSection?.optJSONArray("items") ?: JSONArray() return false
}
val prefs = preferences(context)
val allNextProgramIds = JSONObject()
var totalActive = 0
var totalDeleted = 0
for (sectionIndex in 0 until sections.length()) {
val section = sections.optJSONObject(sectionIndex) ?: continue
val sectionTitle = section.optString("title")?.takeIf { it.isNotBlank() } ?: DEFAULT_CHANNEL_NAME
val items = section.optJSONArray("items") ?: JSONArray()
Log.d( Log.d(
TAG, TAG,
"synchronize(): using section \"$sectionTitle\" with ${items.length()} item(s)" "synchronize(): section \"$sectionTitle\" ($sectionIndex/${sections.length()}) with ${items.length()} item(s)"
) )
val channelId = getOrCreateChannel(context, sectionTitle) val channelId = getOrCreateChannel(context, sectionTitle)
if (channelId <= 0L) { if (channelId <= 0L) {
Log.w(TAG, "synchronize(): failed to get or create preview channel") Log.w(TAG, "synchronize(): failed to get or create channel for \"$sectionTitle\"")
return false continue
} }
Log.d(TAG, "synchronize(): publishing into channelId=$channelId") // Per Android docs: check channel.isBrowsable() and request if needed.
if (!isChannelBrowsable(context, channelId)) {
Log.d(TAG, "synchronize(): channel $channelId not browsable, requesting browsable")
TvContractCompat.requestChannelBrowsable(context, channelId)
}
val previousProgramIds = preferences(context) val prefKey = "programIds_$channelId"
.getString(KEY_PROGRAM_IDS, null) val previousProgramIds = prefs.getString(prefKey, null)
?.let(::JSONObject) ?.let(::JSONObject)
?: JSONObject() ?: JSONObject()
val nextProgramIds = JSONObject() val nextProgramIds = JSONObject()
@@ -150,44 +246,98 @@ internal object TvRecommendationsPublisher {
val programId = previousProgramIds.optLong(providerId, -1L) val programId = previousProgramIds.optLong(providerId, -1L)
if (programId > 0L) { if (programId > 0L) {
context.contentResolver.delete( deletePreviewProgram(context, programId)
TvContractCompat.buildPreviewProgramUri(programId),
null,
null
)
deletedPrograms += 1 deletedPrograms += 1
Log.d(TAG, "synchronize(): deleted stale programId=$programId for item=$providerId") Log.d(TAG, "synchronize(): deleted stale programId=$programId for item=$providerId")
} }
} }
preferences(context) allNextProgramIds.put(channelId.toString(), nextProgramIds.toString())
.edit() prefs.edit().putString(prefKey, nextProgramIds.toString()).apply()
.putLong(KEY_CHANNEL_ID, channelId) totalActive += activeProviderIds.size
.putString(KEY_PROGRAM_IDS, nextProgramIds.toString()) totalDeleted += deletedPrograms
.apply()
logProviderState(context, channelId) logProviderState(context, channelId)
}
// Store all channel program IDs for clear() to use
prefs.edit().putString(KEY_PROGRAM_IDS, allNextProgramIds.toString()).apply()
Log.d( Log.d(
TAG, TAG,
"synchronize(): completed with ${activeProviderIds.size} active program(s), deleted $deletedPrograms stale program(s)" "synchronize(): completed across ${sections.length()} section(s), $totalActive active program(s), deleted $totalDeleted stale program(s)"
) )
return true return true
} }
/**
* Query provider to check if a channel is browsable.
* Per Android docs: "check channel.isBrowsable() before updating programs."
*/
private fun isChannelBrowsable(context: Context, channelId: Long): Boolean {
return try {
context.contentResolver.query(
TvContractCompat.buildChannelUri(channelId),
null,
null,
null,
null
)?.use { cursor ->
if (cursor.moveToFirst()) {
val browsableIndex = cursor.getColumnIndex(TvContractCompat.Channels.COLUMN_BROWSABLE)
if (browsableIndex >= 0) cursor.getInt(browsableIndex) == 1 else true
} else {
false
}
} ?: false
} catch (e: SecurityException) {
Log.w(TAG, "isChannelBrowsable(): lost provider permission for channelId=$channelId", e)
true // Assume browsable if we can't check, to avoid blocking updates
}
}
/**
* Query provider to verify a channel actually exists.
* Prevents the channel-delete-recreate bug: if update() returns 0 rows
* we must first check whether the channel was deleted by the system
* or if the update simply failed for another reason.
*/
private fun channelExistsInProvider(context: Context, channelId: Long): Boolean {
return try {
context.contentResolver.query(
TvContractCompat.buildChannelUri(channelId),
null,
null,
null,
null
)?.use { cursor ->
cursor.moveToFirst()
} ?: false
} catch (e: SecurityException) {
Log.w(TAG, "channelExistsInProvider(): lost provider permission for channelId=$channelId", e)
false
}
}
private fun getOrCreateChannel(context: Context, displayName: String): Long { private fun getOrCreateChannel(context: Context, displayName: String): Long {
val prefs = preferences(context) val prefs = preferences(context)
val existingChannelId = prefs.getLong(KEY_CHANNEL_ID, -1L) val existingChannelId = prefs.getLong(KEY_CHANNEL_ID, -1L)
val contentResolver = context.contentResolver val contentResolver = context.contentResolver
if (existingChannelId > 0L) { if (existingChannelId > 0L) {
// Query provider first to verify channel actually exists (prevents recreate bug)
val exists = channelExistsInProvider(context, existingChannelId)
if (exists) {
// Channel exists — update it in place, never recreate
val updated = Channel.Builder() val updated = Channel.Builder()
.setType(TvContractCompat.Channels.TYPE_PREVIEW) .setType(TvContractCompat.Channels.TYPE_PREVIEW)
.setDisplayName(displayName) .setDisplayName(displayName)
.setAppLinkIntentUri(buildIntentUri(context, "streamyfin://")) .setAppLinkIntentUri(buildIntentUri(context, "streamyfin://"))
.build() .build()
try {
val updatedRows = contentResolver.update( val updatedRows = contentResolver.update(
TvContractCompat.buildChannelUri(existingChannelId), TvContractCompat.buildChannelUri(existingChannelId),
updated.toContentValues(), updated.toContentValues(),
@@ -202,20 +352,36 @@ internal object TvRecommendationsPublisher {
return existingChannelId return existingChannelId
} }
Log.w(TAG, "getOrCreateChannel(): stored channelId=$existingChannelId was stale, recreating") // Update returned 0 rows but channel exists — log and return existing ID, don't recreate
Log.e(TAG, "getOrCreateChannel(): update returned 0 for existing channelId=$existingChannelId but channel exists — not recreating")
return existingChannelId
} catch (e: SecurityException) {
Log.w(TAG, "getOrCreateChannel(): lost provider permission updating channelId=$existingChannelId", e)
return existingChannelId
}
}
// Channel truly doesn't exist in provider — recreate
Log.w(TAG, "getOrCreateChannel(): channelId=$existingChannelId not in provider, recreating")
prefs.edit().remove(KEY_CHANNEL_ID).apply() prefs.edit().remove(KEY_CHANNEL_ID).apply()
} }
// Create a new channel
val channel = Channel.Builder() val channel = Channel.Builder()
.setType(TvContractCompat.Channels.TYPE_PREVIEW) .setType(TvContractCompat.Channels.TYPE_PREVIEW)
.setDisplayName(displayName) .setDisplayName(displayName)
.setAppLinkIntentUri(buildIntentUri(context, "streamyfin://")) .setAppLinkIntentUri(buildIntentUri(context, "streamyfin://"))
.build() .build()
val channelUri = contentResolver.insert( val channelUri = try {
contentResolver.insert(
TvContractCompat.Channels.CONTENT_URI, TvContractCompat.Channels.CONTENT_URI,
channel.toContentValues() channel.toContentValues()
) ?: return -1L )
} catch (e: SecurityException) {
Log.e(TAG, "getOrCreateChannel(): lost provider permission, cannot create channel", e)
null
} ?: return -1L
val channelId = ContentUris.parseId(channelUri) val channelId = ContentUris.parseId(channelUri)
TvContractCompat.requestChannelBrowsable(context, channelId) TvContractCompat.requestChannelBrowsable(context, channelId)
@@ -249,17 +415,19 @@ internal object TvRecommendationsPublisher {
builder.setDescription(it) builder.setDescription(it)
} }
// Per Android docs: use unique URIs for all images to avoid stale cache
imageUrl.takeIf { it.isNotBlank() }?.let { imageUrl.takeIf { it.isNotBlank() }?.let {
val imageUri = Uri.parse(it) val uniqueImageUrl = appendCacheBuster(it)
val imageUri = Uri.parse(uniqueImageUrl)
builder.setPosterArtUri(imageUri) builder.setPosterArtUri(imageUri)
builder.setThumbnailUri(imageUri) builder.setThumbnailUri(imageUri)
} }
val contentValues = builder.build().toContentValues() val contentValues = builder.build().toContentValues()
val contentResolver = context.contentResolver val contentResolver = context.contentResolver
if (previousProgramId > 0L) { if (previousProgramId > 0L) {
try {
val updatedRows = contentResolver.update( val updatedRows = contentResolver.update(
TvContractCompat.buildPreviewProgramUri(previousProgramId), TvContractCompat.buildPreviewProgramUri(previousProgramId),
contentValues, contentValues,
@@ -273,18 +441,36 @@ internal object TvRecommendationsPublisher {
} }
Log.w(TAG, "upsertPreviewProgram(): existing programId=$previousProgramId was stale, inserting new row") Log.w(TAG, "upsertPreviewProgram(): existing programId=$previousProgramId was stale, inserting new row")
} catch (e: SecurityException) {
Log.w(TAG, "upsertPreviewProgram(): lost provider permission for programId=$previousProgramId", e)
}
} }
val insertedUri = contentResolver.insert( val insertedUri = try {
contentResolver.insert(
TvContractCompat.PreviewPrograms.CONTENT_URI, TvContractCompat.PreviewPrograms.CONTENT_URI,
contentValues contentValues
) ?: return -1L )
} catch (e: SecurityException) {
Log.w(TAG, "upsertPreviewProgram(): lost provider permission, cannot insert program", e)
null
} ?: return -1L
val programId = ContentUris.parseId(insertedUri) val programId = ContentUris.parseId(insertedUri)
Log.d(TAG, "upsertPreviewProgram(): inserted new programId=$programId") Log.d(TAG, "upsertPreviewProgram(): inserted new programId=$programId")
return programId return programId
} }
/**
* Append a cache-busting parameter to ensure unique URIs when images change.
* Per Android docs: "Use unique Uris for all images... the old image will
* continue to appear if you don't change the Uri."
*/
private fun appendCacheBuster(imageUrl: String): String {
val separator = if (imageUrl.contains("?")) "&" else "?"
return "$imageUrl${separator}_t=${System.currentTimeMillis()}"
}
private fun buildIntentUri(context: Context, deepLink: String): Uri { private fun buildIntentUri(context: Context, deepLink: String): Uri {
val intent = Intent(Intent.ACTION_VIEW).apply { val intent = Intent(Intent.ACTION_VIEW).apply {
data = Uri.parse(deepLink) data = Uri.parse(deepLink)
@@ -306,6 +492,7 @@ internal object TvRecommendationsPublisher {
private fun storeChannelLogo(context: Context, channelId: Long) { private fun storeChannelLogo(context: Context, channelId: Long) {
val bitmap = applicationIconBitmap(context) ?: return val bitmap = applicationIconBitmap(context) ?: return
try {
val outputStream = context.contentResolver.openOutputStream( val outputStream = context.contentResolver.openOutputStream(
TvContractCompat.buildChannelLogoUri(channelId) TvContractCompat.buildChannelLogoUri(channelId)
) ?: return ) ?: return
@@ -314,6 +501,9 @@ internal object TvRecommendationsPublisher {
bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream) bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream)
stream.flush() stream.flush()
} }
} catch (e: SecurityException) {
Log.w(TAG, "storeChannelLogo(): lost provider permission for channelId=$channelId", e)
}
} }
private fun applicationIconBitmap(context: Context): Bitmap? { private fun applicationIconBitmap(context: Context): Bitmap? {
@@ -341,9 +531,14 @@ internal object TvRecommendationsPublisher {
return bitmap return bitmap
} }
fun getChannelId(context: Context): Long {
return preferences(context).getLong(KEY_CHANNEL_ID, -1L)
}
private fun preferences(context: Context): SharedPreferences { private fun preferences(context: Context): SharedPreferences {
return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
} }
private fun logProviderState(context: Context, channelId: Long) { private fun logProviderState(context: Context, channelId: Long) {
val contentResolver = context.contentResolver val contentResolver = context.contentResolver
@@ -372,8 +567,8 @@ internal object TvRecommendationsPublisher {
Log.w(TAG, "logProviderState(): channelId=$channelId exists=false") Log.w(TAG, "logProviderState(): channelId=$channelId exists=false")
} }
} ?: Log.w(TAG, "logProviderState(): channel query returned null for channelId=$channelId") } ?: Log.w(TAG, "logProviderState(): channel query returned null for channelId=$channelId")
} catch (error: Exception) { } catch (error: SecurityException) {
Log.w(TAG, "logProviderState(): failed to query channelId=$channelId", error) Log.w(TAG, "logProviderState(): lost provider permission for channelId=$channelId", error)
} }
} }
} }

View File

@@ -1,12 +1,19 @@
import { requireNativeView } from "expo"; import { requireNativeView } from "expo";
import * as React from "react"; import * as React from "react";
import type { View } from "react-native"; import type { View } from "react-native";
import { Platform } from "react-native";
import type { TvSearchViewProps } from "./TvSearchView.types"; import type { TvSearchViewProps } from "./TvSearchView.types";
// The native TvSearchModule is Apple-only (tvOS SwiftUI `.searchable`).
// On Android the component is never rendered, but we must avoid calling
// `requireNativeView` at module-scope because it would crash on import.
const NativeView: React.ComponentType< const NativeView: React.ComponentType<
TvSearchViewProps & React.RefAttributes<View> TvSearchViewProps & React.RefAttributes<View>
> = requireNativeView("TvSearchModule"); > =
Platform.OS === "ios"
? requireNativeView("TvSearchModule")
: ((() => null) as any);
/** /**
* Forwards its ref to the underlying native view so it can be used as a * Forwards its ref to the underlying native view so it can be used as a

View File

@@ -126,7 +126,7 @@
"@react-native-tvos/config-tv": "0.1.6", "@react-native-tvos/config-tv": "0.1.6",
"@types/jest": "29.5.14", "@types/jest": "29.5.14",
"@types/lodash": "4.17.24", "@types/lodash": "4.17.24",
"@types/react": "19.2.17", "@types/react": "~19.2.10",
"@types/react-test-renderer": "19.1.0", "@types/react-test-renderer": "19.1.0",
"cross-env": "10.1.0", "cross-env": "10.1.0",
"expo-doctor": "1.19.7", "expo-doctor": "1.19.7",

View File

@@ -1,236 +0,0 @@
#!/usr/bin/env bun
/**
* Flags likely-duplicate issues when a new issue is opened, using lexical similarity
* (Jaccard over word sets of the title and body) — no API key, no embeddings.
*
* On a match it posts ONE comment listing the closest open issues and adds the
* "possible duplicate" label. If nothing is similar enough, it does nothing.
*
* Env:
* GITHUB_REPOSITORY owner/repo
* ISSUE_NUMBER the new issue number
* ISSUE_TITLE the new issue title
* ISSUE_BODY the new issue body
* GH_TOKEN/GITHUB_TOKEN for gh (provided in CI)
* DUP_THRESHOLD similarity threshold 0..1 (default 0.3)
* DUP_MAX max matches to report (default 5)
* DUP_FIXTURE optional path to a JSON array of {number,title,body} (local testing)
* DRY_RUN if set, print results instead of commenting/labelling
*/
import { execFileSync } from "node:child_process";
import { readFileSync } from "node:fs";
// Parse a numeric env var, falling back to `def` only when unset/empty/NaN so an explicit 0 is honoured.
const numEnv = (name, def) => {
const raw = process.env[name];
if (raw === undefined || raw === "") return def;
const n = Number(raw);
return Number.isNaN(n) ? def : n;
};
const REPO = process.env.GITHUB_REPOSITORY || "streamyfin/streamyfin";
const NUMBER = numEnv("ISSUE_NUMBER", Number.NaN);
const TITLE = process.env.ISSUE_TITLE || "";
const BODY = process.env.ISSUE_BODY || "";
const THRESHOLD = numEnv("DUP_THRESHOLD", 0.3);
const MAX = numEnv("DUP_MAX", 5);
const DRY = !!process.env.DRY_RUN;
const LABEL = "possible duplicate";
const MARKER = "<!-- duplicate-detector -->";
// Generic stop words only — keep domain/feature/platform words (android, downloads,
// subtitles…) since those are exactly what makes two reports the same or different.
const STOP = new Set(
(
"a an the and or but if then of to in on at by for with from as is are was were be been being do does did " +
"it its this that these those i you we they me my your our their he she him her " +
"when while where what which who how why so just then than too very can could would should will " +
"not no nor only own same s t don dont im ive please thanks hi hello also still get got use used using " +
"app application streamyfin issue bug"
).split(/\s+/),
);
const stem = (w) => w.replace(/(ing|ed|es|s)$/, "");
const tokens = (s) =>
(s || "")
.toLowerCase()
.replace(/```[\s\S]*?```/g, " ") // drop code blocks
.replace(/<!--[\s\S]*?-->/g, " ") // drop html comments
.replace(/https?:\/\/\S+/g, " ") // drop urls
.replace(/[^a-z0-9\s]/g, " ")
.split(/\s+/)
.filter((w) => w.length > 2 && !STOP.has(w))
.map(stem)
.filter((w) => w.length > 2);
const jaccard = (a, b) => {
const A = new Set(a);
const B = new Set(b);
if (!A.size || !B.size) return 0;
let inter = 0;
for (const x of A) if (B.has(x)) inter++;
return inter / (A.size + B.size - inter);
};
const newTitle = tokens(TITLE);
const newBody = tokens(BODY);
const score = (o) =>
0.6 * jaccard(newTitle, tokens(o.title)) +
0.4 * jaccard(newBody, tokens(o.body));
// fetch open issues (excluding PRs and the new issue itself)
let issues;
if (process.env.DUP_FIXTURE) {
issues = JSON.parse(readFileSync(process.env.DUP_FIXTURE, "utf8"));
} else {
const raw = execFileSync(
"gh",
[
"api",
`repos/${REPO}/issues`,
"--paginate",
"-X",
"GET",
"-f",
"state=open",
"-f",
"per_page=100",
"--jq",
".[] | select(.pull_request | not) | {number, title, body}",
],
{ encoding: "utf8", maxBuffer: 1e8 },
);
issues = raw
.split("\n")
.filter(Boolean)
.map((l) => JSON.parse(l));
}
const matches = issues
.filter((o) => o.number !== NUMBER)
.map((o) => ({ ...o, s: score(o) }))
.filter((o) => o.s >= THRESHOLD)
.sort((a, b) => b.s - a.s)
.slice(0, MAX);
if (!matches.length) {
console.log("No likely duplicates found.");
process.exit(0);
}
// Neutralise other issues' titles before echoing them back: break @mentions and
// strip markdown/HTML control chars so a maliciously-named issue can't ping people
// or inject formatting into our comment. GitHub linkifies "#123" on its own.
const safeTitle = (t) =>
(t || "")
.replace(/@/g, "@")
.replace(/[`<>|*_~[\]]/g, " ")
.replace(/\s+/g, " ")
.trim()
.slice(0, 140);
const list = matches
.map(
(m) =>
`- #${m.number}${safeTitle(m.title)} (≈ ${Math.round(m.s * 100)}% similar)`,
)
.join("\n");
const comment = [
MARKER,
"🔍 **This looks like it might be a duplicate.** Possibly related open issues:",
"",
list,
"",
"If yours is different, ignore this — a maintainer will confirm. Otherwise, please 👍 the existing issue and add any extra details there.",
].join("\n");
console.log(`Found ${matches.length} possible duplicate(s):\n${list}`);
if (DRY) {
console.log("\nDRY_RUN: not commenting/labelling.");
process.exit(0);
}
// Live mode needs a real issue number; refuse rather than POST to /issues/NaN/...
if (!Number.isInteger(NUMBER) || NUMBER <= 0) {
console.error(
`Invalid ISSUE_NUMBER ${JSON.stringify(process.env.ISSUE_NUMBER)} — refusing to comment.`,
);
process.exit(1);
}
// Idempotency: skip if we've already flagged this issue (guards re-runs / future triggers).
const priorComments = execFileSync(
"gh",
[
"api",
`repos/${REPO}/issues/${NUMBER}/comments`,
"--paginate",
"--jq",
".[].body",
],
{ encoding: "utf8", maxBuffer: 1e8 },
);
if (priorComments.includes(MARKER)) {
console.log("Already flagged (marker present); skipping.");
process.exit(0);
}
execFileSync(
"gh",
[
"api",
"-X",
"POST",
`repos/${REPO}/issues/${NUMBER}/comments`,
"-f",
`body=${comment}`,
],
{ stdio: "ignore" },
);
try {
execFileSync(
"gh",
[
"api",
"-X",
"POST",
`repos/${REPO}/issues/${NUMBER}/labels`,
"-f",
`labels[]=${LABEL}`,
],
{ stdio: "ignore" },
);
} catch {
// label may not exist yet — create then add
execFileSync(
"gh",
[
"api",
"-X",
"POST",
`repos/${REPO}/labels`,
"-f",
`name=${LABEL}`,
"-f",
"color=fbca04",
"-f",
"description=Automatically flagged as a possible duplicate",
],
{ stdio: "ignore" },
);
execFileSync(
"gh",
[
"api",
"-X",
"POST",
`repos/${REPO}/issues/${NUMBER}/labels`,
"-f",
`labels[]=${LABEL}`,
],
{ stdio: "ignore" },
);
}
console.log("Commented and labelled.");