Compare commits

..

6 Commits

Author SHA1 Message Date
Gauvain
7c1900a27d fix(issue-form): bound the gh release fetch with a 30s timeout 2026-06-10 20:24:35 +02:00
Gauvain
888b8bb342 fix(issue-form): pin checkout to develop, serialize runs, simplify release fetch
- `release` events check out the tagged commit, so the script regenerated
  the form from the tag's stale copy and the bot PR could revert form
  edits made on develop since that release - checkout develop explicitly.
- Fixed concurrency group: a release event and the weekly cron no longer
  race on the same ci/update-issue-form branch (queue, no cancel).
- Replace `gh api --paginate` (fetches every release page) with
  `gh release list --exclude-drafts --exclude-pre-releases --limit N`.
- Document the GITHUB_TOKEN limitation on the auto-merge step: bot PRs
  don't trigger required checks; close/reopen kicks them, then auto-merge
  completes after reviews.
2026-06-10 20:20:56 +02:00
Gauvain
1c8a0ac35e Merge remote-tracking branch 'origin/develop' into ci/auto-update-issue-form
# Conflicts:
#	.github/ISSUE_TEMPLATE/issue_report.yml
2026-06-10 20:11:27 +02:00
Gauvino
e4def1f2a1 docs(issue-form): guide TestFlight/dev users to report the exact build
The version dropdown lists published releases only, so a TestFlight or dev
build will not appear in it. Point those reporters to the
"TestFlight/Development build" option and the exact version shown in the
app's Settings.
2026-06-05 13:38:52 +02:00
Gauvino
55376cd824 fix(issue-form): list published releases only, drop TestFlight draft entries
Per maintainer feedback (fredrikburmester, lancechant): the version dropdown
no longer surfaces draft releases as "X (TestFlight)" — drafts and prereleases
are unreliable version identifiers. update-issue-form.mjs now fetches only
published, non-prerelease releases and drops the per-version TestFlight label;
the workflow triggers on `released` events only; and the generic
"TestFlight/Development build" sentinel is restored at the end of the list.
2026-06-05 13:30:47 +02:00
Gauvino
3c8369ea4d ci(issue-form): auto-populate version dropdown from GitHub releases
Replaces the broken update workflow (it targeted a non-existent bug_report.yml
via the npm-based populate action; Streamyfin isn't on npm) with
scripts/update-issue-form.mjs (Bun, dep-free): reads release tags via gh and
rewrites the 'version' dropdown in issue_report.yml, preserving sentinels
(older, TestFlight/Development build), newest-first, capped at
ISSUE_FORM_LIMIT (5) — so each new release rotates the oldest entry out.

Drafts/prereleases are included on purpose: release.yml drafts
v<app.json version> for every TestFlight (iOS) / beta (Android) build, and the
app shows that nativeApplicationVersion to users (UserInfo.tsx), so those
in-flight versions must be selectable. Runs on release events + a weekly cron
safety net + workflow_dispatch, opens a PR and enables auto-merge. Seeds the
current versions.
2026-06-01 16:53:53 +02:00
30 changed files with 567 additions and 637 deletions

View File

@@ -75,10 +75,13 @@ body:
id: version
attributes:
label: Streamyfin Version
description: What version of Streamyfin are you using?
description: What version of Streamyfin are you running? On a TestFlight or development build, choose "TestFlight/Development build" and include the exact version string shown in the app's Settings.
options:
- 0.54.1
- 0.51.0
- 0.47.1
- 0.30.2
- 0.28.0
- Older
- TestFlight/Development build
validations:

View File

@@ -42,7 +42,7 @@ and provides seamless media streaming with offline capabilities and Chromecast s
## Coding Standards
- Use TypeScript for ALL files (no .js files). Tooling-required exceptions: `babel.config.js`, `metro.config.js`, `react-native.config.js`, `tailwind.config.js` (their loaders cannot parse TypeScript)
- Use TypeScript for ALL files (no .js files)
- Use descriptive English names for variables, functions, and components
- Prefer functional React components with hooks
- Use Jotai atoms for global state management

View File

@@ -12,13 +12,10 @@ on:
branches: [develop, master]
# Exposed to `expo prebuild` (app.config.js → extra.build) so Settings can show the
# branch + commit + Actions run a CI build was made from. EAS cloud builds use
# EAS_BUILD_* instead. The run number maps a sideloaded build back to its Actions
# run (artifacts + logs) without needing Expo access.
# branch + commit a CI build was made from. EAS cloud builds use EAS_BUILD_* instead.
env:
EXPO_PUBLIC_GIT_BRANCH: ${{ github.head_ref || github.ref_name }}
EXPO_PUBLIC_GIT_COMMIT: ${{ github.sha }}
EXPO_PUBLIC_GIT_RUN_NUMBER: ${{ github.run_number }}
jobs:
build-android-phone:
@@ -240,9 +237,7 @@ jobs:
- name: 🚀 Build iOS app
env:
EXPO_TV: 0
# `ci` profile (extends production, autoIncrement off): keeps CI builds out of
# the production version tier and stops them inflating the store build counter.
run: eas build -p ios --local --non-interactive --profile ci
run: eas build -p ios --local --non-interactive
- name: 📅 Set date tag
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
@@ -367,7 +362,7 @@ jobs:
- name: 🚀 Build iOS app
env:
EXPO_TV: 1
run: eas build -p ios --local --non-interactive --profile ci_tv
run: eas build -p ios --local --non-interactive
- name: 📅 Set date tag
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV

View File

@@ -17,7 +17,7 @@ jobs:
pull-requests: write
steps:
- name: 🚩 Apply merge conflict label
uses: eps1lon/actions-label-merge-conflict@0273be72a0bbd58fcd71d0d6c02c209b50d1e5e1 # v3.1.0
uses: eps1lon/actions-label-merge-conflict@1df065ebe6e3310545d4f4c4e862e43bdca146f0 # v3.0.3
with:
dirtyLabel: '⚔️ merge-conflict'
commentOnDirty: 'This pull request has merge conflicts. Please resolve the conflicts so the PR can be successfully reviewed and merged.'

View File

@@ -29,7 +29,7 @@ jobs:
bun-version: latest
- name: 🔍 Detect duplicate issues
run: bun scripts/detect-duplicate-issue.ts
run: bun scripts/detect-duplicate-issue.mjs
env:
GH_TOKEN: ${{ github.token }}
GITHUB_REPOSITORY: ${{ github.repository }}

View File

@@ -1,67 +1,102 @@
name: 🐛 Update Bug Report Template
name: 🐛 Update Issue Form Versions
on:
release:
types: [published] # Run on every published release on any branch
# Only full releases populate the dropdown (no drafts/prereleases).
types: [released]
schedule:
- cron: "0 3 * * 1" # Weekly safety net (Mondays 03:00 UTC) in case a release event was missed
workflow_dispatch:
# Fixed group so a release event and the weekly cron can't race on the same
# ci/update-issue-form branch — runs queue instead of force-pushing over each other.
concurrency:
group: update-issue-form-${{ github.event.release.tag_name || github.run_id }}
cancel-in-progress: true
group: update-issue-form
cancel-in-progress: false
permissions:
contents: read
jobs:
update-bug-report:
update-issue-form:
name: 🔢 Populate version dropdown
runs-on: ubuntu-24.04
permissions:
contents: write
pull-requests: write
issues: write
runs-on: ubuntu-24.04
steps:
- name: 📥 Checkout repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: "🟢 Setup Node.js"
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: '24.x'
cache: 'npm'
# On `release` events GITHUB_SHA is the tagged commit — without this the
# script would regenerate the form from the tag's (stale) copy and the bot
# PR would revert any form edits made on develop since that release.
ref: develop
- name: 🔍 Extract minor version from app.json
id: minor
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # main
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
result-encoding: string
script: |
const fs = require('fs-extra');
const semver = require('semver');
const content = fs.readJsonSync('./app.json');
const version = content.expo.version;
const minorVersion = semver.minor(version);
return minorVersion.toString();
bun-version: latest
- name: 📝 Update bug report version
uses: ShaMan123/gha-populate-form-version@be012141ca560dbb92156e3fe098c46035f6260d #v2.0.5
with:
semver: '^0.${{ steps.minor.outputs.result }}.0'
dry_run: no-push
- name: 🔢 Populate version dropdown from GitHub releases
id: populate
run: bun scripts/update-issue-form.mjs
env:
GH_TOKEN: ${{ github.token }}
GITHUB_REPOSITORY: ${{ github.repository }}
- name: ⚙️ Update bug report node version dropdown
uses: ShaMan123/gha-populate-form-version@be012141ca560dbb92156e3fe098c46035f6260d #v2.0.5
with:
dropdown: _node_version
package: node
semver: '>=24.0.0'
dry_run: no-push
- name: 📬 Commit and create pull request
- name: 📬 Create pull request
id: cpr
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
with:
add-paths: .github/ISSUE_TEMPLATE/bug_report.yml
branch: ci-update-bug-report
add-paths: .github/ISSUE_TEMPLATE/issue_report.yml
branch: ci/update-issue-form
base: develop
delete-branch: true
labels: ⚙️ ci, 🤖 github-actions
title: 'chore(): Update bug report template to match release version'
commit-message: "chore: update issue form version dropdown"
title: "chore: update issue form version dropdown"
# Follows .github/pull_request_template.md so the bot PR isn't flagged by PR validation.
body: |
Automated update to `.github/ISSUE_TEMPLATE/bug_report.yml`
Triggered by workflow run [${{ github.run_id }}](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }})
# 📦 Pull Request
## 📝 Description
Automated update of the **Streamyfin Version** dropdown in `.github/ISSUE_TEMPLATE/issue_report.yml`, populated from the latest published GitHub releases by `scripts/update-issue-form.mjs`.
**Version dropdown now lists:** ${{ steps.populate.outputs.versions }}
Triggered by `${{ github.event_name }}`${{ github.event.release.tag_name && format(' — release {0}', github.event.release.tag_name) || '' }} · [run ${{ github.run_id }}](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}).
## 🏷️ Ticket / Issue
N/A — automated maintenance.
### 🖼️ Screenshots / GIFs (if UI)
N/A — issue-template metadata only, no app UI.
## ✅ Checklist
- [x] Ive read the [contribution guidelines](CONTRIBUTING.md)
- [x] Verified that changes behave as expected for all platforms
- [x] Code passes lint/formatting and type checks (`tsc`/`biome`)
- [x] No secrets, hardcoded credentials, or private config files are included
- [x] I've declared if AI was used to assist with this PR (by uncommenting the line at the bottom, or not)
## 🔍 Testing Instructions
N/A — generated by CI from published releases; review the dropdown diff in `issue_report.yml`.
- name: 🔀 Enable auto-merge
if: steps.cpr.outputs.pull-request-operation == 'created'
env:
GH_TOKEN: ${{ github.token }}
# Known limitation: PRs created with GITHUB_TOKEN don't trigger CI workflows
# (GitHub anti-recursion), so the required checks stay "Expected" until a
# maintainer kicks them (close/reopen the PR, or push an empty commit).
# Auto-merge is still worth enabling: once checks run and reviews land,
# the PR merges itself.
run: |
gh pr merge --squash --auto "${{ steps.cpr.outputs.pull-request-number }}" \
|| echo "::warning::Could not enable auto-merge — enable 'Allow auto-merge' in repo settings (and branch protection); merge the PR manually for now."

4
.gitignore vendored
View File

@@ -12,6 +12,10 @@ web-build/
# Platform-specific Build Directories
/ios
/android
/iostv
/iosmobile
/androidmobile
/androidtv
# Gradle caches (top-level + per-module native projects)
**/.gradle/

View File

@@ -152,7 +152,7 @@ import { apiAtom } from "@/providers/JellyfinProvider";
## Coding Standards
- Use TypeScript for all files (no .js). Tooling-required exceptions: `babel.config.js`, `metro.config.js`, `react-native.config.js`, `tailwind.config.js` (their loaders cannot parse TypeScript)
- Use TypeScript for all files (no .js)
- Use functional React components with hooks
- Use Jotai atoms for global state, React Query for server state
- Follow BiomeJS formatting rules (2-space indent, semicolons, LF line endings)

View File

@@ -33,12 +33,6 @@ const buildMeta = {
process.env.EAS_BUILD_PROFILE ||
process.env.EXPO_PUBLIC_BUILD_PROFILE ||
null,
// GitHub Actions run number (#2098) — lets anyone map a sideloaded CI build back
// to its Actions run (artifacts + logs) without Expo access. Null outside CI.
runNumber:
process.env.GITHUB_RUN_NUMBER ||
process.env.EXPO_PUBLIC_GIT_RUN_NUMBER ||
null,
builtAt: new Date().toISOString(),
};

View File

@@ -2,7 +2,7 @@ import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { useAtom } from "jotai";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Platform, ScrollView, TextInput, View } from "react-native";
import { ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import { TVDiscover } from "@/components/jellyseerr/discover/TVDiscover";
@@ -231,48 +231,26 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
paddingTop: insets.top + TOP_PADDING,
}}
>
{/* Search bar: native tvOS SwiftUI `.searchable` on Apple TV, standard
TextInput fallback on Android TV (the native module is Apple-only). */}
{Platform.OS === "ios" ? (
<View
style={{
marginBottom: 24,
height: SEARCH_AREA_HEIGHT,
}}
>
{/* No horizontal margin here: the native tvOS search bar centers
itself and renders a trailing "Hold to Dictate" hint. */}
<TvSearchView
style={{ width: "100%", height: "100%" }}
placeholder={t("search.search")}
onChangeText={(e) => setSearch(e.nativeEvent.text)}
/>
</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>
)}
{/* Native tvOS search field (SwiftUI `.searchable`, our `tv-search`
module). It renders the native search bar + grid keyboard and
forwards typed text into the existing query pipeline via setSearch;
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
style={{
marginBottom: 24,
height: SEARCH_AREA_HEIGHT,
}}
>
<TvSearchView
style={{ width: "100%", height: "100%" }}
placeholder={t("search.search")}
onChangeText={(e) => setSearch(e.nativeEvent.text)}
/>
</View>
</View>
<ScrollView

View File

@@ -14,7 +14,7 @@ export const UserInfo: React.FC<Props> = ({ ...props }) => {
const { t } = useTranslation();
// Graduated build identifier — see utils/version.ts:
// dev → "0.54.1 · branch · commit", develop/CI → "0.54.1 · commit · #run", production → "0.54.1".
// dev → "0.54.1 · branch · commit", develop/CI → "0.54.1 · commit", production → "0.54.1 (42)".
const { display: version } = getVersionInfo();
return (

View File

@@ -3,13 +3,9 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
const MediaTypes = {
export default {
Audio: "Audio",
Video: "Video",
Photo: "Photo",
Book: "Book",
} as const;
export type MediaType = (typeof MediaTypes)[keyof typeof MediaTypes];
export default MediaTypes;
};

View File

@@ -97,14 +97,6 @@
"credentialsSource": "local",
"config": "ios-production.yml"
}
},
"ci": {
"extends": "production",
"autoIncrement": false
},
"ci_tv": {
"extends": "production_tv",
"autoIncrement": false
}
},
"submit": {

View File

View File

@@ -1,12 +1,10 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<receiver android:name=".TvRecommendationsReceiver" android:exported="true">
<receiver
android:name=".TvRecommendationsReceiver"
android:exported="true">
<intent-filter>
<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>
</receiver>
</application>

View File

@@ -16,13 +16,12 @@ import androidx.tvprovider.media.tv.PreviewProgram
import androidx.tvprovider.media.tv.TvContractCompat
import org.json.JSONArray
import org.json.JSONObject
import java.security.MessageDigest
internal object TvRecommendationsPublisher {
private const val TAG = "TvRecommendations"
private const val PREFS_NAME = "StreamyfinTvRecommendations"
private const val KEY_PAYLOAD = "payload"
private const val KEY_CHANNEL_ID_PREFIX = "channelId_"
private const val KEY_CHANNEL_ID = "channelId"
private const val KEY_PROGRAM_IDS = "programIds"
private const val DEFAULT_CHANNEL_NAME = "Continue and Next Up"
@@ -62,61 +61,31 @@ internal object TvRecommendationsPublisher {
fun clear(context: Context): Boolean {
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
// KEY_PROGRAM_IDS is now { channelId: "{ providerId: programId }" }
val allProgramIds = prefs.getString(KEY_PROGRAM_IDS, null)?.let(::JSONObject)
if (allProgramIds != null) {
if (programIds != null) {
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()
while (keys.hasNext()) {
val providerId = keys.next()
val programId = programIds.optLong(providerId, -1L)
if (programId > 0L) {
deletePreviewProgram(contentResolver, programId)
deletedPrograms += 1
}
}
} catch (e: Exception) {
Log.w(TAG, "clear(): failed to parse programIds for channel $channelIdStr", e)
val keys = programIds.keys()
while (keys.hasNext()) {
val key = keys.next()
val programId = programIds.optLong(key, -1L)
if (programId > 0L) {
contentResolver.delete(
TvContractCompat.buildPreviewProgramUri(programId),
null,
null
)
deletedPrograms += 1
}
// 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)")
}
// Also handle legacy format (flat { providerId: programId }) for migration
val legacyProgramIds = prefs.getString("legacy_" + KEY_PROGRAM_IDS, null)?.let(::JSONObject)
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()
if (channelId > 0L) {
contentResolver.notifyChange(TvContractCompat.buildChannelUri(channelId), null)
Log.d(TAG, "clear(): notified channel $channelId")
}
prefs.edit()
@@ -127,274 +96,128 @@ internal object TvRecommendationsPublisher {
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 channelMap = JSONObject(programIdsJson)
val channelKeys = channelMap.keys()
while (channelKeys.hasNext()) {
val channelId = channelKeys.next()
val inner = channelMap.optJSONObject(channelId) ?: continue
val providerKeys = inner.keys()
while (providerKeys.hasNext()) {
val providerId = providerKeys.next()
if (inner.optLong(providerId, -1L) == programId) {
inner.remove(providerId)
if (inner.length() == 0) {
channelMap.remove(channelId)
}
break
}
}
}
prefs.edit().putString(KEY_PROGRAM_IDS, channelMap.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 {
val sections = payload.optJSONArray("sections") ?: JSONArray()
if (sections.length() == 0) {
Log.w(TAG, "synchronize(): no sections in payload")
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(
TAG,
"synchronize(): section \"$sectionTitle\" ($sectionIndex/${sections.length()}) with ${items.length()} item(s)"
)
val channelId = getOrCreateChannel(context, sectionTitle)
if (channelId <= 0L) {
Log.w(TAG, "synchronize(): failed to get or create channel for \"$sectionTitle\"")
continue
}
// 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 prefKey = "programIds_$channelId"
val previousProgramIds = prefs.getString(prefKey, null)
?.let(::JSONObject)
?: JSONObject()
val nextProgramIds = JSONObject()
val activeProviderIds = mutableSetOf<String>()
for (index in 0 until items.length()) {
val item = items.optJSONObject(index) ?: continue
val providerId = item.optString("id")
if (providerId.isBlank()) continue
val programId = upsertPreviewProgram(
context = context,
channelId = channelId,
item = item,
previousProgramId = previousProgramIds.optLong(providerId, -1L),
weight = index
)
if (programId > 0L) {
activeProviderIds += providerId
nextProgramIds.put(providerId, programId)
Log.d(TAG, "synchronize(): upserted program for item=$providerId programId=$programId")
}
}
var deletedPrograms = 0
val previousKeys = previousProgramIds.keys()
while (previousKeys.hasNext()) {
val providerId = previousKeys.next()
if (activeProviderIds.contains(providerId)) continue
val programId = previousProgramIds.optLong(providerId, -1L)
if (programId > 0L) {
deletePreviewProgram(context, programId)
deletedPrograms += 1
Log.d(TAG, "synchronize(): deleted stale programId=$programId for item=$providerId")
}
}
allNextProgramIds.put(channelId.toString(), nextProgramIds.toString())
prefs.edit().putString(prefKey, nextProgramIds.toString()).apply()
totalActive += activeProviderIds.size
totalDeleted += deletedPrograms
logProviderState(context, channelId)
}
// Store all channel program IDs for clear() to use
prefs.edit().putString(KEY_PROGRAM_IDS, allNextProgramIds.toString()).apply()
val firstSection = if (sections.length() > 0) sections.optJSONObject(0) else null
val sectionTitle = firstSection?.optString("title")?.takeIf { it.isNotBlank() } ?: DEFAULT_CHANNEL_NAME
val items = firstSection?.optJSONArray("items") ?: JSONArray()
Log.d(
TAG,
"synchronize(): completed across ${sections.length()} section(s), $totalActive active program(s), deleted $totalDeleted stale program(s)"
"synchronize(): using section \"$sectionTitle\" with ${items.length()} item(s)"
)
val channelId = getOrCreateChannel(context, sectionTitle)
if (channelId <= 0L) {
Log.w(TAG, "synchronize(): failed to get or create preview channel")
return false
}
Log.d(TAG, "synchronize(): publishing into channelId=$channelId")
val previousProgramIds = preferences(context)
.getString(KEY_PROGRAM_IDS, null)
?.let(::JSONObject)
?: JSONObject()
val nextProgramIds = JSONObject()
val activeProviderIds = mutableSetOf<String>()
for (index in 0 until items.length()) {
val item = items.optJSONObject(index) ?: continue
val providerId = item.optString("id")
if (providerId.isBlank()) continue
val programId = upsertPreviewProgram(
context = context,
channelId = channelId,
item = item,
previousProgramId = previousProgramIds.optLong(providerId, -1L),
weight = index
)
if (programId > 0L) {
activeProviderIds += providerId
nextProgramIds.put(providerId, programId)
Log.d(TAG, "synchronize(): upserted program for item=$providerId programId=$programId")
}
}
var deletedPrograms = 0
val previousKeys = previousProgramIds.keys()
while (previousKeys.hasNext()) {
val providerId = previousKeys.next()
if (activeProviderIds.contains(providerId)) continue
val programId = previousProgramIds.optLong(providerId, -1L)
if (programId > 0L) {
context.contentResolver.delete(
TvContractCompat.buildPreviewProgramUri(programId),
null,
null
)
deletedPrograms += 1
Log.d(TAG, "synchronize(): deleted stale programId=$programId for item=$providerId")
}
}
preferences(context)
.edit()
.putLong(KEY_CHANNEL_ID, channelId)
.putString(KEY_PROGRAM_IDS, nextProgramIds.toString())
.apply()
logProviderState(context, channelId)
Log.d(
TAG,
"synchronize(): completed with ${activeProviderIds.size} active program(s), deleted $deletedPrograms stale program(s)"
)
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 {
val prefs = preferences(context)
val channelKey = getChannelKey(displayName)
val existingChannelId = prefs.getLong(channelKey, -1L)
val existingChannelId = prefs.getLong(KEY_CHANNEL_ID, -1L)
val contentResolver = context.contentResolver
if (existingChannelId > 0L) {
// Query provider first to verify channel actually exists (prevents recreate bug)
val exists = channelExistsInProvider(context, existingChannelId)
val updated = Channel.Builder()
.setType(TvContractCompat.Channels.TYPE_PREVIEW)
.setDisplayName(displayName)
.setAppLinkIntentUri(buildIntentUri(context, "streamyfin://"))
.build()
if (exists) {
// Channel exists — update it in place, never recreate
val updated = Channel.Builder()
.setType(TvContractCompat.Channels.TYPE_PREVIEW)
.setDisplayName(displayName)
.setAppLinkIntentUri(buildIntentUri(context, "streamyfin://"))
.build()
val updatedRows = contentResolver.update(
TvContractCompat.buildChannelUri(existingChannelId),
updated.toContentValues(),
null,
null
)
try {
val updatedRows = contentResolver.update(
TvContractCompat.buildChannelUri(existingChannelId),
updated.toContentValues(),
null,
null
)
if (updatedRows > 0) {
TvContractCompat.requestChannelBrowsable(context, existingChannelId)
storeChannelLogo(context, existingChannelId)
Log.d(TAG, "getOrCreateChannel(): updated existing channelId=$existingChannelId and requested browsable")
return existingChannelId
}
// 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
}
if (updatedRows > 0) {
TvContractCompat.requestChannelBrowsable(context, existingChannelId)
storeChannelLogo(context, existingChannelId)
Log.d(TAG, "getOrCreateChannel(): updated existing channelId=$existingChannelId and requested browsable")
return existingChannelId
}
// Channel truly doesn't exist in provider — recreate
Log.w(TAG, "getOrCreateChannel(): channelId=$existingChannelId not in provider, recreating")
prefs.edit().remove(channelKey).apply()
Log.w(TAG, "getOrCreateChannel(): stored channelId=$existingChannelId was stale, recreating")
prefs.edit().remove(KEY_CHANNEL_ID).apply()
}
// Create a new channel
val channel = Channel.Builder()
.setType(TvContractCompat.Channels.TYPE_PREVIEW)
.setDisplayName(displayName)
.setAppLinkIntentUri(buildIntentUri(context, "streamyfin://"))
.build()
val channelUri = try {
contentResolver.insert(
TvContractCompat.Channels.CONTENT_URI,
channel.toContentValues()
)
} catch (e: SecurityException) {
Log.e(TAG, "getOrCreateChannel(): lost provider permission, cannot create channel", e)
null
} ?: return -1L
val channelUri = contentResolver.insert(
TvContractCompat.Channels.CONTENT_URI,
channel.toContentValues()
) ?: return -1L
val channelId = ContentUris.parseId(channelUri)
prefs.edit().putLong(channelKey, channelId).apply()
TvContractCompat.requestChannelBrowsable(context, channelId)
storeChannelLogo(context, channelId)
Log.d(TAG, "getOrCreateChannel(): created new channelId=$channelId displayName=\"$displayName\"")
@@ -402,10 +225,6 @@ internal object TvRecommendationsPublisher {
return channelId
}
private fun getChannelKey(displayName: String): String {
return KEY_CHANNEL_ID_PREFIX + displayName.hashCode()
}
private fun upsertPreviewProgram(
context: Context,
channelId: Long,
@@ -430,67 +249,42 @@ internal object TvRecommendationsPublisher {
builder.setDescription(it)
}
// Per Android docs: use unique URIs for all images to avoid stale cache
imageUrl.takeIf { it.isNotBlank() }?.let {
val uniqueImageUrl = appendCacheBuster(it)
val imageUri = Uri.parse(uniqueImageUrl)
val imageUri = Uri.parse(it)
builder.setPosterArtUri(imageUri)
builder.setThumbnailUri(imageUri)
}
val contentValues = builder.build().toContentValues()
val contentResolver = context.contentResolver
if (previousProgramId > 0L) {
try {
val updatedRows = contentResolver.update(
TvContractCompat.buildPreviewProgramUri(previousProgramId),
contentValues,
null,
null
)
val updatedRows = contentResolver.update(
TvContractCompat.buildPreviewProgramUri(previousProgramId),
contentValues,
null,
null
)
if (updatedRows > 0) {
Log.d(TAG, "upsertPreviewProgram(): updated existing programId=$previousProgramId")
return previousProgramId
}
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)
if (updatedRows > 0) {
Log.d(TAG, "upsertPreviewProgram(): updated existing programId=$previousProgramId")
return previousProgramId
}
Log.w(TAG, "upsertPreviewProgram(): existing programId=$previousProgramId was stale, inserting new row")
}
val insertedUri = try {
contentResolver.insert(
TvContractCompat.PreviewPrograms.CONTENT_URI,
contentValues
)
} catch (e: SecurityException) {
Log.w(TAG, "upsertPreviewProgram(): lost provider permission, cannot insert program", e)
null
} ?: return -1L
val insertedUri = contentResolver.insert(
TvContractCompat.PreviewPrograms.CONTENT_URI,
contentValues
) ?: return -1L
val programId = ContentUris.parseId(insertedUri)
Log.d(TAG, "upsertPreviewProgram(): inserted new programId=$programId")
return programId
}
/**
* Append a stable cache key derived from the image URL.
* The Jellyfin image URLs already include a `tag=` query param (etag)
* that changes whenever the image content changes, so a deterministic
* hash of the URL is sufficient — the param only changes when the URL
* (and therefore the image) actually changes, avoiding unnecessary
* re-downloads on every sync.
*/
private fun appendCacheBuster(imageUrl: String): String {
val digest = MessageDigest.getInstance("MD5").digest(imageUrl.toByteArray())
val hash = digest.joinToString("") { "%02x".format(it) }.substring(0, 16)
val separator = if (imageUrl.contains("?")) "&" else "?"
return "$imageUrl${separator}_v=$hash"
}
private fun buildIntentUri(context: Context, deepLink: String): Uri {
val intent = Intent(Intent.ACTION_VIEW).apply {
data = Uri.parse(deepLink)
@@ -512,17 +306,13 @@ internal object TvRecommendationsPublisher {
private fun storeChannelLogo(context: Context, channelId: Long) {
val bitmap = applicationIconBitmap(context) ?: return
try {
val outputStream = context.contentResolver.openOutputStream(
TvContractCompat.buildChannelLogoUri(channelId)
) ?: return
val outputStream = context.contentResolver.openOutputStream(
TvContractCompat.buildChannelLogoUri(channelId)
) ?: return
outputStream.use { stream ->
bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream)
stream.flush()
}
} catch (e: SecurityException) {
Log.w(TAG, "storeChannelLogo(): lost provider permission for channelId=$channelId", e)
outputStream.use { stream ->
bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream)
stream.flush()
}
}
@@ -551,14 +341,9 @@ internal object TvRecommendationsPublisher {
return bitmap
}
fun getChannelId(context: Context, displayName: String = DEFAULT_CHANNEL_NAME): Long {
return preferences(context).getLong(getChannelKey(displayName), -1L)
}
private fun preferences(context: Context): SharedPreferences {
return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
}
private fun logProviderState(context: Context, channelId: Long) {
val contentResolver = context.contentResolver
@@ -587,10 +372,8 @@ internal object TvRecommendationsPublisher {
Log.w(TAG, "logProviderState(): channelId=$channelId exists=false")
}
} ?: Log.w(TAG, "logProviderState(): channel query returned null for channelId=$channelId")
} catch (error: SecurityException) {
Log.w(TAG, "logProviderState(): lost provider permission for channelId=$channelId", error)
} catch (error: Exception) {
Log.w(TAG, "logProviderState(): failed to query channelId=$channelId", error)
}
} catch (error: Exception) {
Log.w(TAG, "logProviderState(): failed to query channelId=$channelId", error)
}
}
}

View File

@@ -3,24 +3,16 @@ package expo.modules.tvrecommendations
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.ContentUris
import android.util.Log
import androidx.tvprovider.media.tv.TvContractCompat
class TvRecommendationsReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
when (intent.action) {
TvContractCompat.ACTION_INITIALIZE_PROGRAMS -> {
Log.d("TvRecommendations", "Handling INITIALIZE_PROGRAMS broadcast")
TvRecommendationsPublisher.refreshFromCache(context)
}
"android.media.tv.action.PREVIEW_PROGRAM_BROWSABLE_DISABLED" -> {
val programId = intent.data?.let { ContentUris.parseId(it) } ?: -1L
if (programId > 0L) {
Log.d("TvRecommendations", "Handling PREVIEW_PROGRAM_BROWSABLE_DISABLED for programId=$programId")
TvRecommendationsPublisher.deletePreviewProgram(context, programId)
}
}
if (intent.action != TvContractCompat.ACTION_INITIALIZE_PROGRAMS) {
return
}
Log.d("TvRecommendations", "Handling INITIALIZE_PROGRAMS broadcast")
TvRecommendationsPublisher.refreshFromCache(context)
}
}

View File

@@ -1,19 +1,12 @@
import { requireNativeView } from "expo";
import * as React from "react";
import type { View } from "react-native";
import { Platform } from "react-native";
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<
TvSearchViewProps & React.RefAttributes<View>
> =
Platform.OS === "ios"
? requireNativeView("TvSearchModule")
: ((() => null) as any);
> = requireNativeView("TvSearchModule");
/**
* Forwards its ref to the underlying native view so it can be used as a

View File

@@ -17,13 +17,13 @@
"ios:unsigned-build": "cross-env EXPO_TV=0 bun scripts/ios/build-ios.ts --production",
"ios:unsigned-build:tv": "cross-env EXPO_TV=1 bun scripts/ios/build-ios.ts --production",
"prepare": "husky",
"typecheck": "bun scripts/typecheck.ts",
"typecheck": "node scripts/typecheck.js",
"check": "biome check . --max-diagnostics 1000",
"lint": "biome check --write --unsafe --max-diagnostics 1000",
"format": "biome format --write .",
"doctor": "expo-doctor",
"i18n:check": "bun scripts/check-i18n-keys.ts",
"i18n:fix-unused": "bun scripts/check-i18n-keys.ts --fix-unused",
"i18n:check": "bun scripts/check-i18n-keys.mjs",
"i18n:fix-unused": "bun scripts/check-i18n-keys.mjs --fix-unused",
"test": "bun run typecheck && bun run lint && bun run format && bun run i18n:check && bun run doctor",
"postinstall": "patch-package"
},

View File

@@ -18,11 +18,11 @@
* - Edge cases the static scan cannot see can be allow-listed in the config file.
*
* Usage:
* bun scripts/check-i18n-keys.ts # report + exit 1 on missing OR unused
* bun scripts/check-i18n-keys.ts --unused=warn # exit 1 only on missing; unused = warning
* bun scripts/check-i18n-keys.ts --unused=off # ignore unused entirely
* bun scripts/check-i18n-keys.ts --json # machine-readable output
* bun scripts/check-i18n-keys.ts --fix-unused # remove dead keys from en.json (Crowdin syncs the rest)
* bun scripts/check-i18n-keys.mjs # report + exit 1 on missing OR unused
* bun scripts/check-i18n-keys.mjs --unused=warn # exit 1 only on missing; unused = warning
* bun scripts/check-i18n-keys.mjs --unused=off # ignore unused entirely
* bun scripts/check-i18n-keys.mjs --json # machine-readable output
* bun scripts/check-i18n-keys.mjs --fix-unused # remove dead keys from en.json (Crowdin syncs the rest)
*/
import {
@@ -34,20 +34,9 @@ import {
} from "node:fs";
import { extname, join, relative } from "node:path";
type LocaleTree = { [key: string]: LocaleTree | string };
interface I18nConfig {
localesDir: string;
sourceLocale: string;
srcDirs: string[];
srcExtensions: string[];
excludeDirs: string[];
ignoreUnused: string[];
}
const ROOT = process.cwd();
const args = process.argv.slice(2);
const flag = (name: string, def: string | boolean): string | boolean => {
const flag = (name, def) => {
const a = args.find((x) => x === `--${name}` || x.startsWith(`--${name}=`));
if (!a) return def;
const [, v] = a.split("=");
@@ -59,7 +48,7 @@ const FIX_UNUSED = !!flag("fix-unused", false);
// ---- config ----
const CONFIG_PATH = join(ROOT, "scripts", "i18n-keys.config.json");
const DEFAULT_CONFIG: I18nConfig = {
const DEFAULT_CONFIG = {
localesDir: "translations",
sourceLocale: "en",
// Scan the whole repo by default so keys referenced outside the obvious dirs
@@ -80,36 +69,29 @@ const DEFAULT_CONFIG: I18nConfig = {
// Keys (or glob-ish prefixes ending with .* or *) known to be used dynamically / externally.
ignoreUnused: [],
};
const config: I18nConfig = existsSync(CONFIG_PATH)
? {
...DEFAULT_CONFIG,
...(JSON.parse(readFileSync(CONFIG_PATH, "utf8")) as Partial<I18nConfig>),
}
const config = existsSync(CONFIG_PATH)
? { ...DEFAULT_CONFIG, ...JSON.parse(readFileSync(CONFIG_PATH, "utf8")) }
: DEFAULT_CONFIG;
// ---- helpers ----
const flatten = (
obj: LocaleTree,
prefix = "",
out: Record<string, string> = {},
): Record<string, string> => {
const flatten = (obj, prefix = "", out = {}) => {
for (const [k, v] of Object.entries(obj)) {
const key = prefix ? `${prefix}.${k}` : k;
if (v && typeof v === "object" && !Array.isArray(v)) flatten(v, key, out);
else out[key] = v as string;
else out[key] = v;
}
return out;
};
const globMatch = (key: string, pattern: string): boolean => {
const globMatch = (key, pattern) => {
if (pattern.endsWith(".*"))
return key === pattern.slice(0, -2) || key.startsWith(pattern.slice(0, -1));
if (pattern.endsWith("*")) return key.startsWith(pattern.slice(0, -1));
return key === pattern;
};
const walk = (dir: string, files: string[] = []): string[] => {
let entries: string[];
const walk = (dir, files = []) => {
let entries;
try {
entries = readdirSync(dir);
} catch {
@@ -117,7 +99,7 @@ const walk = (dir: string, files: string[] = []): string[] => {
}
for (const name of entries) {
const full = join(dir, name);
let st: ReturnType<typeof statSync>;
let st;
try {
st = statSync(full);
} catch {
@@ -136,7 +118,7 @@ const walk = (dir: string, files: string[] = []): string[] => {
// ---- load source keys ----
const sourcePath = join(ROOT, config.localesDir, `${config.sourceLocale}.json`);
const sourceKeys = Object.keys(
flatten(JSON.parse(readFileSync(sourcePath, "utf8")) as LocaleTree),
flatten(JSON.parse(readFileSync(sourcePath, "utf8"))),
);
const sourceKeySet = new Set(sourceKeys);
@@ -147,16 +129,16 @@ const TPL_DYN_RE = /\bt\(\s*`([^`$]*)\$\{/g; // t(`a.b.${x}`) -> prefix "a.b."
const I18NKEY_RE = /\bi18nKey\s*=\s*(?:\{\s*)?(['"])((?:\\.|(?!\1).)+?)\1/g; // <Trans i18nKey="a.b">
const KEY_SHAPE = /^[A-Za-z0-9_]+(\.[A-Za-z0-9_]+)+$/; // dotted key, e.g. home.x.y
const usedStatic = new Set<string>(); // keys passed to t(...) / i18nKey — used for MISSING detection
const dynamicPrefixes = new Set<string>();
const fullyDynamic: Array<{ file: string; line: number }> = [];
const usedStatic = new Set(); // keys passed to t(...) / i18nKey — used for MISSING detection
const dynamicPrefixes = new Set();
const fullyDynamic = []; // { file, line }
let codeBlob = ""; // all (comment-stripped) source text — searched for delimited key literals
// Strip comments so keys mentioned in comments (e.g. `// t("old.key")`) are not counted as
// usage. Block comments and JSX {/* */} are blanked (preserving newlines for line numbers);
// line comments are only stripped when `//` follows start/whitespace/punctuation, which keeps
// `://` inside string URLs intact.
const stripComments = (src: string): string =>
const stripComments = (src) =>
src
.replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, " "))
.replace(/(^|[\s;{}()[\],=>])\/\/[^\n]*/g, (_m, p) => p);
@@ -186,11 +168,11 @@ const prefixList = [...dynamicPrefixes];
// the code (covers t("k"), <Trans i18nKey>, and keys stored as bare string constants in
// arrays/config then resolved via t(variable)), or it is reached via a dynamic prefix, or
// explicitly allow-listed. Delimited search avoids substring false-matches (e.g. a.b vs a.b_c).
const literalUsed = (key: string): boolean =>
const literalUsed = (key) =>
codeBlob.includes(`"${key}"`) ||
codeBlob.includes(`'${key}'`) ||
codeBlob.includes(`\`${key}\``);
const isUsed = (key: string): boolean =>
const isUsed = (key) =>
literalUsed(key) ||
prefixList.some((p) => key.startsWith(p)) ||
config.ignoreUnused.some((g) => globMatch(key, g));
@@ -209,22 +191,25 @@ const missing = [...usedStatic]
// keys are static literals in practice; revisit if dynamic key constants become common.
// ---- optional fix: strip dead keys from the source locale (en.json) ----
const removeKey = (obj: LocaleTree, parts: string[]): void => {
const removeKey = (obj, parts) => {
const [head, ...rest] = parts;
if (!(head in obj)) return;
if (rest.length === 0) {
delete obj[head];
return;
}
const child = obj[head];
if (!child || typeof child !== "object") return;
removeKey(child, rest);
if (Object.keys(child).length === 0) delete obj[head];
removeKey(obj[head], rest);
if (
obj[head] &&
typeof obj[head] === "object" &&
Object.keys(obj[head]).length === 0
)
delete obj[head];
};
if (FIX_UNUSED && unused.length) {
// Only edit the SOURCE locale (en.json). Crowdin owns the target locales and removes
// the keys from them automatically on the next sync once they disappear from the source.
const data = JSON.parse(readFileSync(sourcePath, "utf8")) as LocaleTree;
const data = JSON.parse(readFileSync(sourcePath, "utf8"));
for (const key of unused) removeKey(data, key.split("."));
writeFileSync(sourcePath, `${JSON.stringify(data, null, 2)}\n`);
console.log(
@@ -274,7 +259,7 @@ if (JSON_OUT) {
);
for (const k of unused) console.log(` - ${k}`);
console.log(
`\n → remove with: bun scripts/check-i18n-keys.ts --fix-unused`,
`\n → remove with: bun scripts/check-i18n-keys.mjs --fix-unused`,
);
console.log(
` → or allow-list a dynamic key in scripts/i18n-keys.config.json ("ignoreUnused").`,

View File

@@ -21,14 +21,8 @@
import { execFileSync } from "node:child_process";
import { readFileSync } from "node:fs";
interface Issue {
number: number;
title: string;
body: string | null;
}
// Parse a numeric env var, falling back to `def` only when unset/empty/NaN so an explicit 0 is honoured.
const numEnv = (name: string, def: number): number => {
const numEnv = (name, def) => {
const raw = process.env[name];
if (raw === undefined || raw === "") return def;
const n = Number(raw);
@@ -57,9 +51,9 @@ const STOP = new Set(
).split(/\s+/),
);
const stem = (w: string): string => w.replace(/(ing|ed|es|s)$/, "");
const stem = (w) => w.replace(/(ing|ed|es|s)$/, "");
const tokens = (s: string | null): string[] =>
const tokens = (s) =>
(s || "")
.toLowerCase()
.replace(/```[\s\S]*?```/g, " ") // drop code blocks
@@ -71,7 +65,7 @@ const tokens = (s: string | null): string[] =>
.map(stem)
.filter((w) => w.length > 2);
const jaccard = (a: string[], b: string[]): number => {
const jaccard = (a, b) => {
const A = new Set(a);
const B = new Set(b);
if (!A.size || !B.size) return 0;
@@ -82,14 +76,14 @@ const jaccard = (a: string[], b: string[]): number => {
const newTitle = tokens(TITLE);
const newBody = tokens(BODY);
const score = (o: Issue): number =>
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: Issue[];
let issues;
if (process.env.DUP_FIXTURE) {
issues = JSON.parse(readFileSync(process.env.DUP_FIXTURE, "utf8")) as Issue[];
issues = JSON.parse(readFileSync(process.env.DUP_FIXTURE, "utf8"));
} else {
const raw = execFileSync(
"gh",
@@ -111,7 +105,7 @@ if (process.env.DUP_FIXTURE) {
issues = raw
.split("\n")
.filter(Boolean)
.map((l) => JSON.parse(l) as Issue);
.map((l) => JSON.parse(l));
}
const matches = issues
@@ -129,7 +123,7 @@ if (!matches.length) {
// 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: string): string =>
const safeTitle = (t) =>
(t || "")
.replace(/@/g, "@")
.replace(/[`<>|*_~[\]]/g, " ")

View File

@@ -0,0 +1,62 @@
#!/usr/bin/env node
const _fs = require("node:fs");
const path = require("node:path");
const process = require("node:process");
const { execSync } = require("node:child_process");
const root = process.cwd();
// const tvosPath = path.join(root, 'iostv');
// const iosPath = path.join(root, 'iosmobile');
// const androidPath = path.join(root, 'androidmobile');
// const androidTVPath = path.join(root, 'androidtv');
// const device = process.argv[2];
// const platform = process.argv[2];
const isTV = process.env.EXPO_TV || false;
const paths = new Map([
["tvos", path.join(root, "iostv")],
["ios", path.join(root, "iosmobile")],
["android", path.join(root, "androidmobile")],
["androidtv", path.join(root, "androidtv")],
]);
// const platformPath = paths.get(platform);
if (isTV) {
stdout = execSync(
`mkdir -p ${paths.get("tvos")}; ln -nsf ${paths.get("tvos")} ios`,
);
console.log(stdout.toString());
stdout = execSync(
`mkdir -p ${paths.get("androidtv")}; ln -nsf ${paths.get(
"androidtv",
)} android`,
);
console.log(stdout.toString());
} else {
stdout = execSync(
`mkdir -p ${paths.get("ios")}; ln -nsf ${paths.get("ios")} ios`,
);
console.log(stdout.toString());
stdout = execSync(
`mkdir -p ${paths.get("android")}; ln -nsf ${paths.get("android")} android`,
);
console.log(stdout.toString());
}
// target = "";
// switch (platform) {
// case "tvos":
// target = "ios";
// break;
// case "ios":
// target = "ios";
// break;
// case "android":
// target = "android";
// break;
// case "androidtv":
// target = "android";
// break;
// }

View File

@@ -1,8 +1,5 @@
import { execFileSync } from "node:child_process";
import { createRequire } from "node:module";
import process from "node:process";
const require = createRequire(import.meta.url);
const { execFileSync } = require("node:child_process");
const process = require("node:process");
// Enhanced ANSI color codes and styles
const colors = {
@@ -35,7 +32,7 @@ const centeredTitle = " ".repeat(titlePadding) + title;
const useColor = process.stdout.isTTY && !process.env.NO_COLOR;
function log(message: string, color = "") {
function log(message, color = "") {
if (useColor && color) {
console.log(`${color}${message}${colors.reset}`);
} else {
@@ -43,7 +40,7 @@ function log(message: string, color = "") {
}
}
function formatError(errorLine: string): string {
function formatError(errorLine) {
if (!useColor) return errorLine;
// Color file paths in cyan
@@ -73,15 +70,12 @@ function formatError(errorLine: string): string {
return formatted;
}
function parseErrorsAndCreateSummary(errorOutput: string): {
formattedErrors: string[];
errorsByFile: Map<string, number>;
} {
function parseErrorsAndCreateSummary(errorOutput) {
const lines = errorOutput.split("\n").filter((line) => line.trim());
const errorsByFile = new Map<string, number>();
const formattedErrors: string[] = [];
const errorsByFile = new Map();
const formattedErrors = [];
let currentError: string[] = [];
let currentError = [];
for (const line of lines) {
const trimmedLine = line.trim();
@@ -102,7 +96,7 @@ function parseErrorsAndCreateSummary(errorOutput: string): {
if (!errorsByFile.has(filePath)) {
errorsByFile.set(filePath, 0);
}
errorsByFile.set(filePath, (errorsByFile.get(filePath) ?? 0) + 1);
errorsByFile.set(filePath, errorsByFile.get(filePath) + 1);
// Start new error
currentError.push(formatError(line));
@@ -125,7 +119,7 @@ function parseErrorsAndCreateSummary(errorOutput: string): {
return { formattedErrors, errorsByFile };
}
function createErrorSummaryTable(errorsByFile: Map<string, number>): string {
function createErrorSummaryTable(errorsByFile) {
if (errorsByFile.size === 0) return "";
const sortedFiles = Array.from(errorsByFile.entries()).sort(
@@ -142,12 +136,12 @@ function createErrorSummaryTable(errorsByFile: Map<string, number>): string {
return table;
}
function runTypeCheck(): { ok: boolean } {
function runTypeCheck() {
const extraArgs = process.argv.slice(2);
// Prefer local TypeScript binary when available
const runnerArgs = ["-p", "tsconfig.json", "--noEmit", ...extraArgs];
let execArgs: { cmd: string; args: string[] };
let execArgs = null;
try {
const tscBin = require.resolve("typescript/bin/tsc");
execArgs = { cmd: process.execPath, args: [tscBin, ...runnerArgs] };
@@ -180,8 +174,7 @@ function runTypeCheck(): { ok: boolean } {
);
return { ok: true };
} catch (error) {
const execError = error as { stderr?: string; stdout?: string };
const errorOutput = execError.stderr || execError.stdout || "";
const errorOutput = (error && (error.stderr || error.stdout)) || "";
// Filter out jellyseerr utils errors - this is a third-party git submodule
// that generates a large volume of known type errors

View File

@@ -0,0 +1,122 @@
#!/usr/bin/env bun
/**
* Populates the "Streamyfin Version" dropdown in the issue report form with the
* latest GitHub releases. Run by the "Update Issue Form Versions" workflow on
* release events + a weekly cron (and manually via workflow_dispatch).
*
* Source: published, non-draft, non-prerelease GitHub releases, newest first.
* Non-version sentinels (e.g. "older", "TestFlight/Development build") are
* preserved at the end of the list.
*
* Usage:
* bun scripts/update-issue-form.mjs # rewrite the form in place
* ISSUE_FORM_LIMIT=8 bun scripts/update-issue-form.mjs
* bun scripts/update-issue-form.mjs --dry-run # print the new options, don't write
*
* Env: GITHUB_REPOSITORY (owner/repo), GH_TOKEN/GITHUB_TOKEN (for gh, provided in CI).
*/
import { execFileSync } from "node:child_process";
import {
appendFileSync,
readFileSync as read,
writeFileSync as write,
} from "node:fs";
const FORM = ".github/ISSUE_TEMPLATE/issue_report.yml";
const DROPDOWN_ID = "version"; // the `id:` of the dropdown to populate
const parsedLimit = Number.parseInt(process.env.ISSUE_FORM_LIMIT ?? "", 10);
const LIMIT =
Number.isInteger(parsedLimit) && parsedLimit > 0 ? parsedLimit : 5;
const REPO = process.env.GITHUB_REPOSITORY || "streamyfin/streamyfin";
const DRY = process.argv.includes("--dry-run");
// Matches "0.54.1" and prerelease/beta tags like "0.54.0-beta.1".
const isVersion = (s) => /^\d+\.\d+/.test(s.trim());
// 1. Fetch the latest published releases (newest first) — drafts and prereleases
// aren't a full release users run, so they don't belong in the dropdown.
const raw = execFileSync(
"gh",
[
"release",
"list",
"--repo",
REPO,
"--exclude-drafts",
"--exclude-pre-releases",
"--limit",
String(LIMIT),
"--json",
"tagName",
"--jq",
".[].tagName",
],
// Bounded timeout so a stuck gh process fails the job fast instead of
// holding the workflow open until the job-level timeout.
{ encoding: "utf8", timeout: 30_000 },
);
const seen = new Set();
const versions = [];
for (const tag of raw.split("\n")) {
if (!tag) continue;
const ver = tag.trim().replace(/^v/, "");
if (!isVersion(ver) || seen.has(ver)) continue;
seen.add(ver);
versions.push(ver);
}
if (!versions.length) {
console.error("No release versions found — leaving the form untouched.");
process.exit(1);
}
// 2. rewrite the dropdown options, preserving non-version sentinels
// (e.g. "older", "TestFlight/Development build") at the end of the list.
const lines = read(FORM, "utf8").split("\n");
const idIdx = lines.findIndex((l) =>
l.match(new RegExp(`^\\s*id:\\s*${DROPDOWN_ID}\\s*$`)),
);
if (idIdx === -1)
throw new Error(`dropdown id: ${DROPDOWN_ID} not found in ${FORM}`);
const optIdx = lines.findIndex(
(l, i) => i > idIdx && /^\s*options:\s*$/.test(l),
);
if (optIdx === -1)
throw new Error(`options: not found after id: ${DROPDOWN_ID}`);
const itemIndent = lines[optIdx].match(/^\s*/)[0] + " "; // options items are nested one level deeper
let end = optIdx + 1;
const sentinels = [];
while (end < lines.length && /^\s*-\s+/.test(lines[end])) {
const val = lines[end].replace(/^\s*-\s+/, "");
if (!isVersion(val)) sentinels.push(val);
end++;
}
const newOptions = [...versions, ...sentinels].map(
(v) => `${itemIndent}- ${v}`,
);
const updated = [
...lines.slice(0, optIdx + 1),
...newOptions,
...lines.slice(end),
].join("\n");
console.log(
`Versions: ${versions.join(", ")}${sentinels.length ? ` | kept: ${sentinels.join(", ")}` : ""}`,
);
if (DRY) {
console.log("--dry-run: not writing.");
} else {
write(FORM, updated);
console.log(`Updated ${FORM}.`);
}
// Expose the resulting list for the workflow (PR description).
if (process.env.GITHUB_OUTPUT) {
appendFileSync(
process.env.GITHUB_OUTPUT,
`versions=${versions.join(", ")}\n`,
);
}

View File

@@ -3,17 +3,17 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import type {
DeviceProfile,
SubtitleProfile,
} from "@jellyfin/sdk/lib/generated-client/models";
import { type AudioTranscodeModeType, generateDeviceProfile } from "./native";
import { generateDeviceProfile } from "./native";
/**
* @typedef {"auto" | "stereo" | "5.1" | "passthrough"} AudioTranscodeModeType
*/
/**
* Download-specific subtitle profiles.
* These are more permissive than streaming profiles since we can embed subtitles.
*/
const downloadSubtitleProfiles: SubtitleProfile[] = [
const downloadSubtitleProfiles = [
// Official formats
{ Format: "vtt", Method: "Encode" },
{ Format: "webvtt", Method: "Encode" },
@@ -46,10 +46,11 @@ const downloadSubtitleProfiles: SubtitleProfile[] = [
/**
* Generates a device profile optimized for downloads.
* Uses the same audio codec logic as streaming but with download-specific bitrate limits.
*
* @param {AudioTranscodeModeType} [audioMode="auto"] - Audio transcoding mode
* @returns {Object} Jellyfin device profile for downloads
*/
export const generateDownloadProfile = (
audioMode: AudioTranscodeModeType = "auto",
): DeviceProfile => {
export const generateDownloadProfile = (audioMode = "auto") => {
// Get the base profile with proper audio codec configuration
const baseProfile = generateDeviceProfile({ audioMode });

View File

@@ -3,7 +3,6 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import type { DeviceProfile } from "@jellyfin/sdk/lib/generated-client/models";
import { Platform } from "react-native";
import MediaTypes from "../../constants/MediaTypes";
import { getSubtitleProfiles } from "./subtitles";
@@ -194,7 +193,7 @@ export const generateDeviceProfile = (options: ProfileOptions = {}) => {
},
],
SubtitleProfiles: getSubtitleProfiles(),
} satisfies DeviceProfile;
};
return profile;
};

View File

@@ -3,7 +3,6 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import type { SubtitleProfile } from "@jellyfin/sdk/lib/generated-client/models";
// Image-based formats - these need to be burned in by Jellyfin (Encode method)
// because MPV cannot load them externally over HTTP
@@ -14,7 +13,7 @@ const IMAGE_BASED_FORMATS = [
"pgssub",
"teletext",
"vobsub",
] as const;
];
// Text-based formats - these can be loaded externally by MPV
const TEXT_BASED_FORMATS = [
@@ -38,10 +37,10 @@ const TEXT_BASED_FORMATS = [
"text",
"vplayer",
"xsub",
] as const;
];
export const getSubtitleProfiles = (): SubtitleProfile[] => {
const profiles: SubtitleProfile[] = [];
export const getSubtitleProfiles = () => {
const profiles = [];
// Image-based formats: Embed or Encode (burn-in), NOT External
for (const format of IMAGE_BASED_FORMATS) {
@@ -59,4 +58,4 @@ export const getSubtitleProfiles = (): SubtitleProfile[] => {
};
// Export for use in player filtering
export const IMAGE_SUBTITLE_CODECS: readonly string[] = IMAGE_BASED_FORMATS;
export const IMAGE_SUBTITLE_CODECS = IMAGE_BASED_FORMATS;

19
utils/profiles/trackplayer.d.ts vendored Normal file
View File

@@ -0,0 +1,19 @@
/**
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
export type PlatformType = "ios" | "android";
export interface TrackPlayerProfileOptions {
/** Target platform */
platform?: PlatformType;
}
export function generateTrackPlayerProfile(
options?: TrackPlayerProfileOptions,
): any;
declare const _default: any;
export default _default;

View File

@@ -3,26 +3,23 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import type {
CodecProfile,
DeviceProfile,
DirectPlayProfile,
} from "@jellyfin/sdk/lib/generated-client/models";
import { Platform } from "react-native";
import MediaTypes from "../../constants/MediaTypes";
export type PlatformType = "ios" | "android";
export interface TrackPlayerProfileOptions {
/** Target platform */
platform?: PlatformType;
}
/**
* @typedef {"ios" | "android"} PlatformType
*
* @typedef {Object} TrackPlayerProfileOptions
* @property {PlatformType} [platform] - Target platform
*/
/**
* Audio direct play profiles for react-native-track-player.
* iOS uses AVPlayer, Android uses ExoPlayer - each has different codec support.
*
* @param {PlatformType} platform
*/
const getDirectPlayProfile = (platform: PlatformType): DirectPlayProfile => {
const getDirectPlayProfile = (platform) => {
if (platform === "ios") {
// iOS AVPlayer supported formats
return {
@@ -42,8 +39,10 @@ const getDirectPlayProfile = (platform: PlatformType): DirectPlayProfile => {
/**
* Audio codec profiles for react-native-track-player.
*
* @param {PlatformType} platform
*/
const getCodecProfile = (platform: PlatformType): CodecProfile => {
const getCodecProfile = (platform) => {
if (platform === "ios") {
// iOS AVPlayer codec constraints
return {
@@ -65,11 +64,12 @@ const getCodecProfile = (platform: PlatformType): CodecProfile => {
* This profile is specifically for standalone audio playback using:
* - AVPlayer on iOS
* - ExoPlayer on Android
*
* @param {TrackPlayerProfileOptions} [options] - Profile configuration options
* @returns {Object} Jellyfin device profile for track player
*/
export const generateTrackPlayerProfile = (
options: TrackPlayerProfileOptions = {},
): DeviceProfile => {
const platform = (options.platform || Platform.OS) as PlatformType;
export const generateTrackPlayerProfile = (options = {}) => {
const platform = options.platform || Platform.OS;
return {
Name: "Track Player",

View File

@@ -10,7 +10,6 @@ export interface BuildMeta {
commit?: string | null;
branch?: string | null;
profile?: string | null;
runNumber?: string | null;
builtAt?: string | null;
}
@@ -23,10 +22,8 @@ export interface VersionInfo {
commit: string | null;
/** Git branch the build was made from, e.g. "develop". */
branch: string | null;
/** EAS build profile, e.g. "production", "ci", "preview", or null for local. */
/** EAS build profile, e.g. "production", "preview", or null for local. */
profile: string | null;
/** GitHub Actions run number the build came from, e.g. "2098". Null outside CI. */
runNumber: string | null;
isDev: boolean;
isProduction: boolean;
/** Graduated label for the Settings "App version" row (see tiering below). */
@@ -37,13 +34,13 @@ export interface VersionInfo {
* Resolve a graduated version string for Settings.
*
* Tiering (most → least detailed):
* - dev / local build → `version · branch · commit` (full context for debugging)
* - develop / CI / preview → `version · commit · #run` (pin the exact source; the
* Actions run number maps the build to its run — artifacts + logs — without
* Expo access)
* - production (store / TestFlight) → `version` (build number intentionally
* not shown: TestFlight already displays it to testers, and the commit pins the
* binary better)
* - dev / local build → `version · branch · commit` (full context for debugging)
* - develop / CI / preview → `version · commit` (pin the exact source)
* - production (store / TestFlight) → `version (build)` (store-correlatable; the
* build number lets TestFlight reports pin a build whose version isn't a
* published release. Note: TestFlight and the public App Store ship the same
* binary — telling them apart needs a runtime iOS receipt check, intentionally
* not done here.)
*/
export function getVersionInfo(): VersionInfo {
// Read native/config values defensively — a version string must never crash Settings
@@ -63,7 +60,6 @@ export function getVersionInfo(): VersionInfo {
const commit = meta.commit ?? null;
const branch = meta.branch ?? null;
const profile = meta.profile ?? null;
const runNumber = meta.runNumber ?? null;
const isDev = __DEV__ === true;
const isProduction =
typeof profile === "string" && profile.startsWith("production");
@@ -72,12 +68,10 @@ export function getVersionInfo(): VersionInfo {
if (isDev) {
display = [version ?? "dev", branch, commit].filter(Boolean).join(" · ");
} else if (isProduction) {
display = version ?? build ?? "N/A";
} else {
display =
[version, commit, runNumber && `#${runNumber}`]
.filter(Boolean)
.join(" · ") || "N/A";
version && build ? `${version} (${build})` : (version ?? build ?? "N/A");
} else {
display = [version, commit].filter(Boolean).join(" · ") || version || "N/A";
}
return {
@@ -86,7 +80,6 @@ export function getVersionInfo(): VersionInfo {
commit,
branch,
profile,
runNumber,
isDev,
isProduction,
display,