From 8ca330d765e3b4fe3f94cf3fc191fc35b4ef8ea1 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sun, 28 Sep 2025 14:55:31 +0200 Subject: [PATCH 01/25] chore(deps): Update github/codeql-action action to v3.30.5 (#1094) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/ci-codeql.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci-codeql.yml b/.github/workflows/ci-codeql.yml index 024cc6e9..0e1ee2c3 100644 --- a/.github/workflows/ci-codeql.yml +++ b/.github/workflows/ci-codeql.yml @@ -31,13 +31,13 @@ jobs: fetch-depth: 0 - name: ๐Ÿ Initialize CodeQL - uses: github/codeql-action/init@303c0aef88fc2fe5ff6d63d3b1596bfd83dfa1f9 # v3.30.4 + uses: github/codeql-action/init@3599b3baa15b485a2e49ef411a7a4bb2452e7f93 # v3.30.5 with: languages: ${{ matrix.language }} queries: +security-extended,security-and-quality - name: ๐Ÿ› ๏ธ Autobuild - uses: github/codeql-action/autobuild@303c0aef88fc2fe5ff6d63d3b1596bfd83dfa1f9 # v3.30.4 + uses: github/codeql-action/autobuild@3599b3baa15b485a2e49ef411a7a4bb2452e7f93 # v3.30.5 - name: ๐Ÿงช Perform CodeQL Analysis - uses: github/codeql-action/analyze@303c0aef88fc2fe5ff6d63d3b1596bfd83dfa1f9 # v3.30.4 + uses: github/codeql-action/analyze@3599b3baa15b485a2e49ef411a7a4bb2452e7f93 # v3.30.5 From 1b3a5443ef71ab0ce8729e0fa6ff8840f3543193 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sun, 28 Sep 2025 14:55:39 +0200 Subject: [PATCH 02/25] chore(deps): Update actions/dependency-review-action action to v4.7.4 (#1093) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/linting.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/linting.yml b/.github/workflows/linting.yml index 9d09d751..e1aba157 100644 --- a/.github/workflows/linting.yml +++ b/.github/workflows/linting.yml @@ -57,7 +57,7 @@ jobs: fetch-depth: 0 - name: Dependency Review - uses: actions/dependency-review-action@595b5aeba73380359d98a5e087f648dbb0edce1b # v4.7.3 + uses: actions/dependency-review-action@31c9f175b9cbbdee66d6ab34ed35e2c827f8be10 # v4.7.4 with: fail-on-severity: high base-ref: ${{ github.event.pull_request.base.sha || 'develop' }} From 26c99cfc3d658e82065f73c998463859262f193d Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sun, 28 Sep 2025 14:59:24 +0200 Subject: [PATCH 03/25] chore(deps): Update actions/dependency-review-action action to v4.8.0 (#1095) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/linting.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/linting.yml b/.github/workflows/linting.yml index e1aba157..ec3ab9aa 100644 --- a/.github/workflows/linting.yml +++ b/.github/workflows/linting.yml @@ -57,7 +57,7 @@ jobs: fetch-depth: 0 - name: Dependency Review - uses: actions/dependency-review-action@31c9f175b9cbbdee66d6ab34ed35e2c827f8be10 # v4.7.4 + uses: actions/dependency-review-action@56339e523c0409420f6c2c9a2f4292bbb3c07dd3 # v4.8.0 with: fail-on-severity: high base-ref: ${{ github.event.pull_request.base.sha || 'develop' }} From af6b18546ebf2240fd1d5451e758d754edfa8f56 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Mon, 29 Sep 2025 11:22:39 +0200 Subject: [PATCH 04/25] fix: ios 26 design improvements (#1097) --- app/(auth)/(tabs)/(custom-links)/_layout.tsx | 2 +- app/(auth)/(tabs)/(favorites)/_layout.tsx | 6 +- app/(auth)/(tabs)/(home)/_layout.tsx | 24 +- app/(auth)/(tabs)/(libraries)/_layout.tsx | 267 +++++-------------- app/(auth)/(tabs)/(search)/_layout.tsx | 6 +- bun.lock | 66 +++-- components/AddToFavorites.tsx | 14 +- components/Chromecast.tsx | 18 +- components/ItemContent.tsx | 28 +- components/PlayedStatus.tsx | 17 +- components/RoundButton.tsx | 21 ++ components/common/HeaderBackButton.tsx | 12 + components/settings/LibraryOptionsSheet.tsx | 254 ++++++++++++++++++ components/stacks/NestedTabPageStack.tsx | 1 + package.json | 10 +- 15 files changed, 488 insertions(+), 258 deletions(-) create mode 100644 components/settings/LibraryOptionsSheet.tsx diff --git a/app/(auth)/(tabs)/(custom-links)/_layout.tsx b/app/(auth)/(tabs)/(custom-links)/_layout.tsx index a580146f..f3b436a7 100644 --- a/app/(auth)/(tabs)/(custom-links)/_layout.tsx +++ b/app/(auth)/(tabs)/(custom-links)/_layout.tsx @@ -12,7 +12,7 @@ export default function CustomMenuLayout() { headerShown: true, headerLargeTitle: true, headerTitle: t("tabs.custom_links"), - headerBlurEffect: "prominent", + headerBlurEffect: "none", headerTransparent: Platform.OS === "ios", headerShadowVisible: false, }} diff --git a/app/(auth)/(tabs)/(favorites)/_layout.tsx b/app/(auth)/(tabs)/(favorites)/_layout.tsx index 9f75619f..2a0139f4 100644 --- a/app/(auth)/(tabs)/(favorites)/_layout.tsx +++ b/app/(auth)/(tabs)/(favorites)/_layout.tsx @@ -11,12 +11,8 @@ export default function SearchLayout() { name='index' options={{ headerShown: !Platform.isTV, - headerLargeTitle: true, headerTitle: t("tabs.favorites"), - headerLargeStyle: { - backgroundColor: "black", - }, - headerBlurEffect: "prominent", + headerBlurEffect: "none", headerTransparent: Platform.OS === "ios", headerShadowVisible: false, }} diff --git a/app/(auth)/(tabs)/(home)/_layout.tsx b/app/(auth)/(tabs)/(home)/_layout.tsx index 5ad41f97..5e43476d 100644 --- a/app/(auth)/(tabs)/(home)/_layout.tsx +++ b/app/(auth)/(tabs)/(home)/_layout.tsx @@ -21,19 +21,16 @@ export default function IndexLayout() { name='index' options={{ headerShown: !Platform.isTV, - headerLargeTitle: true, headerTitle: t("tabs.home"), - headerBlurEffect: "prominent", - headerLargeStyle: { - backgroundColor: "black", - }, + headerBlurEffect: "none", headerTransparent: Platform.OS === "ios", headerShadowVisible: false, headerRight: () => ( - + {!Platform.isTV && ( <> - + + {user?.Policy?.IsAdministrator && } @@ -138,14 +135,13 @@ const SessionsButton = () => { onPress={() => { router.push("/(auth)/sessions"); }} + className='mr-4' > - - - + ); }; diff --git a/app/(auth)/(tabs)/(libraries)/_layout.tsx b/app/(auth)/(tabs)/(libraries)/_layout.tsx index e450e3ec..89a3e847 100644 --- a/app/(auth)/(tabs)/(libraries)/_layout.tsx +++ b/app/(auth)/(tabs)/(libraries)/_layout.tsx @@ -1,224 +1,85 @@ import { Ionicons } from "@expo/vector-icons"; import { Stack } from "expo-router"; -import { Platform } from "react-native"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Platform, TouchableOpacity } from "react-native"; +import { LibraryOptionsSheet } from "@/components/settings/LibraryOptionsSheet"; import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack"; import { useSettings } from "@/utils/atoms/settings"; -const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null; - -import { useTranslation } from "react-i18next"; - export default function IndexLayout() { const { settings, updateSettings, pluginSettings } = useSettings(); + const [optionsSheetOpen, setOptionsSheetOpen] = useState(false); const { t } = useTranslation(); if (!settings?.libraryOptions) return null; return ( - - - !pluginSettings?.libraryOptions?.locked && - !Platform.isTV && ( - - + <> + + + !pluginSettings?.libraryOptions?.locked && + !Platform.isTV && ( + setOptionsSheetOpen(true)} + className='flex flex-row items-center justify-center w-9 h-9' + > - - - - {t("library.options.display")} - - - - - {t("library.options.display")} - - - - updateSettings({ - libraryOptions: { - ...settings.libraryOptions, - display: "row", - }, - }) - } - > - - - {t("library.options.row")} - - - - updateSettings({ - libraryOptions: { - ...settings.libraryOptions, - display: "list", - }, - }) - } - > - - - {t("library.options.list")} - - - - - - - {t("library.options.image_style")} - - - - updateSettings({ - libraryOptions: { - ...settings.libraryOptions, - imageStyle: "poster", - }, - }) - } - > - - - {t("library.options.poster")} - - - - updateSettings({ - libraryOptions: { - ...settings.libraryOptions, - imageStyle: "cover", - }, - }) - } - > - - - {t("library.options.cover")} - - - - - - - { - if (settings.libraryOptions.imageStyle === "poster") - return; - updateSettings({ - libraryOptions: { - ...settings.libraryOptions, - showTitles: newValue === "on", - }, - }); - }} - > - - - {t("library.options.show_titles")} - - - { - updateSettings({ - libraryOptions: { - ...settings.libraryOptions, - showStats: newValue === "on", - }, - }); - }} - > - - - {t("library.options.show_stats")} - - - - - - - - ), - }} + + ), + }} + /> + + {Object.entries(nestedTabPageScreenOptions).map(([name, options]) => ( + + ))} + + + + updateSettings({ + libraryOptions: { + ...settings.libraryOptions, + ...options, + }, + }) + } + disabled={pluginSettings?.libraryOptions?.locked} /> - - {Object.entries(nestedTabPageScreenOptions).map(([name, options]) => ( - - ))} - - + ); } diff --git a/app/(auth)/(tabs)/(search)/_layout.tsx b/app/(auth)/(tabs)/(search)/_layout.tsx index 9caf105b..4577a38a 100644 --- a/app/(auth)/(tabs)/(search)/_layout.tsx +++ b/app/(auth)/(tabs)/(search)/_layout.tsx @@ -14,12 +14,8 @@ export default function SearchLayout() { name='index' options={{ headerShown: !Platform.isTV, - headerLargeTitle: true, headerTitle: t("tabs.search"), - headerLargeStyle: { - backgroundColor: "black", - }, - headerBlurEffect: "prominent", + headerBlurEffect: "none", headerTransparent: Platform.OS === "ios", headerShadowVisible: false, }} diff --git a/bun.lock b/bun.lock index c511875f..39f33dc9 100644 --- a/bun.lock +++ b/bun.lock @@ -4,8 +4,8 @@ "": { "name": "streamyfin", "dependencies": { - "@bottom-tabs/react-navigation": "^0.9.2", - "@expo/metro-runtime": "~5.0.4", + "@bottom-tabs/react-navigation": "^0.11.2", + "@expo/metro-runtime": "~5.0.5", "@expo/react-native-action-sheet": "^4.1.1", "@expo/vector-icons": "^14.1.0", "@gorhom/bottom-sheet": "^5.1.0", @@ -18,7 +18,7 @@ "@shopify/flash-list": "^1.8.3", "@tanstack/react-query": "^5.66.0", "axios": "^1.7.9", - "expo": "^53.0.22", + "expo": "^53.0.23", "expo-application": "~6.1.4", "expo-asset": "~11.1.7", "expo-background-task": "~0.2.8", @@ -35,7 +35,7 @@ "expo-linking": "~7.1.4", "expo-localization": "~16.1.5", "expo-notifications": "~0.31.2", - "expo-router": "~5.1.5", + "expo-router": "~5.1.7", "expo-screen-orientation": "~8.1.6", "expo-sensors": "~14.1.4", "expo-sharing": "~13.1.5", @@ -53,7 +53,7 @@ "react-i18next": "^15.4.0", "react-native": "npm:react-native-tvos@0.79.5-0", "react-native-awesome-slider": "^2.9.0", - "react-native-bottom-tabs": "^0.9.2", + "react-native-bottom-tabs": "^0.11.2", "react-native-circular-progress": "^1.4.1", "react-native-collapsible": "^1.6.2", "react-native-country-flag": "^2.0.2", @@ -315,7 +315,7 @@ "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.2.4", "", { "os": "win32", "cpu": "x64" }, "sha512-3Y4V4zVRarVh/B/eSHczR4LYoSVyv3Dfuvm3cWs5w/HScccS0+Wt/lHOcDTRYeHjQmMYVC3rIRWqyN2EI52+zg=="], - "@bottom-tabs/react-navigation": ["@bottom-tabs/react-navigation@0.9.2", "", { "dependencies": { "color": "^4.2.3" }, "peerDependencies": { "@react-navigation/native": ">=7", "react": "*", "react-native": "*", "react-native-bottom-tabs": "*" } }, "sha512-IZZKllcaqCGsKIgeXmYFGU95IXxbBpXtwKws4Lg2GJw/qqAYYsPFEl0JBvnymSD7G1zkHYEilg5UHuTd0NmX7A=="], + "@bottom-tabs/react-navigation": ["@bottom-tabs/react-navigation@0.11.2", "", { "dependencies": { "color": "^5.0.0" }, "peerDependencies": { "@react-navigation/native": ">=7", "react": "*", "react-native": "*", "react-native-bottom-tabs": "*" } }, "sha512-xjRZZe3GZ/bIADBkJSe+qjRC/pQKcTEhZgtoGb4lyINq1NPzhKXhlZHwZLzNJng/Q/+F4RD3M7bQ6oCgSHV2WA=="], "@dominicstop/ts-event-emitter": ["@dominicstop/ts-event-emitter@1.1.0", "", {}, "sha512-CcxmJIvUb1vsFheuGGVSQf4KdPZC44XolpUT34+vlal+LyQoBUOn31pjFET5M9ctOxEpt8xa0M3/2M7uUiAoJw=="], @@ -323,7 +323,7 @@ "@epic-web/invariant": ["@epic-web/invariant@1.0.0", "", {}, "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA=="], - "@expo/cli": ["@expo/cli@0.24.21", "", { "dependencies": { "@0no-co/graphql.web": "^1.0.8", "@babel/runtime": "^7.20.0", "@expo/code-signing-certificates": "^0.0.5", "@expo/config": "~11.0.13", "@expo/config-plugins": "~10.1.2", "@expo/devcert": "^1.1.2", "@expo/env": "~1.0.7", "@expo/image-utils": "^0.7.6", "@expo/json-file": "^9.1.5", "@expo/metro-config": "~0.20.17", "@expo/osascript": "^2.2.5", "@expo/package-manager": "^1.8.6", "@expo/plist": "^0.3.5", "@expo/prebuild-config": "^9.0.11", "@expo/schema-utils": "^0.1.0", "@expo/spawn-async": "^1.7.2", "@expo/ws-tunnel": "^1.0.1", "@expo/xcpretty": "^4.3.0", "@react-native/dev-middleware": "0.79.6", "@urql/core": "^5.0.6", "@urql/exchange-retry": "^1.3.0", "accepts": "^1.3.8", "arg": "^5.0.2", "better-opn": "~3.0.2", "bplist-creator": "0.1.0", "bplist-parser": "^0.3.1", "chalk": "^4.0.0", "ci-info": "^3.3.0", "compression": "^1.7.4", "connect": "^3.7.0", "debug": "^4.3.4", "env-editor": "^0.4.1", "freeport-async": "^2.0.0", "getenv": "^2.0.0", "glob": "^10.4.2", "lan-network": "^0.1.6", "minimatch": "^9.0.0", "node-forge": "^1.3.1", "npm-package-arg": "^11.0.0", "ora": "^3.4.0", "picomatch": "^3.0.1", "pretty-bytes": "^5.6.0", "pretty-format": "^29.7.0", "progress": "^2.0.3", "prompts": "^2.3.2", "qrcode-terminal": "0.11.0", "require-from-string": "^2.0.2", "requireg": "^0.2.2", "resolve": "^1.22.2", "resolve-from": "^5.0.0", "resolve.exports": "^2.0.3", "semver": "^7.6.0", "send": "^0.19.0", "slugify": "^1.3.4", "source-map-support": "~0.5.21", "stacktrace-parser": "^0.1.10", "structured-headers": "^0.4.1", "tar": "^7.4.3", "terminal-link": "^2.1.1", "undici": "^6.18.2", "wrap-ansi": "^7.0.0", "ws": "^8.12.1" }, "bin": { "expo-internal": "build/bin/cli" } }, "sha512-DT6K9vgFHqqWL/19mU1ofRcPoO1pn4qmgi76GtuiNU4tbBe/02mRHwFsQw7qRfFAT28If5e/wiwVozgSuZVL8g=="], + "@expo/cli": ["@expo/cli@0.24.22", "", { "dependencies": { "@0no-co/graphql.web": "^1.0.8", "@babel/runtime": "^7.20.0", "@expo/code-signing-certificates": "^0.0.5", "@expo/config": "~11.0.13", "@expo/config-plugins": "~10.1.2", "@expo/devcert": "^1.1.2", "@expo/env": "~1.0.7", "@expo/image-utils": "^0.7.6", "@expo/json-file": "^9.1.5", "@expo/metro-config": "~0.20.17", "@expo/osascript": "^2.2.5", "@expo/package-manager": "^1.8.6", "@expo/plist": "^0.3.5", "@expo/prebuild-config": "^9.0.12", "@expo/schema-utils": "^0.1.0", "@expo/spawn-async": "^1.7.2", "@expo/ws-tunnel": "^1.0.1", "@expo/xcpretty": "^4.3.0", "@react-native/dev-middleware": "0.79.6", "@urql/core": "^5.0.6", "@urql/exchange-retry": "^1.3.0", "accepts": "^1.3.8", "arg": "^5.0.2", "better-opn": "~3.0.2", "bplist-creator": "0.1.0", "bplist-parser": "^0.3.1", "chalk": "^4.0.0", "ci-info": "^3.3.0", "compression": "^1.7.4", "connect": "^3.7.0", "debug": "^4.3.4", "env-editor": "^0.4.1", "freeport-async": "^2.0.0", "getenv": "^2.0.0", "glob": "^10.4.2", "lan-network": "^0.1.6", "minimatch": "^9.0.0", "node-forge": "^1.3.1", "npm-package-arg": "^11.0.0", "ora": "^3.4.0", "picomatch": "^3.0.1", "pretty-bytes": "^5.6.0", "pretty-format": "^29.7.0", "progress": "^2.0.3", "prompts": "^2.3.2", "qrcode-terminal": "0.11.0", "require-from-string": "^2.0.2", "requireg": "^0.2.2", "resolve": "^1.22.2", "resolve-from": "^5.0.0", "resolve.exports": "^2.0.3", "semver": "^7.6.0", "send": "^0.19.0", "slugify": "^1.3.4", "source-map-support": "~0.5.21", "stacktrace-parser": "^0.1.10", "structured-headers": "^0.4.1", "tar": "^7.4.3", "terminal-link": "^2.1.1", "undici": "^6.18.2", "wrap-ansi": "^7.0.0", "ws": "^8.12.1" }, "bin": { "expo-internal": "build/bin/cli" } }, "sha512-cEg6/F8ZWjoVkEwm0rXKReWbsCUROFbLFBYht+d5RzHnDwJoTX4QWJKx4m+TGNDPamRUIGw36U4z41Fvev0XmA=="], "@expo/code-signing-certificates": ["@expo/code-signing-certificates@0.0.5", "", { "dependencies": { "node-forge": "^1.2.1", "nullthrows": "^1.1.1" } }, "sha512-BNhXkY1bblxKZpltzAx98G2Egj9g1Q+JRcvR7E99DOj862FTCX+ZPsAUtPTr7aHxwtrL7+fL3r0JSmM9kBm+Bw=="], @@ -345,7 +345,7 @@ "@expo/metro-config": ["@expo/metro-config@0.20.17", "", { "dependencies": { "@babel/core": "^7.20.0", "@babel/generator": "^7.20.5", "@babel/parser": "^7.20.0", "@babel/types": "^7.20.0", "@expo/config": "~11.0.12", "@expo/env": "~1.0.7", "@expo/json-file": "~9.1.5", "@expo/spawn-async": "^1.7.2", "chalk": "^4.1.0", "debug": "^4.3.2", "dotenv": "~16.4.5", "dotenv-expand": "~11.0.6", "getenv": "^2.0.0", "glob": "^10.4.2", "jsc-safe-url": "^0.2.4", "lightningcss": "~1.27.0", "minimatch": "^9.0.0", "postcss": "~8.4.32", "resolve-from": "^5.0.0" } }, "sha512-lpntF2UZn5bTwrPK6guUv00Xv3X9mkN3YYla+IhEHiYXWyG7WKOtDU0U4KR8h3ubkZ6SPH3snDyRyAzMsWtZFA=="], - "@expo/metro-runtime": ["@expo/metro-runtime@5.0.4", "", { "peerDependencies": { "react-native": "*" } }, "sha512-r694MeO+7Vi8IwOsDIDzH/Q5RPMt1kUDYbiTJwnO15nIqiDwlE8HU55UlRhffKZy6s5FmxQsZ8HA+T8DqUW8cQ=="], + "@expo/metro-runtime": ["@expo/metro-runtime@5.0.5", "", { "peerDependencies": { "react-native": "*" } }, "sha512-P8UFTi+YsmiD1BmdTdiIQITzDMcZgronsA3RTQ4QKJjHM3bas11oGzLQOnFaIZnlEV8Rrr3m1m+RHxvnpL+t/A=="], "@expo/osascript": ["@expo/osascript@2.2.5", "", { "dependencies": { "@expo/spawn-async": "^1.7.2", "exec-async": "^2.2.0" } }, "sha512-Bpp/n5rZ0UmpBOnl7Li3LtM7la0AR3H9NNesqL+ytW5UiqV/TbonYW3rDZY38u4u/lG7TnYflVIVQPD+iqZJ5w=="], @@ -817,13 +817,13 @@ "clone": ["clone@1.0.4", "", {}, "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg=="], - "color": ["color@4.2.3", "", { "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" } }, "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A=="], + "color": ["color@5.0.2", "", { "dependencies": { "color-convert": "^3.0.1", "color-string": "^2.0.0" } }, "sha512-e2hz5BzbUPcYlIRHo8ieAhYgoajrJr+hWoceg6E345TPsATMUKqDgzt8fSXZJJbxfpiPzkWyphz8yn8At7q3fA=="], - "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], + "color-convert": ["color-convert@3.1.2", "", { "dependencies": { "color-name": "^2.0.0" } }, "sha512-UNqkvCDXstVck3kdowtOTWROIJQwafjOfXSmddoDrXo4cewMKmusCeF22Q24zvjR8nwWib/3S/dfyzPItPEiJg=="], - "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + "color-name": ["color-name@2.0.2", "", {}, "sha512-9vEt7gE16EW7Eu7pvZnR0abW9z6ufzhXxGXZEVU9IqPdlsUiMwJeJfRtq0zePUmnbHGT9zajca7mX8zgoayo4A=="], - "color-string": ["color-string@1.9.1", "", { "dependencies": { "color-name": "^1.0.0", "simple-swizzle": "^0.2.2" } }, "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg=="], + "color-string": ["color-string@2.1.2", "", { "dependencies": { "color-name": "^2.0.0" } }, "sha512-RxmjYxbWemV9gKu4zPgiZagUxbH3RQpEIO77XoSSX0ivgABDZ+h8Zuash/EMFLTI4N9QgFPOJ6JQpPZKFxa+dA=="], "colorette": ["colorette@2.0.20", "", {}, "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w=="], @@ -983,7 +983,7 @@ "expect": ["expect@29.7.0", "", { "dependencies": { "@jest/expect-utils": "^29.7.0", "jest-get-type": "^29.6.3", "jest-matcher-utils": "^29.7.0", "jest-message-util": "^29.7.0", "jest-util": "^29.7.0" } }, "sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw=="], - "expo": ["expo@53.0.22", "", { "dependencies": { "@babel/runtime": "^7.20.0", "@expo/cli": "0.24.21", "@expo/config": "~11.0.13", "@expo/config-plugins": "~10.1.2", "@expo/fingerprint": "0.13.4", "@expo/metro-config": "0.20.17", "@expo/vector-icons": "^14.0.0", "babel-preset-expo": "~13.2.4", "expo-asset": "~11.1.7", "expo-constants": "~17.1.7", "expo-file-system": "~18.1.11", "expo-font": "~13.3.2", "expo-keep-awake": "~14.1.4", "expo-modules-autolinking": "2.1.14", "expo-modules-core": "2.5.0", "react-native-edge-to-edge": "1.6.0", "whatwg-url-without-unicode": "8.0.0-3" }, "peerDependencies": { "@expo/dom-webview": "*", "@expo/metro-runtime": "*", "react": "*", "react-native": "*", "react-native-webview": "*" }, "optionalPeers": ["@expo/dom-webview", "@expo/metro-runtime", "react-native-webview"], "bin": { "expo": "bin/cli", "fingerprint": "bin/fingerprint", "expo-modules-autolinking": "bin/autolinking" } }, "sha512-sJ2I4W/e5iiM4u/wYCe3qmW4D7WPCRqByPDD0hJcdYNdjc9HFFFdO4OAudZVyC/MmtoWZEIH5kTJP1cw9FjzYA=="], + "expo": ["expo@53.0.23", "", { "dependencies": { "@babel/runtime": "^7.20.0", "@expo/cli": "0.24.22", "@expo/config": "~11.0.13", "@expo/config-plugins": "~10.1.2", "@expo/fingerprint": "0.13.4", "@expo/metro-config": "0.20.17", "@expo/vector-icons": "^14.0.0", "babel-preset-expo": "~13.2.4", "expo-asset": "~11.1.7", "expo-constants": "~17.1.7", "expo-file-system": "~18.1.11", "expo-font": "~13.3.2", "expo-keep-awake": "~14.1.4", "expo-modules-autolinking": "2.1.14", "expo-modules-core": "2.5.0", "react-native-edge-to-edge": "1.6.0", "whatwg-url-without-unicode": "8.0.0-3" }, "peerDependencies": { "@expo/dom-webview": "*", "@expo/metro-runtime": "*", "react": "*", "react-native": "*", "react-native-webview": "*" }, "optionalPeers": ["@expo/dom-webview", "@expo/metro-runtime", "react-native-webview"], "bin": { "expo": "bin/cli", "fingerprint": "bin/fingerprint", "expo-modules-autolinking": "bin/autolinking" } }, "sha512-6TOLuNCP3AsSkXBJA5W6U/7wpZUop3Q6BxHMtRD2OOgT7CCPvnYgJdnTzqU+gD1hMfcryD8Ejq9RdHbLduXohg=="], "expo-application": ["expo-application@6.1.5", "", { "peerDependencies": { "expo": "*" } }, "sha512-ToImFmzw8luY043pWFJhh2ZMm4IwxXoHXxNoGdlhD4Ym6+CCmkAvCglg0FK8dMLzAb+/XabmOE7Rbm8KZb6NZg=="], @@ -1037,7 +1037,7 @@ "expo-notifications": ["expo-notifications@0.31.4", "", { "dependencies": { "@expo/image-utils": "^0.7.6", "@ide/backoff": "^1.0.0", "abort-controller": "^3.0.0", "assert": "^2.0.0", "badgin": "^1.1.5", "expo-application": "~6.1.5", "expo-constants": "~17.1.7" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-NnGKIFGpgZU66qfiFUyjEBYsS77VahURpSSeWEOLt+P1zOaUFlgx2XqS+dxH3/Bn1Vm7TMj04qKsK5KvzR/8Lw=="], - "expo-router": ["expo-router@5.1.5", "", { "dependencies": { "@expo/metro-runtime": "5.0.4", "@expo/schema-utils": "^0.1.0", "@expo/server": "^0.6.3", "@radix-ui/react-slot": "1.2.0", "@react-navigation/bottom-tabs": "^7.3.10", "@react-navigation/native": "^7.1.6", "@react-navigation/native-stack": "^7.3.10", "client-only": "^0.0.1", "invariant": "^2.2.4", "react-fast-compare": "^3.2.2", "react-native-is-edge-to-edge": "^1.1.6", "semver": "~7.6.3", "server-only": "^0.0.1", "shallowequal": "^1.1.0" }, "peerDependencies": { "@react-navigation/drawer": "^7.3.9", "expo": "*", "expo-constants": "*", "expo-linking": "*", "react-native-reanimated": "*", "react-native-safe-area-context": "*", "react-native-screens": "*" }, "optionalPeers": ["@react-navigation/drawer", "react-native-reanimated"] }, "sha512-VPhS21DPP+riJIUshs/qpb11L/nzmRK7N7mqSFCr/mjpziznYu/qS+BPeQ88akIuXv6QsXipY5UTfYINdV+P0Q=="], + "expo-router": ["expo-router@5.1.7", "", { "dependencies": { "@expo/metro-runtime": "5.0.5", "@expo/schema-utils": "^0.1.0", "@expo/server": "^0.6.3", "@radix-ui/react-slot": "1.2.0", "@react-navigation/bottom-tabs": "^7.3.10", "@react-navigation/native": "^7.1.6", "@react-navigation/native-stack": "^7.3.10", "client-only": "^0.0.1", "invariant": "^2.2.4", "react-fast-compare": "^3.2.2", "react-native-is-edge-to-edge": "^1.1.6", "semver": "~7.6.3", "server-only": "^0.0.1", "shallowequal": "^1.1.0" }, "peerDependencies": { "@react-navigation/drawer": "^7.3.9", "expo": "*", "expo-constants": "*", "expo-linking": "*", "react-native-reanimated": "*", "react-native-safe-area-context": "*", "react-native-screens": "*" }, "optionalPeers": ["@react-navigation/drawer", "react-native-reanimated"] }, "sha512-E7hIqTZs4Cub4sbYPeednfYPi+2cyRGMdqc5IYBJ/vC+WBKoYJ8C9eU13ZLbPz//ZybSo2Dsm7v89uFIlO2Gow=="], "expo-screen-orientation": ["expo-screen-orientation@8.1.7", "", { "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-nYwadYtdU6mMDk0MCHMPPPQtBoeFYJ2FspLRW+J35CMLqzE4nbpwGeiImfXzkvD94fpOCfI4KgLj5vGauC3pfA=="], @@ -1609,7 +1609,7 @@ "react-native-awesome-slider": ["react-native-awesome-slider@2.9.0", "", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-gesture-handler": ">=2.0.0", "react-native-reanimated": ">=3.0.0" } }, "sha512-sc5qgX4YtM6IxjtosjgQLdsal120MvU+YWs0F2MdgQWijps22AXLDCUoBnZZ8vxVhVyJ2WnnIPrmtVBvVJjSuQ=="], - "react-native-bottom-tabs": ["react-native-bottom-tabs@0.9.2", "", { "dependencies": { "react-freeze": "^1.0.0", "sf-symbols-typescript": "^2.0.0", "use-latest-callback": "^0.2.1" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-kwLx9OM6v5P10TdmNhlEgb8nmwBOpwy3ULIxEv1v6cYjzuRkeYtA2dqYeFhJAn1rmWMrl3MnL3xzW5Q3IQyfAg=="], + "react-native-bottom-tabs": ["react-native-bottom-tabs@0.11.2", "", { "dependencies": { "react-freeze": "^1.0.0", "sf-symbols-typescript": "^2.0.0", "use-latest-callback": "^0.2.1" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-2zvR9DgQgqOKhxGeETkphXANDkMyUKN/i0+M+WF52JQd4q4h+uY3ctLnXNQ4pZf1cEDlWQ6aBtYWe3NJKvDIwA=="], "react-native-circular-progress": ["react-native-circular-progress@1.4.1", "", { "dependencies": { "prop-types": "^15.8.1" }, "peerDependencies": { "react": ">=16.0.0", "react-native": ">=0.50.0", "react-native-svg": ">=7.0.0" } }, "sha512-HEzvI0WPuWvsCgWE3Ff2HBTMgAEQB2GvTFw0KHyD/t1STAlDDRiolu0mEGhVvihKR3jJu3v3V4qzvSklY/7XzQ=="], @@ -2003,6 +2003,8 @@ "@babel/plugin-transform-runtime/@babel/helper-module-imports": ["@babel/helper-module-imports@7.27.1", "", { "dependencies": { "@babel/traverse": "^7.27.1", "@babel/types": "^7.27.1" } }, "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w=="], + "@expo/cli/@expo/prebuild-config": ["@expo/prebuild-config@9.0.12", "", { "dependencies": { "@expo/config": "~11.0.13", "@expo/config-plugins": "~10.1.2", "@expo/config-types": "^53.0.5", "@expo/image-utils": "^0.7.6", "@expo/json-file": "^9.1.5", "@react-native/normalize-colors": "0.79.6", "debug": "^4.3.1", "resolve-from": "^5.0.0", "semver": "^7.6.0", "xml2js": "0.6.0" } }, "sha512-AKH5Scf+gEMgGxZZaimrJI2wlUJlRoqzDNn7/rkhZa5gUTnO4l6slKak2YdaH+nXlOWCNfAQWa76NnpQIfmv6Q=="], + "@expo/cli/getenv": ["getenv@2.0.0", "", {}, "sha512-VilgtJj/ALgGY77fiLam5iD336eSWi96Q15JSAG1zi8NRBysm3LXKdGnHb4m5cuyxvOLQQKWpBZAT6ni4FI2iQ=="], "@expo/cli/glob": ["glob@10.4.5", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg=="], @@ -2101,6 +2103,12 @@ "@react-native/dev-middleware/open": ["open@7.4.2", "", { "dependencies": { "is-docker": "^2.0.0", "is-wsl": "^2.1.1" } }, "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q=="], + "@react-navigation/bottom-tabs/color": ["color@4.2.3", "", { "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" } }, "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A=="], + + "@react-navigation/elements/color": ["color@4.2.3", "", { "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" } }, "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A=="], + + "@react-navigation/material-top-tabs/color": ["color@4.2.3", "", { "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" } }, "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A=="], + "accepts/negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="], "ansi-fragments/colorette": ["colorette@1.4.0", "", {}, "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g=="], @@ -2109,6 +2117,8 @@ "ansi-fragments/strip-ansi": ["strip-ansi@5.2.0", "", { "dependencies": { "ansi-regex": "^4.1.0" } }, "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA=="], + "ansi-styles/color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], + "babel-preset-expo/@babel/helper-module-imports": ["@babel/helper-module-imports@7.27.1", "", { "dependencies": { "@babel/traverse": "^7.27.1", "@babel/types": "^7.27.1" } }, "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w=="], "better-opn/open": ["open@8.4.2", "", { "dependencies": { "define-lazy-prop": "^2.0.0", "is-docker": "^2.1.1", "is-wsl": "^2.2.0" } }, "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ=="], @@ -2325,12 +2335,26 @@ "@react-native/dev-middleware/open/is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="], + "@react-navigation/bottom-tabs/color/color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], + + "@react-navigation/bottom-tabs/color/color-string": ["color-string@1.9.1", "", { "dependencies": { "color-name": "^1.0.0", "simple-swizzle": "^0.2.2" } }, "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg=="], + + "@react-navigation/elements/color/color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], + + "@react-navigation/elements/color/color-string": ["color-string@1.9.1", "", { "dependencies": { "color-name": "^1.0.0", "simple-swizzle": "^0.2.2" } }, "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg=="], + + "@react-navigation/material-top-tabs/color/color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], + + "@react-navigation/material-top-tabs/color/color-string": ["color-string@1.9.1", "", { "dependencies": { "color-name": "^1.0.0", "simple-swizzle": "^0.2.2" } }, "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg=="], + "ansi-fragments/slice-ansi/ansi-styles": ["ansi-styles@3.2.1", "", { "dependencies": { "color-convert": "^1.9.0" } }, "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA=="], "ansi-fragments/slice-ansi/is-fullwidth-code-point": ["is-fullwidth-code-point@2.0.0", "", {}, "sha512-VHskAKYM8RfSFXwee5t5cbN5PZeq1Wrh6qd5bkyiXIf6UQcN6w/A0eXM9r6t8d+GYOh+o6ZhiEnb88LN/Y8m2w=="], "ansi-fragments/strip-ansi/ansi-regex": ["ansi-regex@4.1.1", "", {}, "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g=="], + "ansi-styles/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + "better-opn/open/is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="], "body-parser/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], @@ -2423,6 +2447,18 @@ "@react-native/community-cli-plugin/@react-native/dev-middleware/open/is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="], + "@react-navigation/bottom-tabs/color/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + + "@react-navigation/bottom-tabs/color/color-string/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + + "@react-navigation/elements/color/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + + "@react-navigation/elements/color/color-string/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + + "@react-navigation/material-top-tabs/color/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + + "@react-navigation/material-top-tabs/color/color-string/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + "ansi-fragments/slice-ansi/ansi-styles/color-convert": ["color-convert@1.9.3", "", { "dependencies": { "color-name": "1.1.3" } }, "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg=="], "cli-truncate/string-width/strip-ansi/ansi-regex": ["ansi-regex@6.1.0", "", {}, "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA=="], diff --git a/components/AddToFavorites.tsx b/components/AddToFavorites.tsx index 156ed194..a00d20bb 100644 --- a/components/AddToFavorites.tsx +++ b/components/AddToFavorites.tsx @@ -1,6 +1,6 @@ import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; import type { FC } from "react"; -import { View, type ViewProps } from "react-native"; +import { Platform, View, type ViewProps } from "react-native"; import { RoundButton } from "@/components/RoundButton"; import { useFavorite } from "@/hooks/useFavorite"; @@ -11,6 +11,18 @@ interface Props extends ViewProps { export const AddToFavorites: FC = ({ item, ...props }) => { const { isFavorite, toggleFavorite } = useFavorite(item); + if (Platform.OS === "ios") { + return ( + + + + ); + } + return ( { + if (mediaStatus?.currentItemId) CastContext.showExpandedControls(); + else CastContext.showCastDialog(); + }} + {...props} + > + + + + ); + } + if (background === "transparent") return ( = React.memo( if (!Platform.isTV) { navigation.setOptions({ headerRight: () => - item && ( + item && + (Platform.OS === "ios" ? ( + + + {item.Type !== "Program" && ( + + {!Platform.isTV && ( + + )} + {user?.Policy?.IsAdministrator && ( + + )} + + + + + )} + + ) : ( - + {item.Type !== "Program" && ( {!Platform.isTV && ( @@ -126,7 +140,7 @@ export const ItemContent: React.FC = React.memo( )} - ), + )), }); } }, [item, navigation, user]); diff --git a/components/PlayedStatus.tsx b/components/PlayedStatus.tsx index e055b8cd..b1a6ac1f 100644 --- a/components/PlayedStatus.tsx +++ b/components/PlayedStatus.tsx @@ -1,6 +1,6 @@ import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import type React from "react"; -import { View, type ViewProps } from "react-native"; +import { Platform, View, type ViewProps } from "react-native"; import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed"; import { RoundButton } from "./RoundButton"; @@ -14,6 +14,21 @@ export const PlayedStatus: React.FC = ({ items, ...props }) => { const allPlayed = items.every((item) => item.UserData?.Played); const toggle = useMarkAsPlayed(items); + if (Platform.OS === "ios") { + return ( + + { + await toggle(!allPlayed); + }} + size={props.size} + /> + + ); + } + return ( > = ({ children, size = "default", fillColor, + color = "white", hapticFeedback = true, ...viewProps }) => { @@ -34,6 +36,25 @@ export const RoundButton: React.FC> = ({ onPress?.(); }; + if (Platform.OS === "ios") { + return ( + + {icon ? ( + + ) : null} + {children ? children : null} + + ); + } + if (fillColor) return ( = ({ }) => { const router = useRouter(); + if (Platform.OS === "ios") { + return ( + router.back()} + className='flex items-center justify-center w-9 h-9' + {...touchableOpacityProps} + > + + + ); + } + if (background === "transparent" && Platform.OS !== "android") return ( void; + settings: LibraryOptions; + updateSettings: (options: Partial) => void; + disabled?: boolean; +} + +const OptionGroup: React.FC<{ title: string; children: React.ReactNode }> = ({ + title, + children, +}) => ( + + {title} + + {children} + + +); + +const OptionItem: React.FC<{ + label: string; + selected: boolean; + onPress: () => void; + disabled?: boolean; + isLast?: boolean; +}> = ({ label, selected, onPress, disabled: itemDisabled, isLast }) => ( + <> + + {label} + {selected ? ( + + ) : ( + + )} + + {!isLast && ( + + )} + +); + +const ToggleItem: React.FC<{ + label: string; + value: boolean; + onToggle: () => void; + disabled?: boolean; + isLast?: boolean; +}> = ({ label, value, onToggle, disabled: itemDisabled, isLast }) => ( + <> + + {label} + + + + + {!isLast && ( + + )} + +); + +/** + * LibraryOptionsSheet Component + * + * This component creates a bottom sheet modal for managing library display options. + */ +export const LibraryOptionsSheet: React.FC = ({ + open, + setOpen, + settings, + updateSettings, + disabled = false, +}) => { + const bottomSheetModalRef = useRef(null); + const { t } = useTranslation(); + const insets = useSafeAreaInsets(); + + const handlePresentModal = useCallback(() => { + bottomSheetModalRef.current?.present(); + }, []); + + const handleDismissModal = useCallback(() => { + bottomSheetModalRef.current?.dismiss(); + }, []); + + useEffect(() => { + if (open) { + handlePresentModal(); + } else { + handleDismissModal(); + } + }, [open, handlePresentModal, handleDismissModal]); + + const handleSheetChanges = useCallback( + (index: number) => { + if (index === -1) { + setOpen(false); + } + }, + [setOpen], + ); + + const renderBackdrop = useCallback( + (props: BottomSheetBackdropProps) => ( + + ), + [], + ); + + if (disabled) return null; + + return ( + + + + + {t("library.options.display")} + + + + updateSettings({ display: "row" })} + /> + updateSettings({ display: "list" })} + isLast + /> + + + + updateSettings({ imageStyle: "poster" })} + /> + updateSettings({ imageStyle: "cover" })} + isLast + /> + + + + + updateSettings({ showTitles: !settings.showTitles }) + } + disabled={settings.imageStyle === "poster"} + /> + + updateSettings({ showStats: !settings.showStats }) + } + isLast + /> + + + + + ); +}; diff --git a/components/stacks/NestedTabPageStack.tsx b/components/stacks/NestedTabPageStack.tsx index 6c151b0f..ec4ba1e8 100644 --- a/components/stacks/NestedTabPageStack.tsx +++ b/components/stacks/NestedTabPageStack.tsx @@ -14,6 +14,7 @@ export const commonScreenOptions: ICommonScreenOptions = { headerShown: true, headerTransparent: true, headerShadowVisible: false, + headerBlurEffect: "none", headerLeft: () => , }; diff --git a/package.json b/package.json index e4653ebe..e7c38afb 100644 --- a/package.json +++ b/package.json @@ -22,8 +22,8 @@ "test": "bun run typecheck && bun run lint && bun run format && bun run doctor" }, "dependencies": { - "@bottom-tabs/react-navigation": "^0.9.2", - "@expo/metro-runtime": "~5.0.4", + "@bottom-tabs/react-navigation": "^0.11.2", + "@expo/metro-runtime": "~5.0.5", "@expo/react-native-action-sheet": "^4.1.1", "@expo/vector-icons": "^14.1.0", "@gorhom/bottom-sheet": "^5.1.0", @@ -36,7 +36,7 @@ "@shopify/flash-list": "^1.8.3", "@tanstack/react-query": "^5.66.0", "axios": "^1.7.9", - "expo": "^53.0.22", + "expo": "^53.0.23", "expo-application": "~6.1.4", "expo-asset": "~11.1.7", "expo-background-task": "~0.2.8", @@ -53,7 +53,7 @@ "expo-linking": "~7.1.4", "expo-localization": "~16.1.5", "expo-notifications": "~0.31.2", - "expo-router": "~5.1.5", + "expo-router": "~5.1.7", "expo-screen-orientation": "~8.1.6", "expo-sensors": "~14.1.4", "expo-sharing": "~13.1.5", @@ -71,7 +71,7 @@ "react-i18next": "^15.4.0", "react-native": "npm:react-native-tvos@0.79.5-0", "react-native-awesome-slider": "^2.9.0", - "react-native-bottom-tabs": "^0.9.2", + "react-native-bottom-tabs": "^0.11.2", "react-native-circular-progress": "^1.4.1", "react-native-collapsible": "^1.6.2", "react-native-country-flag": "^2.0.2", From cc2e6341378e95e06f69523b7e18764049954579 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Mon, 29 Sep 2025 11:32:34 +0200 Subject: [PATCH 05/25] feat: new large carousel (#1098) --- components/AppleTVCarousel.tsx | 749 +++++++++++++++++++++++++ components/ItemContent.tsx | 5 +- components/PlayButton.tsx | 28 +- components/PlayButton.tv.tsx | 22 +- components/home/LargeMovieCarousel.tsx | 25 +- components/settings/HomeIndex.tsx | 106 ++-- hooks/useImageColorsReturn.ts | 131 +++++ 7 files changed, 988 insertions(+), 78 deletions(-) create mode 100644 components/AppleTVCarousel.tsx create mode 100644 hooks/useImageColorsReturn.ts diff --git a/components/AppleTVCarousel.tsx b/components/AppleTVCarousel.tsx new file mode 100644 index 00000000..2f5b2d50 --- /dev/null +++ b/components/AppleTVCarousel.tsx @@ -0,0 +1,749 @@ +import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; +import { + getItemsApi, + getTvShowsApi, + getUserLibraryApi, +} from "@jellyfin/sdk/lib/utils/api"; +import { useQuery } from "@tanstack/react-query"; +import { Image } from "expo-image"; +import { LinearGradient } from "expo-linear-gradient"; +import { useAtomValue } from "jotai"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { Dimensions, Pressable, View } from "react-native"; +import { Gesture, GestureDetector } from "react-native-gesture-handler"; +import Animated, { + Easing, + runOnJS, + useAnimatedStyle, + useSharedValue, + withTiming, +} from "react-native-reanimated"; +import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings"; +import { useImageColorsReturn } from "@/hooks/useImageColorsReturn"; +import { useNetworkStatus } from "@/hooks/useNetworkStatus"; +import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; +import { useSettings } from "@/utils/atoms/settings"; +import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById"; +import { ItemImage } from "./common/ItemImage"; +import type { SelectedOptions } from "./ItemContent"; +import { PlayButton } from "./PlayButton"; +import { PlayedStatus } from "./PlayedStatus"; + +interface AppleTVCarouselProps { + initialIndex?: number; + onItemChange?: (index: number) => void; +} + +const { width: screenWidth, height: screenHeight } = Dimensions.get("window"); + +// Layout Constants +const CAROUSEL_HEIGHT = screenHeight / 1.45; +const GRADIENT_HEIGHT_TOP = 150; +const GRADIENT_HEIGHT_BOTTOM = 150; +const LOGO_HEIGHT = 80; + +// Position Constants +const LOGO_BOTTOM_POSITION = 210; +const GENRES_BOTTOM_POSITION = 170; +const CONTROLS_BOTTOM_POSITION = 100; +const DOTS_BOTTOM_POSITION = 60; + +// Size Constants +const DOT_HEIGHT = 6; +const DOT_ACTIVE_WIDTH = 20; +const DOT_INACTIVE_WIDTH = 12; +const PLAY_BUTTON_SKELETON_HEIGHT = 50; +const PLAYED_STATUS_SKELETON_SIZE = 40; +const TEXT_SKELETON_HEIGHT = 20; +const TEXT_SKELETON_WIDTH = 250; +const _EMPTY_STATE_ICON_SIZE = 64; + +// Spacing Constants +const HORIZONTAL_PADDING = 40; +const DOT_PADDING = 2; +const DOT_GAP = 4; +const CONTROLS_GAP = 20; +const _TEXT_MARGIN_TOP = 16; + +// Border Radius Constants +const DOT_BORDER_RADIUS = 3; +const LOGO_SKELETON_BORDER_RADIUS = 8; +const TEXT_SKELETON_BORDER_RADIUS = 4; +const PLAY_BUTTON_BORDER_RADIUS = 25; +const PLAYED_STATUS_BORDER_RADIUS = 20; + +// Animation Constants +const DOT_ANIMATION_DURATION = 300; +const CAROUSEL_TRANSITION_DURATION = 250; +const PAN_ACTIVE_OFFSET = 10; +const TRANSLATION_THRESHOLD = 0.2; +const VELOCITY_THRESHOLD = 400; + +// Text Constants +const GENRES_FONT_SIZE = 16; +const _EMPTY_STATE_FONT_SIZE = 18; +const TEXT_SHADOW_RADIUS = 2; +const MAX_GENRES_COUNT = 2; +const MAX_BUTTON_WIDTH = 300; + +// Opacity Constants +const OVERLAY_OPACITY = 0.4; +const DOT_INACTIVE_OPACITY = 0.6; +const TEXT_OPACITY = 0.9; + +// Color Constants +const SKELETON_BACKGROUND_COLOR = "#1a1a1a"; +const SKELETON_ELEMENT_COLOR = "#333"; +const SKELETON_ACTIVE_DOT_COLOR = "#666"; +const _EMPTY_STATE_COLOR = "#666"; +const TEXT_SHADOW_COLOR = "rgba(0, 0, 0, 0.8)"; +const LOGO_WIDTH_PERCENTAGE = "80%"; + +const DotIndicator = ({ + index, + currentIndex, + onPress, +}: { + index: number; + currentIndex: number; + onPress: (index: number) => void; +}) => { + const isActive = index === currentIndex; + + const animatedStyle = useAnimatedStyle(() => ({ + width: withTiming(isActive ? DOT_ACTIVE_WIDTH : DOT_INACTIVE_WIDTH, { + duration: DOT_ANIMATION_DURATION, + easing: Easing.out(Easing.quad), + }), + opacity: withTiming(isActive ? 1 : DOT_INACTIVE_OPACITY, { + duration: DOT_ANIMATION_DURATION, + easing: Easing.out(Easing.quad), + }), + })); + + return ( + onPress(index)} + style={{ + padding: DOT_PADDING, // Increase touch area + }} + > + + + ); +}; + +export const AppleTVCarousel: React.FC = ({ + initialIndex = 0, + onItemChange, +}) => { + const { settings } = useSettings(); + const api = useAtomValue(apiAtom); + const user = useAtomValue(userAtom); + const { isConnected, serverConnected } = useNetworkStatus(); + const [currentIndex, setCurrentIndex] = useState(initialIndex); + const translateX = useSharedValue(-currentIndex * screenWidth); + + const isQueryEnabled = + !!api && !!user?.Id && isConnected && serverConnected === true; + + const { data: continueWatchingData, isLoading: continueWatchingLoading } = + useQuery({ + queryKey: ["appleTVCarousel", "continueWatching", user?.Id], + queryFn: async () => { + if (!api || !user?.Id) return []; + const response = await getItemsApi(api).getResumeItems({ + userId: user.Id, + enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"], + includeItemTypes: ["Movie", "Series", "Episode"], + fields: ["Genres"], + limit: 2, + }); + return response.data.Items || []; + }, + enabled: isQueryEnabled, + staleTime: 60 * 1000, + }); + + const { data: nextUpData, isLoading: nextUpLoading } = useQuery({ + queryKey: ["appleTVCarousel", "nextUp", user?.Id], + queryFn: async () => { + if (!api || !user?.Id) return []; + const response = await getTvShowsApi(api).getNextUp({ + userId: user.Id, + fields: ["MediaSourceCount", "Genres"], + limit: 2, + enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"], + enableResumable: false, + }); + return response.data.Items || []; + }, + enabled: isQueryEnabled, + staleTime: 60 * 1000, + }); + + const { data: recentlyAddedData, isLoading: recentlyAddedLoading } = useQuery( + { + queryKey: ["appleTVCarousel", "recentlyAdded", user?.Id], + queryFn: async () => { + if (!api || !user?.Id) return []; + const response = await getUserLibraryApi(api).getLatestMedia({ + userId: user.Id, + limit: 2, + fields: ["PrimaryImageAspectRatio", "Path", "Genres"], + imageTypeLimit: 1, + enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"], + }); + return response.data || []; + }, + enabled: isQueryEnabled, + staleTime: 60 * 1000, + }, + ); + + const items = useMemo(() => { + const continueItems = continueWatchingData ?? []; + const nextItems = nextUpData ?? []; + const recentItems = recentlyAddedData ?? []; + + return [ + ...continueItems.slice(0, 2), + ...nextItems.slice(0, 2), + ...recentItems.slice(0, 2), + ]; + }, [continueWatchingData, nextUpData, recentlyAddedData]); + + const isLoading = + continueWatchingLoading || nextUpLoading || recentlyAddedLoading; + const hasItems = items.length > 0; + + // Only get play settings if we have valid items + const currentItem = hasItems ? items[currentIndex] : null; + + // Extract colors for the current item only (for performance) + const currentItemColors = useImageColorsReturn({ item: currentItem }); + + // Create a fallback empty item for useDefaultPlaySettings when no item is available + const itemForPlaySettings = currentItem || { MediaSources: [] }; + const { + defaultAudioIndex, + defaultBitrate, + defaultMediaSource, + defaultSubtitleIndex, + } = useDefaultPlaySettings(itemForPlaySettings as BaseItemDto, settings); + + const [selectedOptions, setSelectedOptions] = useState< + SelectedOptions | undefined + >(undefined); + + useEffect(() => { + // Only set options if we have valid current item + if (currentItem) { + setSelectedOptions({ + bitrate: defaultBitrate, + mediaSource: defaultMediaSource, + subtitleIndex: defaultSubtitleIndex ?? -1, + audioIndex: defaultAudioIndex, + }); + } else { + setSelectedOptions(undefined); + } + }, [ + defaultAudioIndex, + defaultBitrate, + defaultSubtitleIndex, + defaultMediaSource, + currentIndex, + currentItem, + ]); + + useEffect(() => { + if (!hasItems) { + setCurrentIndex(initialIndex); + translateX.value = -initialIndex * screenWidth; + return; + } + + setCurrentIndex((prev) => { + const newIndex = Math.min(prev, items.length - 1); + translateX.value = -newIndex * screenWidth; + return newIndex; + }); + }, [hasItems, items, initialIndex, translateX]); + + useEffect(() => { + if (hasItems) { + onItemChange?.(currentIndex); + } + }, [hasItems, currentIndex, onItemChange]); + + const goToIndex = useCallback( + (index: number) => { + if (!hasItems || index < 0 || index >= items.length) return; + + translateX.value = withTiming(-index * screenWidth, { + duration: CAROUSEL_TRANSITION_DURATION, // Slightly longer for smoother feel + easing: Easing.bezier(0.25, 0.46, 0.45, 0.94), // iOS-like smooth deceleration curve + }); + + setCurrentIndex(index); + onItemChange?.(index); + }, + [hasItems, items, onItemChange, translateX], + ); + + const panGesture = Gesture.Pan() + .activeOffsetX([-PAN_ACTIVE_OFFSET, PAN_ACTIVE_OFFSET]) + .onUpdate((event) => { + translateX.value = -currentIndex * screenWidth + event.translationX; + }) + .onEnd((event) => { + const velocity = event.velocityX; + const translation = event.translationX; + + let newIndex = currentIndex; + + // Improved thresholds for more responsive navigation + if ( + Math.abs(translation) > screenWidth * TRANSLATION_THRESHOLD || + Math.abs(velocity) > VELOCITY_THRESHOLD + ) { + if (translation > 0 && currentIndex > 0) { + newIndex = currentIndex - 1; + } else if ( + translation < 0 && + items && + currentIndex < items.length - 1 + ) { + newIndex = currentIndex + 1; + } + } + + runOnJS(goToIndex)(newIndex); + }); + + const containerAnimatedStyle = useAnimatedStyle(() => { + return { + transform: [{ translateX: translateX.value }], + }; + }); + + const renderDots = () => { + if (!hasItems || items.length <= 1) return null; + + return ( + + {items.map((_, index) => ( + + ))} + + ); + }; + + const renderSkeletonLoader = () => { + return ( + + {/* Background Skeleton */} + + + {/* Dark Overlay Skeleton */} + + + {/* Gradient Fade to Black Top Skeleton */} + + + {/* Gradient Fade to Black Bottom Skeleton */} + + + {/* Logo Skeleton */} + + + + + {/* Type and Genres Skeleton */} + + + + + {/* Controls Skeleton */} + + {/* Play Button Skeleton */} + + + {/* Played Status Skeleton */} + + + + {/* Dots Skeleton */} + + {[1, 2, 3].map((_, index) => ( + + ))} + + + ); + }; + + const renderItem = (item: BaseItemDto, _index: number) => { + const itemLogoUrl = api ? getLogoImageUrlById({ api, item }) : null; + + return ( + + {/* Background Backdrop */} + + + {/* Dark Overlay */} + + + {/* Gradient Fade to Black at Top */} + + + {/* Gradient Fade to Black at Bottom */} + + + {/* Logo Section */} + {itemLogoUrl && ( + + + + )} + + {/* Type and Genres Section */} + + + {(() => { + const typeLabel = + item.Type === "Series" + ? "TV Show" + : item.Type === "Movie" + ? "Movie" + : item.Type || ""; + + const genres = + item.Genres && item.Genres.length > 0 + ? item.Genres.slice(0, MAX_GENRES_COUNT).join(" โ€ข ") + : ""; + + if (typeLabel && genres) { + return `${typeLabel} โ€ข ${genres}`; + } else if (typeLabel) { + return typeLabel; + } else if (genres) { + return genres; + } else { + return ""; + } + })()} + + + + {/* Controls Section */} + + + {/* Play Button */} + + {selectedOptions && ( + + )} + + + {/* Mark as Played */} + + + + + ); + }; + + // Handle loading state + if (isLoading) { + return ( + + {renderSkeletonLoader()} + + ); + } + + // Handle empty items + if (!hasItems) { + return null; + } + + return ( + + + + {items.map((item, index) => renderItem(item, index))} + + + + {/* Animated Dots Indicator */} + {renderDots()} + + ); +}; diff --git a/components/ItemContent.tsx b/components/ItemContent.tsx index 4fc41301..070b02e2 100644 --- a/components/ItemContent.tsx +++ b/components/ItemContent.tsx @@ -22,7 +22,7 @@ import { CastAndCrew } from "@/components/series/CastAndCrew"; import { CurrentSeries } from "@/components/series/CurrentSeries"; import { SeasonEpisodesCarousel } from "@/components/series/SeasonEpisodesCarousel"; import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings"; -import { useImageColors } from "@/hooks/useImageColors"; +import { useImageColorsReturn } from "@/hooks/useImageColorsReturn"; import { useOrientation } from "@/hooks/useOrientation"; import * as ScreenOrientation from "@/packages/expo-screen-orientation"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; @@ -61,7 +61,7 @@ export const ItemContent: React.FC = React.memo( const [user] = useAtom(userAtom); const { t } = useTranslation(); - useImageColors({ item }); + const itemColors = useImageColorsReturn({ item }); const [loadingLogo, setLoadingLogo] = useState(true); const [headerHeight, setHeaderHeight] = useState(350); @@ -267,6 +267,7 @@ export const ItemContent: React.FC = React.memo( selectedOptions={selectedOptions} item={item} isOffline={isOffline} + colors={itemColors} /> diff --git a/components/PlayButton.tsx b/components/PlayButton.tsx index 3f4ca141..6ac1956e 100644 --- a/components/PlayButton.tsx +++ b/components/PlayButton.tsx @@ -23,6 +23,7 @@ import Animated, { withTiming, } from "react-native-reanimated"; import { useHaptic } from "@/hooks/useHaptic"; +import type { ThemeColors } from "@/hooks/useImageColorsReturn"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { itemThemeColorAtom } from "@/utils/atoms/primaryColor"; import { useSettings } from "@/utils/atoms/settings"; @@ -39,6 +40,7 @@ interface Props extends React.ComponentProps { item: BaseItemDto; selectedOptions: SelectedOptions; isOffline?: boolean; + colors?: ThemeColors; } const ANIMATION_DURATION = 500; @@ -48,6 +50,7 @@ export const PlayButton: React.FC = ({ item, selectedOptions, isOffline, + colors, ...props }: Props) => { const { showActionSheetWithOptions } = useActionSheet(); @@ -55,16 +58,19 @@ export const PlayButton: React.FC = ({ const mediaStatus = useMediaStatus(); const { t } = useTranslation(); - const [colorAtom] = useAtom(itemThemeColorAtom); + const [globalColorAtom] = useAtom(itemThemeColorAtom); const api = useAtomValue(apiAtom); const user = useAtomValue(userAtom); + // Use colors prop if provided, otherwise fallback to global atom + const effectiveColors = colors || globalColorAtom; + const router = useRouter(); const startWidth = useSharedValue(0); const targetWidth = useSharedValue(0); - const endColor = useSharedValue(colorAtom); - const startColor = useSharedValue(colorAtom); + const endColor = useSharedValue(effectiveColors); + const startColor = useSharedValue(effectiveColors); const widthProgress = useSharedValue(0); const colorChangeProgress = useSharedValue(0); const { settings, updateSettings } = useSettings(); @@ -297,7 +303,7 @@ export const PlayButton: React.FC = ({ ); useAnimatedReaction( - () => colorAtom, + () => effectiveColors, (newColor) => { endColor.value = newColor; colorChangeProgress.value = 0; @@ -306,19 +312,19 @@ export const PlayButton: React.FC = ({ easing: Easing.bezier(0.9, 0, 0.31, 0.99), }); }, - [colorAtom], + [effectiveColors], ); useEffect(() => { const timeout_2 = setTimeout(() => { - startColor.value = colorAtom; + startColor.value = effectiveColors; startWidth.value = targetWidth.value; }, ANIMATION_DURATION); return () => { clearTimeout(timeout_2); }; - }, [colorAtom, item]); + }, [effectiveColors, item]); /** * ANIMATED STYLES @@ -367,7 +373,7 @@ export const PlayButton: React.FC = ({ className={"relative"} {...props} > - + = ({ diff --git a/components/PlayButton.tv.tsx b/components/PlayButton.tv.tsx index b4fa45a9..8e3b9811 100644 --- a/components/PlayButton.tv.tsx +++ b/components/PlayButton.tv.tsx @@ -15,6 +15,7 @@ import Animated, { withTiming, } from "react-native-reanimated"; import { useHaptic } from "@/hooks/useHaptic"; +import type { ThemeColors } from "@/hooks/useImageColorsReturn"; import { itemThemeColorAtom } from "@/utils/atoms/primaryColor"; import { useSettings } from "@/utils/atoms/settings"; import { runtimeTicksToMinutes } from "@/utils/time"; @@ -24,6 +25,7 @@ import type { SelectedOptions } from "./ItemContent"; interface Props extends React.ComponentProps { item: BaseItemDto; selectedOptions: SelectedOptions; + colors?: ThemeColors; } const ANIMATION_DURATION = 500; @@ -32,16 +34,20 @@ const MIN_PLAYBACK_WIDTH = 15; export const PlayButton: React.FC = ({ item, selectedOptions, + colors, ...props }: Props) => { - const [colorAtom] = useAtom(itemThemeColorAtom); + const [globalColorAtom] = useAtom(itemThemeColorAtom); + + // Use colors prop if provided, otherwise fallback to global atom + const effectiveColors = colors || globalColorAtom; const router = useRouter(); const startWidth = useSharedValue(0); const targetWidth = useSharedValue(0); - const endColor = useSharedValue(colorAtom); - const startColor = useSharedValue(colorAtom); + const endColor = useSharedValue(effectiveColors); + const startColor = useSharedValue(effectiveColors); const widthProgress = useSharedValue(0); const colorChangeProgress = useSharedValue(0); const { settings } = useSettings(); @@ -101,7 +107,7 @@ export const PlayButton: React.FC = ({ ); useAnimatedReaction( - () => colorAtom, + () => effectiveColors, (newColor) => { endColor.value = newColor; colorChangeProgress.value = 0; @@ -110,19 +116,19 @@ export const PlayButton: React.FC = ({ easing: Easing.bezier(0.9, 0, 0.31, 0.99), }); }, - [colorAtom], + [effectiveColors], ); useEffect(() => { const timeout_2 = setTimeout(() => { - startColor.value = colorAtom; + startColor.value = effectiveColors; startWidth.value = targetWidth.value; }, ANIMATION_DURATION); return () => { clearTimeout(timeout_2); }; - }, [colorAtom, item]); + }, [effectiveColors, item]); /** * ANIMATED STYLES @@ -189,7 +195,7 @@ export const PlayButton: React.FC = ({ = ({ ...props }) => { if (!popularItems) return null; return ( - + } + scrollAnimationDuration={1000} /> = ({ item }) => { const tap = Gesture.Tap() .maxDuration(2000) + .shouldCancelWhenOutside(true) .onBegin(() => { opacity.value = withTiming(0.8, { duration: 100 }); }) @@ -173,25 +176,19 @@ const RenderItem: React.FC<{ item: BaseItemDto }> = ({ item }) => { return ( - - + + - + { useEffect(() => { const unsubscribe = eventBus.on("scrollToTop", () => { if ((segments as string[])[2] === "(home)") - scrollViewRef.current?.scrollTo({ y: -152, animated: true }); + scrollViewRef.current?.scrollTo({ + y: Platform.isTV ? -152 : -100, + animated: true, + }); }); return () => { @@ -192,9 +195,9 @@ export const HomeIndex = () => { await getUserLibraryApi(api).getLatestMedia({ userId: user?.Id, limit: 20, - fields: ["PrimaryImageAspectRatio", "Path"], + fields: ["PrimaryImageAspectRatio", "Path", "Genres"], imageTypeLimit: 1, - enableImageTypes: ["Primary", "Backdrop", "Thumb"], + enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"], includeItemTypes, parentId, }) @@ -236,8 +239,9 @@ export const HomeIndex = () => { ( await getItemsApi(api).getResumeItems({ userId: user.Id, - enableImageTypes: ["Primary", "Backdrop", "Thumb"], + enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"], includeItemTypes: ["Movie", "Series", "Episode"], + fields: ["Genres"], }) ).data.Items || [], type: "ScrollingCollectionList", @@ -250,9 +254,9 @@ export const HomeIndex = () => { ( await getTvShowsApi(api).getNextUp({ userId: user?.Id, - fields: ["MediaSourceCount"], + fields: ["MediaSourceCount", "Genres"], limit: 20, - enableImageTypes: ["Primary", "Backdrop", "Thumb"], + enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"], enableResumable: false, }) ).data.Items || [], @@ -334,9 +338,9 @@ export const HomeIndex = () => { if (section.nextUp) { const response = await getTvShowsApi(api).getNextUp({ userId: user?.Id, - fields: ["MediaSourceCount"], + fields: ["MediaSourceCount", "Genres"], limit: section.nextUp?.limit || 25, - enableImageTypes: ["Primary", "Backdrop", "Thumb"], + enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"], enableResumable: section.nextUp?.enableResumable, enableRewatching: section.nextUp?.enableRewatching, }); @@ -443,44 +447,60 @@ export const HomeIndex = () => { scrollToOverflowEnabled={true} ref={scrollViewRef} nestedScrollEnabled - contentInsetAdjustmentBehavior='automatic' + contentInsetAdjustmentBehavior='never' refreshControl={ - + } - contentContainerStyle={{ - paddingLeft: insets.left, - paddingRight: insets.right, - paddingBottom: 16, - }} + style={{ marginTop: Platform.isTV ? 0 : -100 }} + contentContainerStyle={{ paddingTop: Platform.isTV ? 0 : 100 }} > - - - - {sections.map((section, index) => { - if (section.type === "ScrollingCollectionList") { - return ( - - ); - } - if (section.type === "MediaListSection") { - return ( - - ); - } - return null; - })} + { + console.log(`Now viewing carousel item ${index}`); + }} + /> + + + {sections.map((section, index) => { + if (section.type === "ScrollingCollectionList") { + return ( + + ); + } + if (section.type === "MediaListSection") { + return ( + + ); + } + return null; + })} + + ); }; diff --git a/hooks/useImageColorsReturn.ts b/hooks/useImageColorsReturn.ts new file mode 100644 index 00000000..b9c53e61 --- /dev/null +++ b/hooks/useImageColorsReturn.ts @@ -0,0 +1,131 @@ +import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; +import { useAtomValue } from "jotai"; +import { useEffect, useMemo, useState } from "react"; +import { Platform } from "react-native"; +import { getColors, ImageColorsResult } from "react-native-image-colors"; +import { apiAtom } from "@/providers/JellyfinProvider"; +import { + adjustToNearBlack, + calculateTextColor, + isCloseToBlack, +} from "@/utils/atoms/primaryColor"; +import { getItemImage } from "@/utils/getItemImage"; +import { storage } from "@/utils/mmkv"; + +export interface ThemeColors { + primary: string; + text: string; +} + +const DEFAULT_COLORS: ThemeColors = { + primary: "#FFFFFF", + text: "#000000", +}; + +/** + * Custom hook to extract and return image colors for a given item. + * Returns colors as state instead of updating global atom. + * + * @param item - The BaseItemDto object representing the item. + * @param disabled - A boolean flag to disable color extraction. + * @returns ThemeColors object with primary and text colors + */ +export const useImageColorsReturn = ({ + item, + url, + disabled, +}: { + item?: BaseItemDto | null; + url?: string | null; + disabled?: boolean; +}): ThemeColors => { + const api = useAtomValue(apiAtom); + const [colors, setColors] = useState(DEFAULT_COLORS); + + const isTv = Platform.isTV; + + const source = useMemo(() => { + if (!api) return; + if (url) return { uri: url }; + if (item) + return getItemImage({ + item, + api, + variant: "Primary", + quality: 80, + width: 300, + }); + return null; + }, [api, item, url]); + + useEffect(() => { + // Reset to default colors when item changes + if (!item && !url) { + setColors(DEFAULT_COLORS); + return; + } + + if (isTv) return; + if (disabled) return; + if (source?.uri) { + const _primary = storage.getString(`${source.uri}-primary`); + const _text = storage.getString(`${source.uri}-text`); + + if (_primary && _text) { + setColors({ + primary: _primary, + text: _text, + }); + return; + } + + // Extract colors from the image + getColors(source.uri, { + fallback: "#fff", + cache: false, + }) + .then((colors: ImageColorsResult) => { + let primary = "#fff"; + let text = "#000"; + let backup = "#fff"; + + // Select the appropriate color based on the platform + if (colors.platform === "android") { + primary = colors.dominant; + backup = colors.vibrant; + } else if (colors.platform === "ios") { + primary = colors.detail; + backup = colors.primary; + } + + // Adjust the primary color if it's too close to black + if (primary && isCloseToBlack(primary)) { + if (backup && !isCloseToBlack(backup)) primary = backup; + primary = adjustToNearBlack(primary); + } + + // Calculate the text color based on the primary color + if (primary) text = calculateTextColor(primary); + + const newColors = { + primary, + text, + }; + + setColors(newColors); + + // Cache the colors in storage + if (source.uri && primary) { + storage.set(`${source.uri}-primary`, primary); + storage.set(`${source.uri}-text`, text); + } + }) + .catch((error: any) => { + console.error("Error getting colors", error); + setColors(DEFAULT_COLORS); + }); + } + }, [isTv, source?.uri, disabled, item, url]); + + return colors; +}; From 1733db6c28da96b1b27bdd39d98c6818bca41cee Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Mon, 29 Sep 2025 11:36:14 +0200 Subject: [PATCH 06/25] chore: version bump --- app.json | 4 ++-- eas.json | 6 +++--- providers/JellyfinProvider.tsx | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/app.json b/app.json index 3119300d..f77e82cb 100644 --- a/app.json +++ b/app.json @@ -2,7 +2,7 @@ "expo": { "name": "Streamyfin", "slug": "streamyfin", - "version": "0.36.0", + "version": "0.38.0", "orientation": "default", "icon": "./assets/images/icon.png", "scheme": "streamyfin", @@ -37,7 +37,7 @@ }, "android": { "jsEngine": "hermes", - "versionCode": 69, + "versionCode": 70, "adaptiveIcon": { "foregroundImage": "./assets/images/icon-android-plain.png", "monochromeImage": "./assets/images/icon-android-themed.png", diff --git a/eas.json b/eas.json index 9173f4ea..f7918986 100644 --- a/eas.json +++ b/eas.json @@ -45,14 +45,14 @@ }, "production": { "environment": "production", - "channel": "0.36.0", + "channel": "0.38.0", "android": { "image": "latest" } }, "production-apk": { "environment": "production", - "channel": "0.36.0", + "channel": "0.38.0", "android": { "buildType": "apk", "image": "latest" @@ -60,7 +60,7 @@ }, "production-apk-tv": { "environment": "production", - "channel": "0.36.0", + "channel": "0.38.0", "android": { "buildType": "apk", "image": "latest" diff --git a/providers/JellyfinProvider.tsx b/providers/JellyfinProvider.tsx index 6340bcce..3c07dd48 100644 --- a/providers/JellyfinProvider.tsx +++ b/providers/JellyfinProvider.tsx @@ -64,7 +64,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({ setJellyfin( () => new Jellyfin({ - clientInfo: { name: "Streamyfin", version: "0.36.0" }, + clientInfo: { name: "Streamyfin", version: "0.38.0" }, deviceInfo: { name: deviceName, id, @@ -87,7 +87,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({ return { authorization: `MediaBrowser Client="Streamyfin", Device=${ Platform.OS === "android" ? "Android" : "iOS" - }, DeviceId="${deviceId}", Version="0.36.0"`, + }, DeviceId="${deviceId}", Version="0.38.0"`, }; }, [deviceId]); From 2273b7be0a20fd14428a50751fd3af2891330e81 Mon Sep 17 00:00:00 2001 From: Gauvain <68083474+Gauvino@users.noreply.github.com> Date: Mon, 29 Sep 2025 14:17:07 +0200 Subject: [PATCH 07/25] feat: enhance download pause/resume with progress persistence (#1034) --- app/(auth)/(tabs)/(home)/sessions/index.tsx | 7 +- .../jellyseerr/page.tsx | 2 +- app/_layout.tsx | 11 +- components/DownloadItem.tsx | 2 +- components/downloads/DownloadCard.tsx | 9 +- components/series/SeriesActions.tsx | 6 +- components/settings/OtherSettings.tsx | 4 +- plugins/withRNBackgroundDownloader.js | 4 +- providers/DownloadProvider.tsx | 536 +++++++++++++++--- providers/Downloads/types.ts | 10 + translations/en.json | 16 +- utils/log.tsx | 11 + 12 files changed, 515 insertions(+), 103 deletions(-) diff --git a/app/(auth)/(tabs)/(home)/sessions/index.tsx b/app/(auth)/(tabs)/(home)/sessions/index.tsx index 9038e2fd..cd5e32a0 100644 --- a/app/(auth)/(tabs)/(home)/sessions/index.tsx +++ b/app/(auth)/(tabs)/(home)/sessions/index.tsx @@ -468,6 +468,7 @@ const TranscodingStreamView = ({ }; const TranscodingView = ({ session }: SessionCardProps) => { + const { t } = useTranslation(); const videoStream = useMemo(() => { return session.NowPlayingItem?.MediaStreams?.filter( (s) => s.Type === "Video", @@ -501,7 +502,7 @@ const TranscodingView = ({ session }: SessionCardProps) => { return ( { /> { {subtitleStream && ( { borderStyle: "solid", }} > - Play + {t("common.play")} ) diff --git a/app/_layout.tsx b/app/_layout.tsx index c94f540e..677fc6ce 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -395,12 +395,17 @@ function Layout() { appState.current.match(/inactive|background/) && nextAppState === "active" ) { - BackGroundDownloader.checkForExistingDownloads(); + BackGroundDownloader.checkForExistingDownloads().catch( + (error: unknown) => { + writeErrorLog("Failed to resume background downloads", error); + }, + ); } }); - BackGroundDownloader.checkForExistingDownloads(); - + BackGroundDownloader.checkForExistingDownloads().catch((error: unknown) => { + writeErrorLog("Failed to resume background downloads", error); + }); return () => { subscription.remove(); }; diff --git a/components/DownloadItem.tsx b/components/DownloadItem.tsx index 60a248c7..80ead77a 100644 --- a/components/DownloadItem.tsx +++ b/components/DownloadItem.tsx @@ -225,7 +225,7 @@ export const DownloadItems: React.FC = ({ if (!mediaSource) { console.error(`Could not get download URL for ${item.Name}`); toast.error( - t("Could not get download URL for {{itemName}}", { + t("home.downloads.toasts.could_not_get_download_url_for_item", { itemName: item.Name, }), ); diff --git a/components/downloads/DownloadCard.tsx b/components/downloads/DownloadCard.tsx index 5ffce752..b90c4836 100644 --- a/components/downloads/DownloadCard.tsx +++ b/components/downloads/DownloadCard.tsx @@ -6,6 +6,7 @@ import { t } from "i18next"; import { useMemo } from "react"; import { ActivityIndicator, + Platform, TouchableOpacity, type TouchableOpacityProps, View, @@ -109,9 +110,9 @@ export const DownloadCard = ({ process, ...props }: DownloadCardProps) => { /> )} - {/* Action buttons in top right corner */} - - {process.status === "downloading" && ( + {/* Action buttons in bottom right corner */} + + {process.status === "downloading" && Platform.OS !== "ios" && ( handlePause(process.id)} className='p-1' @@ -119,7 +120,7 @@ export const DownloadCard = ({ process, ...props }: DownloadCardProps) => { )} - {process.status === "paused" && ( + {process.status === "paused" && Platform.OS !== "ios" && ( handleResume(process.id)} className='p-1' diff --git a/components/series/SeriesActions.tsx b/components/series/SeriesActions.tsx index 64a9dbfa..a2cd32ba 100644 --- a/components/series/SeriesActions.tsx +++ b/components/series/SeriesActions.tsx @@ -1,6 +1,7 @@ import { Ionicons } from "@expo/vector-icons"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; import { useCallback, useMemo } from "react"; +import { useTranslation } from "react-i18next"; import { Alert, Linking, @@ -16,6 +17,7 @@ interface Props extends ViewProps { } export const ItemActions = ({ item, ...props }: Props) => { + const { t } = useTranslation(); const trailerLink = useMemo(() => { if ("RemoteTrailers" in item && item.RemoteTrailers?.[0]?.Url) { return item.RemoteTrailers[0].Url; @@ -30,7 +32,7 @@ export const ItemActions = ({ item, ...props }: Props) => { const openTrailer = useCallback(async () => { if (!trailerLink) { - Alert.alert("No trailer available"); + Alert.alert(t("common.no_trailer_available")); return; } @@ -39,7 +41,7 @@ export const ItemActions = ({ item, ...props }: Props) => { } catch (err) { console.error("Failed to open trailer link:", err); } - }, [trailerLink]); + }, [trailerLink, t]); return ( diff --git a/components/settings/OtherSettings.tsx b/components/settings/OtherSettings.tsx index e0ca1147..d4d7e598 100644 --- a/components/settings/OtherSettings.tsx +++ b/components/settings/OtherSettings.tsx @@ -41,10 +41,10 @@ export const OtherSettings: React.FC = () => { if (settings?.autoDownload === true && !registered) { registerBackgroundFetchAsync(); - toast.success("Background downloads enabled"); + toast.success(t("home.settings.toasts.background_downloads_enabled")); } else if (settings?.autoDownload === false && registered) { unregisterBackgroundFetchAsync(); - toast.info("Background downloads disabled"); + toast.info(t("home.settings.toasts.background_downloads_disabled")); } else if (settings?.autoDownload === true && registered) { // Don't to anything } else if (settings?.autoDownload === false && !registered) { diff --git a/plugins/withRNBackgroundDownloader.js b/plugins/withRNBackgroundDownloader.js index 83e1fb69..af24d642 100644 --- a/plugins/withRNBackgroundDownloader.js +++ b/plugins/withRNBackgroundDownloader.js @@ -50,9 +50,11 @@ function withRNBackgroundDownloader(config) { // Expo 53's xcodeโ€‘js doesn't expose pbxTargets(). // Setting the property once at the project level is sufficient. ["Debug", "Release"].forEach((cfg) => { + // Use the detected projectName to set the bridging header path instead of a hardcoded value + const bridgingHeaderPath = `${projectName}/${projectName}-Bridging-Header.h`; project.updateBuildProperty( "SWIFT_OBJC_BRIDGING_HEADER", - "Streamyfin/Streamyfin-Bridging-Header.h", + bridgingHeaderPath, cfg, ); }); diff --git a/providers/DownloadProvider.tsx b/providers/DownloadProvider.tsx index c3180c0a..4306b8d6 100644 --- a/providers/DownloadProvider.tsx +++ b/providers/DownloadProvider.tsx @@ -25,7 +25,7 @@ import { useSettings } from "@/utils/atoms/settings"; import { getOrSetDeviceId } from "@/utils/device"; import useDownloadHelper from "@/utils/download"; import { getItemImage } from "@/utils/getItemImage"; -import { writeToLog } from "@/utils/log"; +import { dumpDownloadDiagnostics, writeToLog } from "@/utils/log"; import { storage } from "@/utils/mmkv"; import { fetchAndParseSegments } from "@/utils/segments"; import { generateTrickplayUrl, getTrickplayInfo } from "@/utils/trickplay"; @@ -42,37 +42,60 @@ const BackGroundDownloader = !Platform.isTV ? require("@kesha-antonov/react-native-background-downloader") : null; +// Cap progress at 99% to avoid showing 100% before the download is actually complete +const MAX_PROGRESS_BEFORE_COMPLETION = 99; + +// Estimate the total download size in bytes for a job. If the media source +// provides a Size, use that. Otherwise, if we have a bitrate and run time +// (RunTimeTicks), approximate size = (bitrate bits/sec * seconds) / 8. const calculateEstimatedSize = (p: JobStatus): number => { - let size = p.mediaSource.Size; - const maxBitrate = p.maxBitrate.value; - if ( - maxBitrate && - size && - p.mediaSource.Bitrate && - maxBitrate < p.mediaSource.Bitrate - ) { - size = (size / p.mediaSource.Bitrate) * maxBitrate; - } - // This function is for estimated size, so just return the adjusted size - return size ?? 0; -}; + const size = p.mediaSource?.Size || 0; + const maxBitrate = p.maxBitrate?.value; + const runTimeTicks = (p.item?.RunTimeTicks || 0) as number; -// Helper to calculate download speed -const calculateSpeed = ( - process: JobStatus, - newBytesDownloaded: number, -): number | undefined => { - const { bytesDownloaded: oldBytes = 0, lastProgressUpdateTime } = process; - const deltaBytes = newBytesDownloaded - oldBytes; - - if (lastProgressUpdateTime && deltaBytes > 0) { - const deltaTimeInSeconds = - (Date.now() - new Date(lastProgressUpdateTime).getTime()) / 1000; - if (deltaTimeInSeconds > 0) { - return deltaBytes / deltaTimeInSeconds; + if (!size && maxBitrate && runTimeTicks > 0) { + // Jellyfin RunTimeTicks are in 10,000,000 ticks per second + const seconds = runTimeTicks / 10000000; + if (seconds > 0) { + // maxBitrate is in bits per second; convert to bytes + return Math.round((maxBitrate / 8) * seconds); } } - return undefined; + + return size || 0; +}; + +// Calculate download speed in bytes/sec based on a job's last update time +// and previously recorded bytesDownloaded. +const calculateSpeed = ( + p: JobStatus, + currentBytesDownloaded?: number, +): number | undefined => { + // Prefer session-only deltas when available: lastSessionBytes + lastSessionUpdateTime + const now = Date.now(); + + if (p.lastSessionUpdateTime && p.lastSessionBytes !== undefined) { + const last = new Date(p.lastSessionUpdateTime).getTime(); + const deltaTime = (now - last) / 1000; + if (deltaTime > 0) { + const current = + currentBytesDownloaded ?? p.bytesDownloaded ?? p.lastSessionBytes; + const deltaBytes = current - p.lastSessionBytes; + if (deltaBytes > 0) return deltaBytes / deltaTime; + } + } + + // Fallback to total-based deltas for compatibility + if (!p.lastProgressUpdateTime || p.bytesDownloaded === undefined) + return undefined; + const last = new Date(p.lastProgressUpdateTime).getTime(); + const deltaTime = (now - last) / 1000; + if (deltaTime <= 0) return undefined; + const prev = p.bytesDownloaded || 0; + const current = currentBytesDownloaded ?? prev; + const deltaBytes = current - prev; + if (deltaBytes <= 0) return undefined; + return deltaBytes / deltaTime; }; export const processesAtom = atom([]); @@ -170,27 +193,96 @@ function useDownloadProvider() { const currentProcesses = [...processes, ...missingProcesses]; const updatedProcesses = currentProcesses.map((p) => { - // fallback. Doesn't really work for transcodes as they may be a lot smaller. - // We make an wild guess by comparing bitrates + // Enhanced filtering to prevent iOS zombie task interference + // Only update progress for downloads that are actively downloading + if (p.status !== "downloading") { + return p; + } + + // Find task for this process const task = tasks.find((s: any) => s.id === p.id); + if (!task) { + return p; // No task found, keep current state + } + + /* + // TODO: Uncomment this block to re-enable iOS zombie task detection + // iOS: Extra validation to prevent zombie task interference + if (Platform.OS === "ios") { + // Check if we have multiple tasks for same ID (zombie detection) + const tasksForId = tasks.filter((t: any) => t.id === p.id); + if (tasksForId.length > 1) { + console.warn( + `[UPDATE] Detected ${tasksForId.length} zombie tasks for ${p.id}, ignoring progress update`, + ); + return p; // Don't update progress from potentially conflicting tasks + } + + // If task state looks suspicious (e.g., iOS task stuck in background), be conservative + if ( + task.state && + ["SUSPENDED", "PAUSED"].includes(task.state) && + p.status === "downloading" + ) { + console.warn( + `[UPDATE] Task ${p.id} has suspicious state ${task.state}, ignoring progress update`, + ); + return p; + } + } + */ + if (task && p.status === "downloading") { const estimatedSize = calculateEstimatedSize(p); let progress = p.progress; - if (estimatedSize > 0) { - progress = (100 / estimatedSize) * task.bytesDownloaded; + + // If we have a pausedProgress snapshot then merge current session + // progress into it. We accept pausedProgress === 0 as valid because + // users can pause immediately after starting. + if (p.pausedProgress !== undefined) { + const totalBytesDownloaded = + (p.pausedBytes ?? 0) + task.bytesDownloaded; + + // Calculate progress based on total bytes downloaded vs estimated size + progress = + estimatedSize > 0 + ? (totalBytesDownloaded / estimatedSize) * 100 + : 0; + + // Use the total accounted bytes when computing speed so the + // displayed speed and progress remain consistent after resume. + const speed = calculateSpeed(p, totalBytesDownloaded); + + return { + ...p, + progress: Math.min(progress, MAX_PROGRESS_BEFORE_COMPLETION), + speed, + bytesDownloaded: totalBytesDownloaded, + lastProgressUpdateTime: new Date(), + estimatedTotalSizeBytes: estimatedSize, + // Set session bytes to total bytes downloaded + lastSessionBytes: totalBytesDownloaded, + lastSessionUpdateTime: new Date(), + }; + } else { + if (estimatedSize > 0) { + progress = (100 / estimatedSize) * task.bytesDownloaded; + } + if (progress >= 100) { + progress = MAX_PROGRESS_BEFORE_COMPLETION; + } + const speed = calculateSpeed(p, task.bytesDownloaded); + return { + ...p, + progress, + speed, + bytesDownloaded: task.bytesDownloaded, + lastProgressUpdateTime: new Date(), + estimatedTotalSizeBytes: estimatedSize, + lastSessionBytes: task.bytesDownloaded, + lastSessionUpdateTime: new Date(), + }; } - if (progress >= 100) { - progress = 99; - } - const speed = calculateSpeed(p, task.bytesDownloaded); - return { - ...p, - progress, - speed, - bytesDownloaded: task.bytesDownloaded, - lastProgressUpdateTime: new Date(), - estimatedTotalSizeBytes: estimatedSize, - }; } return p; }); @@ -372,10 +464,76 @@ function useDownloadProvider() { async (process: JobStatus) => { if (!process?.item.Id || !authHeader) throw new Error("No item id"); + // Enhanced cleanup for existing tasks to prevent duplicates + try { + const allTasks = await BackGroundDownloader.checkForExistingDownloads(); + const existingTasks = allTasks?.filter((t: any) => t.id === process.id); + + if (existingTasks && existingTasks.length > 0) { + console.log( + `[START] Found ${existingTasks.length} existing task(s) for ${process.id}, cleaning up...`, + ); + + for (let i = 0; i < existingTasks.length; i++) { + const existingTask = existingTasks[i]; + console.log( + `[START] Cleaning up task ${i + 1}/${existingTasks.length} for ${process.id}`, + ); + + try { + /* + // TODO: Uncomment this block to re-enable iOS-specific cleanup + // iOS: More aggressive cleanup sequence + if (Platform.OS === "ios") { + try { + await existingTask.pause(); + await new Promise((resolve) => setTimeout(resolve, 50)); + } catch (_pauseErr) { + // Ignore pause errors + } + + await existingTask.stop(); + await new Promise((resolve) => setTimeout(resolve, 50)); + + // Multiple complete handler calls to ensure cleanup + BackGroundDownloader.completeHandler(process.id); + await new Promise((resolve) => setTimeout(resolve, 25)); + } else { + */ + + // Simple cleanup for all platforms (currently Android only) + await existingTask.stop(); + BackGroundDownloader.completeHandler(process.id); + + /* } // End of iOS block - uncomment when re-enabling iOS functionality */ + + console.log( + `[START] Successfully cleaned up task ${i + 1} for ${process.id}`, + ); + } catch (taskError) { + console.warn( + `[START] Failed to cleanup task ${i + 1} for ${process.id}:`, + taskError, + ); + } + } + + // Cleanup delay (simplified for Android) + const cleanupDelay = 200; // Platform.OS === "ios" ? 500 : 200; + await new Promise((resolve) => setTimeout(resolve, cleanupDelay)); + console.log(`[START] Cleanup completed for ${process.id}`); + } + } catch (error) { + console.warn( + `[START] Failed to check/cleanup existing tasks for ${process.id}:`, + error, + ); + } + updateProcess(process.id, { speed: undefined, status: "downloading", - progress: 0, + progress: process.progress || 0, // Preserve existing progress for resume }); BackGroundDownloader?.setConfig({ @@ -396,21 +554,42 @@ function useDownloadProvider() { .begin(() => { updateProcess(process.id, { status: "downloading", - progress: 0, - bytesDownloaded: 0, + progress: process.progress || 0, + bytesDownloaded: process.bytesDownloaded || 0, lastProgressUpdateTime: new Date(), + lastSessionBytes: process.lastSessionBytes || 0, + lastSessionUpdateTime: new Date(), }); }) .progress( throttle((data) => { updateProcess(process.id, (currentProcess) => { - const percent = (data.bytesDownloaded / data.bytesTotal) * 100; + // If this is a resumed download, add the paused bytes to current session bytes + const resumedBytes = currentProcess.pausedBytes || 0; + const totalBytes = data.bytesDownloaded + resumedBytes; + + // Calculate progress based on total bytes if we have resumed bytes + let percent: number; + if (resumedBytes > 0 && data.bytesTotal > 0) { + // For resumed downloads, calculate based on estimated total size + const estimatedTotal = + currentProcess.estimatedTotalSizeBytes || + data.bytesTotal + resumedBytes; + percent = (totalBytes / estimatedTotal) * 100; + } else { + // For fresh downloads, use normal calculation + percent = (data.bytesDownloaded / data.bytesTotal) * 100; + } + return { - speed: calculateSpeed(currentProcess, data.bytesDownloaded), + speed: calculateSpeed(currentProcess, totalBytes), status: "downloading", - progress: percent, - bytesDownloaded: data.bytesDownloaded, + progress: Math.min(percent, MAX_PROGRESS_BEFORE_COMPLETION), + bytesDownloaded: totalBytes, lastProgressUpdateTime: new Date(), + // update session-only counters - use current session bytes only for speed calc + lastSessionBytes: data.bytesDownloaded, + lastSessionUpdateTime: new Date(), }; }); }, 500), @@ -542,7 +721,17 @@ function useDownloadProvider() { if (activeDownloads < concurrentLimit) { const queuedDownload = processes.find((p) => p.status === "queued"); if (queuedDownload) { - startDownload(queuedDownload); + // Reserve the slot immediately to avoid race where startDownload's + // asynchronous begin callback hasn't executed yet and multiple + // downloads are started, bypassing the concurrent limit. + updateProcess(queuedDownload.id, { status: "downloading" }); + startDownload(queuedDownload).catch((error) => { + console.error("Failed to start download:", error); + updateProcess(queuedDownload.id, { status: "error" }); + toast.error(t("home.downloads.toasts.failed_to_start_download"), { + description: error.message || "Unknown error", + }); + }); } } }, [processes, settings?.remuxConcurrentLimit, startDownload]); @@ -551,8 +740,38 @@ function useDownloadProvider() { async (id: string) => { const tasks = await BackGroundDownloader.checkForExistingDownloads(); const task = tasks?.find((t: any) => t.id === id); - task?.stop(); - BackGroundDownloader.completeHandler(id); + if (task) { + // On iOS, suspended tasks need to be cancelled properly + if (Platform.OS === "ios") { + const state = task.state || task.state?.(); + if ( + state === "PAUSED" || + state === "paused" || + state === "SUSPENDED" || + state === "suspended" + ) { + // For suspended tasks, we need to resume first, then stop + try { + await task.resume(); + // Small delay to allow resume to take effect + await new Promise((resolve) => setTimeout(resolve, 100)); + } catch (_resumeError) { + // Resume might fail, continue with stop + } + } + } + + try { + task.stop(); + } catch (_err) { + // ignore stop errors + } + try { + BackGroundDownloader.completeHandler(id); + } catch (_err) { + // ignore + } + } setProcesses((prev) => prev.filter((process) => process.id !== id)); manageDownloadQueue(); }, @@ -575,7 +794,7 @@ function useDownloadProvider() { intermediates: true, }); } catch (_error) { - toast.error(t("Failed to clean cache directory.")); + toast.error(t("home.downloads.toasts.failed_to_clean_cache_directory")); } }; @@ -611,9 +830,13 @@ function useDownloadProvider() { status: "queued", timestamp: new Date(), }; - setProcesses((prev) => [...prev, job]); + setProcesses((prev) => { + // Remove any existing processes for this item to prevent duplicates + const filtered = prev.filter((p) => p.id !== item.Id); + return [...filtered, job]; + }); toast.success( - t("home.downloads.toasts.download_stated_for_item", { + t("home.downloads.toasts.download_started_for_item", { item: item.Name, }), { @@ -791,12 +1014,99 @@ function useDownloadProvider() { const process = processes.find((p) => p.id === id); if (!process) throw new Error("No active download"); + // TODO: iOS pause functionality temporarily disabled due to background task issues + // Remove this check to re-enable iOS pause functionality in the future + if (Platform.OS === "ios") { + console.warn( + `[PAUSE] Pause functionality temporarily disabled on iOS for ${id}`, + ); + throw new Error("Pause functionality is currently disabled on iOS"); + } + const tasks = await BackGroundDownloader.checkForExistingDownloads(); const task = tasks?.find((t: any) => t.id === id); if (!task) throw new Error("No task found"); - task.pause(); - updateProcess(id, { status: "paused" }); + // Get current progress before stopping + const currentProgress = process.progress; + const currentBytes = process.bytesDownloaded || task.bytesDownloaded || 0; + + console.log( + `[PAUSE] Starting pause for ${id}. Current bytes: ${currentBytes}, Progress: ${currentProgress}%`, + ); + + try { + /* + // TODO: Uncomment this block to re-enable iOS pause functionality + // iOS-specific aggressive cleanup approach based on GitHub issue #26 + if (Platform.OS === "ios") { + // Get ALL tasks for this ID - there might be multiple zombie tasks + const allTasks = + await BackGroundDownloader.checkForExistingDownloads(); + const tasksForId = allTasks?.filter((t: any) => t.id === id) || []; + + console.log(`[PAUSE] Found ${tasksForId.length} task(s) for ${id}`); + + // Stop ALL tasks for this ID to prevent zombie processes + for (let i = 0; i < tasksForId.length; i++) { + const taskToStop = tasksForId[i]; + console.log( + `[PAUSE] Stopping task ${i + 1}/${tasksForId.length} for ${id}`, + ); + + try { + // iOS: pause โ†’ stop sequence with delays (based on issue research) + await taskToStop.pause(); + await new Promise((resolve) => setTimeout(resolve, 100)); + + await taskToStop.stop(); + await new Promise((resolve) => setTimeout(resolve, 100)); + + console.log( + `[PAUSE] Successfully stopped task ${i + 1} for ${id}`, + ); + } catch (taskError) { + console.warn( + `[PAUSE] Failed to stop task ${i + 1} for ${id}:`, + taskError, + ); + } + } + + // Extra cleanup delay for iOS NSURLSession to fully stop + await new Promise((resolve) => setTimeout(resolve, 500)); + } else { + */ + + // Android: simpler approach (currently the only active platform) + await task.stop(); + + /* } // End of iOS block - uncomment when re-enabling iOS functionality */ + + // Clean up the native task handler + try { + BackGroundDownloader.completeHandler(id); + } catch (_err) { + console.warn(`[PAUSE] Handler cleanup warning for ${id}:`, _err); + } + + // Update process state to paused + updateProcess(id, { + status: "paused", + progress: currentProgress, + bytesDownloaded: currentBytes, + pausedAt: new Date(), + pausedProgress: currentProgress, + pausedBytes: currentBytes, + lastSessionBytes: process.lastSessionBytes ?? currentBytes, + lastSessionUpdateTime: process.lastSessionUpdateTime ?? new Date(), + }); + + console.log(`Download paused successfully: ${id}`); + } catch (error) { + console.error("Error pausing task:", error); + throw error; + } }, [processes, updateProcess], ); @@ -806,38 +1116,79 @@ function useDownloadProvider() { const process = processes.find((p) => p.id === id); if (!process) throw new Error("No active download"); - const tasks = await BackGroundDownloader.checkForExistingDownloads(); - const task = tasks?.find((t: any) => t.id === id); - if (!task) throw new Error("No task found"); - - // Check if task state allows resuming - if (task.state === "FAILED") { + // TODO: iOS resume functionality temporarily disabled due to background task issues + // Remove this check to re-enable iOS resume functionality in the future + if (Platform.OS === "ios") { console.warn( - "Download task failed, cannot resume. Restarting download.", + `[RESUME] Resume functionality temporarily disabled on iOS for ${id}`, ); - // For failed tasks, we need to restart rather than resume - await startDownload(process); - return; + throw new Error("Resume functionality is currently disabled on iOS"); } - try { - task.resume(); - updateProcess(id, { status: "downloading" }); - } catch (error: any) { - // Handle specific ERROR_CANNOT_RESUME error - if ( - error?.error === "ERROR_CANNOT_RESUME" || - error?.errorCode === 1008 - ) { - console.warn("Cannot resume download, attempting to restart instead"); - await startDownload(process); - return; // Return early to prevent error from bubbling up - } else { - // Only log error for non-handled cases - console.error("Error resuming download:", error); - throw error; // Re-throw other errors + console.log( + `[RESUME] Attempting to resume ${id}. Paused bytes: ${process.pausedBytes}, Progress: ${process.pausedProgress}%`, + ); + + /* + // TODO: Uncomment this block to re-enable iOS resume functionality + // Enhanced cleanup for iOS based on GitHub issue research + if (Platform.OS === "ios") { + try { + // Clean up any lingering zombie tasks first (critical for iOS) + const allTasks = + await BackGroundDownloader.checkForExistingDownloads(); + const existingTasks = allTasks?.filter((t: any) => t.id === id) || []; + + if (existingTasks.length > 0) { + console.log( + `[RESUME] Found ${existingTasks.length} lingering task(s), cleaning up...`, + ); + + for (const task of existingTasks) { + try { + await task.stop(); + BackGroundDownloader.completeHandler(id); + } catch (cleanupError) { + console.warn(`[RESUME] Cleanup error:`, cleanupError); + } + } + + // Wait for iOS cleanup to complete + await new Promise((resolve) => setTimeout(resolve, 500)); + } + } catch (error) { + console.warn(`[RESUME] Pre-resume cleanup failed:`, error); } } + */ + + // Simple approach: always restart the download from where we left off + // This works consistently across all platforms (currently Android only) + if ( + process.pausedProgress !== undefined && + process.pausedBytes !== undefined + ) { + // We have saved pause state - restore it and restart + updateProcess(id, { + progress: process.pausedProgress, + bytesDownloaded: process.pausedBytes, + status: "downloading", + // Reset session counters for proper speed calculation + lastSessionBytes: process.pausedBytes, + lastSessionUpdateTime: new Date(), + }); + + // Small delay to ensure any cleanup in startDownload completes + await new Promise((resolve) => setTimeout(resolve, 100)); + + const updatedProcess = processes.find((p) => p.id === id); + await startDownload(updatedProcess || process); + + console.log(`Download resumed successfully: ${id}`); + } else { + // No pause state - start from beginning + await startDownload(process); + } }, [processes, updateProcess, startDownload], ); @@ -861,6 +1212,21 @@ function useDownloadProvider() { cleanCacheDirectory, updateDownloadedItem, appSizeUsage, + dumpDownloadDiagnostics: async (id?: string) => { + // Collect JS-side processes and native task info (best-effort) + const tasks = BackGroundDownloader + ? await BackGroundDownloader.checkForExistingDownloads() + : []; + const extra: any = { + processes, + nativeTasks: tasks || [], + }; + if (id) { + const p = processes.find((x) => x.id === id); + extra.focusedProcess = p || null; + } + return dumpDownloadDiagnostics(extra); + }, }; } diff --git a/providers/Downloads/types.ts b/providers/Downloads/types.ts index ee74b25d..cff87ddf 100644 --- a/providers/Downloads/types.ts +++ b/providers/Downloads/types.ts @@ -129,4 +129,14 @@ export type JobStatus = { /** Estimated total size of the download in bytes (optional) this is used when we * download transcoded content because we don't know the size of the file until it's downloaded */ estimatedTotalSizeBytes?: number; + /** Timestamp when the download was paused (optional) */ + pausedAt?: Date; + /** Progress percentage when download was paused (optional) */ + pausedProgress?: number; + /** Bytes downloaded when download was paused (optional) */ + pausedBytes?: number; + /** Bytes downloaded in the current session (since last resume). Used for session-only speed calculation. */ + lastSessionBytes?: number; + /** Timestamp when the session-only bytes were last updated. */ + lastSessionUpdateTime?: Date; }; diff --git a/translations/en.json b/translations/en.json index 834fd183..8578567a 100644 --- a/translations/en.json +++ b/translations/en.json @@ -223,7 +223,9 @@ "system": "System" }, "toasts": { - "error_deleting_files": "Error Deleting Files" + "error_deleting_files": "Error Deleting Files", + "background_downloads_enabled": "Background downloads enabled", + "background_downloads_disabled": "Background downloads disabled" } }, "sessions": { @@ -266,11 +268,23 @@ "download_completed": "Download Completed", "download_failed_for_item": "Download failed for {{item}} - {{error}}", "download_completed_for_item": "Download Completed for {{item}}", + "download_started_for_item": "Download Started for {{item}}", + "failed_to_start_download": "Failed to start download", "all_files_folders_and_jobs_deleted_successfully": "All files, folders, and jobs deleted successfully", + "failed_to_clean_cache_directory": "Failed to clean cache directory", + "could_not_get_download_url_for_item": "Could not get download URL for {{itemName}}", "go_to_downloads": "Go to Downloads" } } }, + "common": { + "select": "Select", + "no_trailer_available": "No trailer available", + "video": "Video", + "audio": "Audio", + "subtitle": "Subtitle", + "play": "Play" + }, "search": { "search": "Search...", "x_items": "{{count}} Items", diff --git a/utils/log.tsx b/utils/log.tsx index 88ca475e..956f1fb0 100644 --- a/utils/log.tsx +++ b/utils/log.tsx @@ -77,6 +77,17 @@ export const clearLogs = () => { storage.delete("logs"); }; +export const dumpDownloadDiagnostics = (extra: any = {}) => { + const diagnostics = { + timestamp: new Date().toISOString(), + processes: extra?.processes || [], + nativeTasks: extra?.nativeTasks || [], + focusedProcess: extra?.focusedProcess || null, + }; + writeDebugLog("Download diagnostics", diagnostics); + return diagnostics; +}; + export function useLog() { const context = useContext(LogContext); if (context === null) { From 0ec44add7d9513191e979a6277cc0ddcfcfb6e96 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Mon, 29 Sep 2025 15:02:24 +0200 Subject: [PATCH 08/25] fix: navigatte to item --- components/AppleTVCarousel.tsx | 99 +++++++++++++++++++++------------- 1 file changed, 63 insertions(+), 36 deletions(-) diff --git a/components/AppleTVCarousel.tsx b/components/AppleTVCarousel.tsx index 2f5b2d50..c439069d 100644 --- a/components/AppleTVCarousel.tsx +++ b/components/AppleTVCarousel.tsx @@ -7,9 +7,10 @@ import { import { useQuery } from "@tanstack/react-query"; import { Image } from "expo-image"; import { LinearGradient } from "expo-linear-gradient"; +import { useRouter } from "expo-router"; import { useAtomValue } from "jotai"; import { useCallback, useEffect, useMemo, useState } from "react"; -import { Dimensions, Pressable, View } from "react-native"; +import { Dimensions, Pressable, TouchableOpacity, View } from "react-native"; import { Gesture, GestureDetector } from "react-native-gesture-handler"; import Animated, { Easing, @@ -25,6 +26,7 @@ import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { useSettings } from "@/utils/atoms/settings"; import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById"; import { ItemImage } from "./common/ItemImage"; +import { getItemNavigation } from "./common/TouchableItemRouter"; import type { SelectedOptions } from "./ItemContent"; import { PlayButton } from "./PlayButton"; import { PlayedStatus } from "./PlayedStatus"; @@ -150,6 +152,7 @@ export const AppleTVCarousel: React.FC = ({ const api = useAtomValue(apiAtom); const user = useAtomValue(userAtom); const { isConnected, serverConnected } = useNetworkStatus(); + const router = useRouter(); const [currentIndex, setCurrentIndex] = useState(initialIndex); const translateX = useSharedValue(-currentIndex * screenWidth); @@ -301,6 +304,14 @@ export const AppleTVCarousel: React.FC = ({ [hasItems, items, onItemChange, translateX], ); + const navigateToItem = useCallback( + (item: BaseItemDto) => { + const navigation = getItemNavigation(item, "(home)"); + router.push(navigation as any); + }, + [router], + ); + const panGesture = Gesture.Pan() .activeOffsetX([-PAN_ACTIVE_OFFSET, PAN_ACTIVE_OFFSET]) .onUpdate((event) => { @@ -591,7 +602,8 @@ export const AppleTVCarousel: React.FC = ({ {/* Logo Section */} {itemLogoUrl && ( - navigateToItem(item)} style={{ position: "absolute", bottom: LOGO_BOTTOM_POSITION, @@ -611,7 +623,7 @@ export const AppleTVCarousel: React.FC = ({ }} contentFit='contain' /> - + )} {/* Type and Genres Section */} @@ -625,41 +637,56 @@ export const AppleTVCarousel: React.FC = ({ alignItems: "center", }} > - - {(() => { - const typeLabel = - item.Type === "Series" - ? "TV Show" - : item.Type === "Movie" - ? "Movie" - : item.Type || ""; + navigateToItem(item)}> + + {(() => { + let typeLabel = ""; - const genres = - item.Genres && item.Genres.length > 0 - ? item.Genres.slice(0, MAX_GENRES_COUNT).join(" โ€ข ") - : ""; + if (item.Type === "Episode") { + // For episodes, show season and episode number + const season = item.ParentIndexNumber; + const episode = item.IndexNumber; + if (season && episode) { + typeLabel = `S${season} โ€ข E${episode}`; + } else { + typeLabel = "Episode"; + } + } else { + typeLabel = + item.Type === "Series" + ? "TV Show" + : item.Type === "Movie" + ? "Movie" + : item.Type || ""; + } - if (typeLabel && genres) { - return `${typeLabel} โ€ข ${genres}`; - } else if (typeLabel) { - return typeLabel; - } else if (genres) { - return genres; - } else { - return ""; - } - })()} - + const genres = + item.Genres && item.Genres.length > 0 + ? item.Genres.slice(0, MAX_GENRES_COUNT).join(" โ€ข ") + : ""; + + if (typeLabel && genres) { + return `${typeLabel} โ€ข ${genres}`; + } else if (typeLabel) { + return typeLabel; + } else if (genres) { + return genres; + } else { + return ""; + } + })()} + + {/* Controls Section */} From f104e952ab0b7ffc45483065e5ab014b53740285 Mon Sep 17 00:00:00 2001 From: Gauvain <68083474+Gauvino@users.noreply.github.com> Date: Mon, 29 Sep 2025 18:37:02 +0200 Subject: [PATCH 09/25] feat: add automated PR comments for build artifacts (#1099) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .github/workflows/artifact-comment.yml | 124 +++++++++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 .github/workflows/artifact-comment.yml diff --git a/.github/workflows/artifact-comment.yml b/.github/workflows/artifact-comment.yml new file mode 100644 index 00000000..112f7113 --- /dev/null +++ b/.github/workflows/artifact-comment.yml @@ -0,0 +1,124 @@ +name: ๐Ÿ“ Artifact Comment on PR + +concurrency: + group: artifact-comment-${{ github.event.workflow_run.head_branch }} + cancel-in-progress: true + +on: + workflow_run: + workflows: ["๐Ÿค– iOS IPA Build (Phone + TV)", "๐Ÿค– Android APK Build (Phone + TV)"] + types: + - completed + +jobs: + comment-artifacts: + if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success' + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + actions: read + + steps: + - name: ๐Ÿ” Get PR and Artifacts + uses: actions/github-script@v8 + with: + script: | + // Find PR associated with this commit + const { data: pullRequests } = await github.rest.repos.listPullRequestsAssociatedWithCommit({ + owner: context.repo.owner, + repo: context.repo.repo, + commit_sha: "${{ github.event.workflow_run.head_sha }}" + }); + + if (pullRequests.length === 0) { + core.setFailed('No pull request found for this commit'); + return; + } + + const pr = pullRequests[0]; + const runId = "${{ github.event.workflow_run.id }}"; + + // Get artifacts from the workflow run + const { data: artifacts } = await github.rest.actions.listWorkflowRunArtifacts({ + owner: context.repo.owner, + repo: context.repo.repo, + run_id: runId + }); + + if (artifacts.artifacts.length === 0) { + console.log('No artifacts found for this run'); + return; + } + + // Sort and categorize artifacts + const androidArtifacts = artifacts.artifacts + .filter(a => a.name.includes('android')) + .sort((a, b) => a.name.localeCompare(b.name)); + const iosArtifacts = artifacts.artifacts + .filter(a => a.name.includes('ios')) + .sort((a, b) => a.name.localeCompare(b.name)); + + // Build comment body with table format + let commentBody = `## ๐Ÿ“ฑ Build Artifacts Ready!\n\n`; + commentBody += `โœ… **Workflow completed successfully** for PR #${pr.number}\n`; + commentBody += `๐Ÿ“ฆ **${artifacts.artifacts.length} artifacts** generated from commit [\`${pr.head.sha.substring(0, 7)}\`](https://github.com/${context.repo.owner}/${context.repo.repo}/commit/${pr.head.sha})\n\n`; + + // Create table for better organization + commentBody += `| Platform | Device Type | Download Link |\n`; + commentBody += `|----------|-------------|---------------|\n`; + + // Add Android artifacts + androidArtifacts.forEach(artifact => { + const isTV = artifact.name.includes('tv'); + const deviceType = isTV ? '๐Ÿ“บ Android TV' : '๐Ÿ“ฑ Android Phone'; + const nightlyLink = `https://nightly.link/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}/${artifact.name}.zip`; + commentBody += `| ๐Ÿค– Android | ${deviceType} | [๐Ÿ“ฅ Download APK](${nightlyLink}) |\n`; + }); + + // Add iOS artifacts + iosArtifacts.forEach(artifact => { + const isTV = artifact.name.includes('tv'); + const deviceType = isTV ? '๐Ÿ“บ Apple TV' : '๐Ÿ“ฑ iPhone/iPad'; + const nightlyLink = `https://nightly.link/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}/${artifact.name}.zip`; + commentBody += `| ๐ŸŽ iOS | ${deviceType} | [๐Ÿ“ฅ Download IPA](${nightlyLink}) |\n`; + }); + + commentBody += `\n`; + commentBody += `### ๐Ÿ”ง Installation Instructions\n\n`; + commentBody += `- **Android APK**: Download and install directly on your device (enable "Install from unknown sources")\n`; + commentBody += `- **iOS IPA**: Install using [AltStore](https://altstore.io/), [Sideloadly](https://sideloadly.io/), or Xcode\n\n`; + commentBody += `> โš ๏ธ **Note**: Artifacts expire in 7 days from build date\n\n`; + commentBody += `*Auto-generated by [GitHub Actions](${context.payload.workflow_run.html_url})*`; + + // Find existing bot comment to update + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number + }); + + const botComment = comments.find(comment => + comment.user.type === 'Bot' && + comment.body.includes('Build Artifacts Ready!') + ); + + if (botComment) { + // Update existing comment + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: botComment.id, + body: commentBody + }); + console.log(`โœ… Updated comment ${botComment.id} on PR #${pr.number}`); + } else { + // Create new comment + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number, + body: commentBody + }); + console.log(`โœ… Created new comment on PR #${pr.number}`); + } From 7fe3ca84842c292b612e7902fe7c6ab0ea06a6d1 Mon Sep 17 00:00:00 2001 From: Gauvain <68083474+Gauvino@users.noreply.github.com> Date: Mon, 29 Sep 2025 22:31:41 +0200 Subject: [PATCH 10/25] feat: add automated PR comments for build artifacts (#1100) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .github/workflows/artifact-comment.yml | 103 +++++++++++++++---------- 1 file changed, 61 insertions(+), 42 deletions(-) diff --git a/.github/workflows/artifact-comment.yml b/.github/workflows/artifact-comment.yml index 112f7113..67e31268 100644 --- a/.github/workflows/artifact-comment.yml +++ b/.github/workflows/artifact-comment.yml @@ -1,12 +1,17 @@ name: ๐Ÿ“ Artifact Comment on PR concurrency: - group: artifact-comment-${{ github.event.workflow_run.head_branch }} - cancel-in-progress: true + group: artifact-comment-${{ github.event.workflow_run.id }} + cancel-in-progress: false on: + workflow_dispatch: workflow_run: - workflows: ["๐Ÿค– iOS IPA Build (Phone + TV)", "๐Ÿค– Android APK Build (Phone + TV)"] + workflows: + - "๐Ÿ—๏ธ Build iOS IPA (phone)" + - "๐Ÿ—๏ธ Build iOS IPA (tv)" + - "๐Ÿ—๏ธ Build Android APK (phone)" + - "๐Ÿ—๏ธ Build Android APK (tv)" types: - completed @@ -24,33 +29,39 @@ jobs: uses: actions/github-script@v8 with: script: | + console.log('Workflow run event:', JSON.stringify(github.event.workflow_run, null, 2)); + // Find PR associated with this commit const { data: pullRequests } = await github.rest.repos.listPullRequestsAssociatedWithCommit({ owner: context.repo.owner, repo: context.repo.repo, commit_sha: "${{ github.event.workflow_run.head_sha }}" }); - + if (pullRequests.length === 0) { - core.setFailed('No pull request found for this commit'); + console.log('No pull request found for commit: ${{ github.event.workflow_run.head_sha }}'); return; } - + const pr = pullRequests[0]; const runId = "${{ github.event.workflow_run.id }}"; - + + console.log(`Found PR #${pr.number} for commit ${pr.head.sha.substring(0, 7)}`); + // Get artifacts from the workflow run const { data: artifacts } = await github.rest.actions.listWorkflowRunArtifacts({ owner: context.repo.owner, repo: context.repo.repo, run_id: runId }); - - if (artifacts.artifacts.length === 0) { + + console.log(`Found ${artifacts?.artifacts?.length || 0} artifacts`); + + if (!artifacts || artifacts.artifacts.length === 0) { console.log('No artifacts found for this run'); return; } - + // Sort and categorize artifacts const androidArtifacts = artifacts.artifacts .filter(a => a.name.includes('android')) @@ -58,51 +69,59 @@ jobs: const iosArtifacts = artifacts.artifacts .filter(a => a.name.includes('ios')) .sort((a, b) => a.name.localeCompare(b.name)); - + + console.log(`Android artifacts: ${androidArtifacts.length}, iOS artifacts: ${iosArtifacts.length}`); + // Build comment body with table format let commentBody = `## ๐Ÿ“ฑ Build Artifacts Ready!\n\n`; commentBody += `โœ… **Workflow completed successfully** for PR #${pr.number}\n`; commentBody += `๐Ÿ“ฆ **${artifacts.artifacts.length} artifacts** generated from commit [\`${pr.head.sha.substring(0, 7)}\`](https://github.com/${context.repo.owner}/${context.repo.repo}/commit/${pr.head.sha})\n\n`; - - // Create table for better organization - commentBody += `| Platform | Device Type | Download Link |\n`; - commentBody += `|----------|-------------|---------------|\n`; - - // Add Android artifacts - androidArtifacts.forEach(artifact => { - const isTV = artifact.name.includes('tv'); - const deviceType = isTV ? '๐Ÿ“บ Android TV' : '๐Ÿ“ฑ Android Phone'; - const nightlyLink = `https://nightly.link/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}/${artifact.name}.zip`; - commentBody += `| ๐Ÿค– Android | ${deviceType} | [๐Ÿ“ฅ Download APK](${nightlyLink}) |\n`; - }); - - // Add iOS artifacts - iosArtifacts.forEach(artifact => { - const isTV = artifact.name.includes('tv'); - const deviceType = isTV ? '๐Ÿ“บ Apple TV' : '๐Ÿ“ฑ iPhone/iPad'; - const nightlyLink = `https://nightly.link/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}/${artifact.name}.zip`; - commentBody += `| ๐ŸŽ iOS | ${deviceType} | [๐Ÿ“ฅ Download IPA](${nightlyLink}) |\n`; - }); - - commentBody += `\n`; - commentBody += `### ๐Ÿ”ง Installation Instructions\n\n`; - commentBody += `- **Android APK**: Download and install directly on your device (enable "Install from unknown sources")\n`; - commentBody += `- **iOS IPA**: Install using [AltStore](https://altstore.io/), [Sideloadly](https://sideloadly.io/), or Xcode\n\n`; - commentBody += `> โš ๏ธ **Note**: Artifacts expire in 7 days from build date\n\n`; - commentBody += `*Auto-generated by [GitHub Actions](${context.payload.workflow_run.html_url})*`; - + + if (androidArtifacts.length === 0 && iosArtifacts.length === 0) { + commentBody += `โš ๏ธ No mobile app artifacts found in this build.\n\n`; + } else { + // Create table for better organization + commentBody += `| Platform | Device Type | Download Link |\n`; + commentBody += `|----------|-------------|---------------|\n`; + + // Add Android artifacts + androidArtifacts.forEach(artifact => { + const isTV = artifact.name.includes('tv'); + const deviceType = isTV ? '๐Ÿ“บ Android TV' : '๐Ÿ“ฑ Android Phone'; + const nightlyLink = `https://nightly.link/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}/${artifact.name}.zip`; + commentBody += `| ๐Ÿค– Android | ${deviceType} | [๐Ÿ“ฅ Download APK](${nightlyLink}) |\n`; + }); + + // Add iOS artifacts + iosArtifacts.forEach(artifact => { + const isTV = artifact.name.includes('tv'); + const deviceType = isTV ? '๐Ÿ“บ Apple TV' : '๐Ÿ“ฑ iPhone/iPad'; + const nightlyLink = `https://nightly.link/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}/${artifact.name}.zip`; + commentBody += `| ๐ŸŽ iOS | ${deviceType} | [๐Ÿ“ฅ Download IPA](${nightlyLink}) |\n`; + }); + + commentBody += `\n`; + commentBody += `### ๐Ÿ”ง Installation Instructions\n\n`; + commentBody += `- **Android APK**: Download and install directly on your device (enable "Install from unknown sources")\n`; + commentBody += `- **iOS IPA**: Install using [AltStore](https://altstore.io/), [Sideloadly](https://sideloadly.io/), or Xcode\n\n`; + commentBody += `> โš ๏ธ **Note**: Artifacts expire in 7 days from build date\n\n`; + } + + commentBody += `*Auto-generated by [GitHub Actions](https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId})*`; + commentBody += `\n`; + // Find existing bot comment to update const { data: comments } = await github.rest.issues.listComments({ owner: context.repo.owner, repo: context.repo.repo, issue_number: pr.number }); - + const botComment = comments.find(comment => comment.user.type === 'Bot' && - comment.body.includes('Build Artifacts Ready!') + comment.body.includes('') ); - + if (botComment) { // Update existing comment await github.rest.issues.updateComment({ From 92e40c7aa0d1ca4ddff4cbfce786f9c080dea2a9 Mon Sep 17 00:00:00 2001 From: Gauvain <68083474+Gauvino@users.noreply.github.com> Date: Mon, 29 Sep 2025 23:16:12 +0200 Subject: [PATCH 11/25] feat: Build in pr (#1101) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .github/workflows/artifact-comment.yml | 75 +++++++++++++++++--------- 1 file changed, 51 insertions(+), 24 deletions(-) diff --git a/.github/workflows/artifact-comment.yml b/.github/workflows/artifact-comment.yml index 67e31268..14e74800 100644 --- a/.github/workflows/artifact-comment.yml +++ b/.github/workflows/artifact-comment.yml @@ -1,23 +1,21 @@ name: ๐Ÿ“ Artifact Comment on PR concurrency: - group: artifact-comment-${{ github.event.workflow_run.id }} + group: artifact-comment-${{ github.event.workflow_run.id || github.run_id }} cancel-in-progress: false on: - workflow_dispatch: + workflow_dispatch: # Allow manual testing workflow_run: workflows: - - "๐Ÿ—๏ธ Build iOS IPA (phone)" - - "๐Ÿ—๏ธ Build iOS IPA (tv)" - - "๐Ÿ—๏ธ Build Android APK (phone)" - - "๐Ÿ—๏ธ Build Android APK (tv)" + - "๐Ÿค– Android APK Build (Phone + TV)" + - "๐Ÿค– iOS IPA Build (Phone + TV)" types: - completed jobs: comment-artifacts: - if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success' + if: github.event_name == 'workflow_dispatch' || (github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success') runs-on: ubuntu-latest permissions: contents: read @@ -29,23 +27,56 @@ jobs: uses: actions/github-script@v8 with: script: | - console.log('Workflow run event:', JSON.stringify(github.event.workflow_run, null, 2)); + // Handle both workflow_run and manual dispatch events + let runId, pr; - // Find PR associated with this commit - const { data: pullRequests } = await github.rest.repos.listPullRequestsAssociatedWithCommit({ - owner: context.repo.owner, - repo: context.repo.repo, - commit_sha: "${{ github.event.workflow_run.head_sha }}" - }); - - if (pullRequests.length === 0) { - console.log('No pull request found for commit: ${{ github.event.workflow_run.head_sha }}'); + if (context.eventName === 'workflow_run') { + runId = github.event.workflow_run.id; + + // Find PR associated with this commit + const { data: pullRequests } = await github.rest.repos.listPullRequestsAssociatedWithCommit({ + owner: context.repo.owner, + repo: context.repo.repo, + commit_sha: github.event.workflow_run.head_sha + }); + + if (pullRequests.length === 0) { + console.log('No pull request found for commit:', github.event.workflow_run.head_sha); + return; + } + pr = pullRequests[0]; + + } else if (context.eventName === 'workflow_dispatch') { + // For manual testing, use most recent test workflow run + const { data: workflows } = await github.rest.actions.listWorkflowRuns({ + owner: context.repo.owner, + repo: context.repo.repo, + workflow_id: 'test-artifact.yml', + per_page: 1 + }); + + if (workflows.workflow_runs.length === 0) { + console.log('No test workflow runs found'); + return; + } + + const testRun = workflows.workflow_runs[0]; + runId = testRun.id; + + // Get current PR for manual testing + const prNumber = context.payload.pull_request?.number || 1101; + const { data: prData } = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: prNumber + }); + pr = prData; + + } else { + console.log('Unsupported event type:', context.eventName); return; } - const pr = pullRequests[0]; - const runId = "${{ github.event.workflow_run.id }}"; - console.log(`Found PR #${pr.number} for commit ${pr.head.sha.substring(0, 7)}`); // Get artifacts from the workflow run @@ -55,8 +86,6 @@ jobs: run_id: runId }); - console.log(`Found ${artifacts?.artifacts?.length || 0} artifacts`); - if (!artifacts || artifacts.artifacts.length === 0) { console.log('No artifacts found for this run'); return; @@ -70,8 +99,6 @@ jobs: .filter(a => a.name.includes('ios')) .sort((a, b) => a.name.localeCompare(b.name)); - console.log(`Android artifacts: ${androidArtifacts.length}, iOS artifacts: ${iosArtifacts.length}`); - // Build comment body with table format let commentBody = `## ๐Ÿ“ฑ Build Artifacts Ready!\n\n`; commentBody += `โœ… **Workflow completed successfully** for PR #${pr.number}\n`; From 1ff09a2d34e7ac2e71c749deaef0ce8cf8d258fe Mon Sep 17 00:00:00 2001 From: Uruk Date: Mon, 29 Sep 2025 23:51:03 +0200 Subject: [PATCH 12/25] feat: enhance artifact workflow to show real-time build status Improves the artifact comment workflow to provide better visibility into ongoing builds by: - Triggering comments when builds start (requested event) instead of only on completion - Using commit SHA for concurrency grouping to better handle multiple builds for the same commit - Collecting artifacts from all recent build workflows for a PR rather than just the current run - Adding a build status table showing progress of Android and iOS workflows - Displaying progressive status updates even when builds are still in progress - Enabling cancel-in-progress to prevent redundant workflow runs This provides users with immediate feedback on build progress and comprehensive artifact availability across all platforms. --- .github/workflows/artifact-comment.yml | 128 +++++++++++++++++-------- 1 file changed, 87 insertions(+), 41 deletions(-) diff --git a/.github/workflows/artifact-comment.yml b/.github/workflows/artifact-comment.yml index 14e74800..60244409 100644 --- a/.github/workflows/artifact-comment.yml +++ b/.github/workflows/artifact-comment.yml @@ -1,8 +1,8 @@ name: ๐Ÿ“ Artifact Comment on PR concurrency: - group: artifact-comment-${{ github.event.workflow_run.id || github.run_id }} - cancel-in-progress: false + group: artifact-comment-${{ github.event.workflow_run.head_sha || github.sha }} + cancel-in-progress: true on: workflow_dispatch: # Allow manual testing @@ -12,10 +12,11 @@ on: - "๐Ÿค– iOS IPA Build (Phone + TV)" types: - completed + - requested # Trigger when build starts jobs: comment-artifacts: - if: github.event_name == 'workflow_dispatch' || (github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success') + if: github.event_name == 'workflow_dispatch' || github.event.workflow_run.event == 'pull_request' runs-on: ubuntu-latest permissions: contents: read @@ -28,11 +29,9 @@ jobs: with: script: | // Handle both workflow_run and manual dispatch events - let runId, pr; + let pr; if (context.eventName === 'workflow_run') { - runId = github.event.workflow_run.id; - // Find PR associated with this commit const { data: pullRequests } = await github.rest.repos.listPullRequestsAssociatedWithCommit({ owner: context.repo.owner, @@ -47,22 +46,6 @@ jobs: pr = pullRequests[0]; } else if (context.eventName === 'workflow_dispatch') { - // For manual testing, use most recent test workflow run - const { data: workflows } = await github.rest.actions.listWorkflowRuns({ - owner: context.repo.owner, - repo: context.repo.repo, - workflow_id: 'test-artifact.yml', - per_page: 1 - }); - - if (workflows.workflow_runs.length === 0) { - console.log('No test workflow runs found'); - return; - } - - const testRun = workflows.workflow_runs[0]; - runId = testRun.id; - // Get current PR for manual testing const prNumber = context.payload.pull_request?.number || 1101; const { data: prData } = await github.rest.pulls.get({ @@ -77,36 +60,97 @@ jobs: return; } - console.log(`Found PR #${pr.number} for commit ${pr.head.sha.substring(0, 7)}`); + console.log(`Processing PR #${pr.number} for commit ${pr.head.sha.substring(0, 7)}`); - // Get artifacts from the workflow run + // Get all recent workflow runs for this PR to collect artifacts from multiple builds + const { data: workflowRuns } = await github.rest.actions.listWorkflowRuns({ + owner: context.repo.owner, + repo: context.repo.repo, + head_sha: pr.head.sha, + per_page: 10 + }); + + // Filter for build workflows only + const buildRuns = workflowRuns.workflow_runs.filter(run => + run.name.includes('Android APK Build') || + run.name.includes('iOS IPA Build') + ); + + console.log(`Found ${buildRuns.length} build workflow runs for this commit`); + + // Collect artifacts from all completed successful builds + let allArtifacts = []; + let buildStatuses = {}; + + for (const run of buildRuns) { + buildStatuses[run.name] = { + status: run.status, + conclusion: run.conclusion, + url: run.html_url, + runId: run.id + }; + + if (run.conclusion === 'success') { + try { + const { data: artifacts } = await github.rest.actions.listWorkflowRunArtifacts({ + owner: context.repo.owner, + repo: context.repo.repo, + run_id: run.id + }); + allArtifacts.push(...artifacts.artifacts); + } catch (error) { + console.log(`Failed to get artifacts for run ${run.id}:`, error.message); + } + } + } + + console.log(`Collected ${allArtifacts.length} total artifacts from all builds`); + + // Get artifacts from current run if needed const { data: artifacts } = await github.rest.actions.listWorkflowRunArtifacts({ owner: context.repo.owner, repo: context.repo.repo, run_id: runId }); - if (!artifacts || artifacts.artifacts.length === 0) { - console.log('No artifacts found for this run'); - return; - } - - // Sort and categorize artifacts - const androidArtifacts = artifacts.artifacts + // Sort and categorize all collected artifacts + const androidArtifacts = allArtifacts .filter(a => a.name.includes('android')) .sort((a, b) => a.name.localeCompare(b.name)); - const iosArtifacts = artifacts.artifacts + const iosArtifacts = allArtifacts .filter(a => a.name.includes('ios')) .sort((a, b) => a.name.localeCompare(b.name)); - // Build comment body with table format - let commentBody = `## ๐Ÿ“ฑ Build Artifacts Ready!\n\n`; - commentBody += `โœ… **Workflow completed successfully** for PR #${pr.number}\n`; - commentBody += `๐Ÿ“ฆ **${artifacts.artifacts.length} artifacts** generated from commit [\`${pr.head.sha.substring(0, 7)}\`](https://github.com/${context.repo.owner}/${context.repo.repo}/commit/${pr.head.sha})\n\n`; + // Build comment body with progressive status + let commentBody = `## ๐Ÿ“ฑ Build Status for PR #${pr.number}\n\n`; + commentBody += `๐Ÿ”— **Commit**: [\`${pr.head.sha.substring(0, 7)}\`](https://github.com/${context.repo.owner}/${context.repo.repo}/commit/${pr.head.sha})\n\n`; + + // Add build status table + commentBody += `### ๐Ÿ”ง Build Status\n\n`; + commentBody += `| Workflow | Status | Artifacts |\n`; + commentBody += `|----------|--------|-----------|\n`; + + for (const [name, status] of Object.entries(buildStatuses)) { + const emoji = status.conclusion === 'success' ? 'โœ…' : + status.conclusion === 'failure' ? 'โŒ' : + status.status === 'in_progress' ? '๐Ÿ”„' : 'โณ'; + const statusText = status.conclusion || status.status || 'pending'; + const artifactCount = allArtifacts.filter(a => { + // Match artifacts to workflows based on naming patterns + if (name.includes('Android')) return a.name.includes('android'); + if (name.includes('iOS')) return a.name.includes('ios'); + return false; + }).length; + + commentBody += `| [${name}](${status.url}) | ${emoji} ${statusText} | ${artifactCount} |\n`; + } + + commentBody += `\n`; + + // Only show download table if there are artifacts + if (allArtifacts.length > 0) { + commentBody += `### ๐Ÿ“ฆ Available Downloads (${allArtifacts.length} artifacts)\n\n`; - if (androidArtifacts.length === 0 && iosArtifacts.length === 0) { - commentBody += `โš ๏ธ No mobile app artifacts found in this build.\n\n`; - } else { // Create table for better organization commentBody += `| Platform | Device Type | Download Link |\n`; commentBody += `|----------|-------------|---------------|\n`; @@ -115,7 +159,7 @@ jobs: androidArtifacts.forEach(artifact => { const isTV = artifact.name.includes('tv'); const deviceType = isTV ? '๐Ÿ“บ Android TV' : '๐Ÿ“ฑ Android Phone'; - const nightlyLink = `https://nightly.link/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}/${artifact.name}.zip`; + const nightlyLink = `https://nightly.link/${context.repo.owner}/${context.repo.repo}/actions/runs/${artifact.workflow_run.id}/${artifact.name}.zip`; commentBody += `| ๐Ÿค– Android | ${deviceType} | [๐Ÿ“ฅ Download APK](${nightlyLink}) |\n`; }); @@ -123,7 +167,7 @@ jobs: iosArtifacts.forEach(artifact => { const isTV = artifact.name.includes('tv'); const deviceType = isTV ? '๐Ÿ“บ Apple TV' : '๐Ÿ“ฑ iPhone/iPad'; - const nightlyLink = `https://nightly.link/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}/${artifact.name}.zip`; + const nightlyLink = `https://nightly.link/${context.repo.owner}/${context.repo.repo}/actions/runs/${artifact.workflow_run.id}/${artifact.name}.zip`; commentBody += `| ๐ŸŽ iOS | ${deviceType} | [๐Ÿ“ฅ Download IPA](${nightlyLink}) |\n`; }); @@ -132,6 +176,8 @@ jobs: commentBody += `- **Android APK**: Download and install directly on your device (enable "Install from unknown sources")\n`; commentBody += `- **iOS IPA**: Install using [AltStore](https://altstore.io/), [Sideloadly](https://sideloadly.io/), or Xcode\n\n`; commentBody += `> โš ๏ธ **Note**: Artifacts expire in 7 days from build date\n\n`; + } else { + commentBody += `โณ **No artifacts available yet** - builds are still in progress or haven't completed successfully.\n\n`; } commentBody += `*Auto-generated by [GitHub Actions](https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId})*`; From 0d1aeaf8aaeea68f5dbb2f8a4a32eebb374db7be Mon Sep 17 00:00:00 2001 From: Uruk Date: Mon, 29 Sep 2025 23:55:36 +0200 Subject: [PATCH 13/25] fix(ci): improve workflow run condition and artifact collection Fixes workflow trigger condition by explicitly checking for workflow_run event type to prevent unintended executions. Improves artifact collection reliability by switching to the correct API method and increasing page size to capture more artifacts from multiple builds. Removes redundant artifact fetching logic that was duplicating collection efforts. --- .github/workflows/artifact-comment.yml | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/.github/workflows/artifact-comment.yml b/.github/workflows/artifact-comment.yml index 60244409..f7d5ff04 100644 --- a/.github/workflows/artifact-comment.yml +++ b/.github/workflows/artifact-comment.yml @@ -16,7 +16,7 @@ on: jobs: comment-artifacts: - if: github.event_name == 'workflow_dispatch' || github.event.workflow_run.event == 'pull_request' + if: github.event_name == 'workflow_dispatch' || (github.event_name == 'workflow_run' && github.event.workflow_run.event == 'pull_request') runs-on: ubuntu-latest permissions: contents: read @@ -63,11 +63,11 @@ jobs: console.log(`Processing PR #${pr.number} for commit ${pr.head.sha.substring(0, 7)}`); // Get all recent workflow runs for this PR to collect artifacts from multiple builds - const { data: workflowRuns } = await github.rest.actions.listWorkflowRuns({ + const { data: workflowRuns } = await github.rest.actions.listWorkflowRunsForRepo({ owner: context.repo.owner, repo: context.repo.repo, head_sha: pr.head.sha, - per_page: 10 + per_page: 20 }); // Filter for build workflows only @@ -106,13 +106,6 @@ jobs: console.log(`Collected ${allArtifacts.length} total artifacts from all builds`); - // Get artifacts from current run if needed - const { data: artifacts } = await github.rest.actions.listWorkflowRunArtifacts({ - owner: context.repo.owner, - repo: context.repo.repo, - run_id: runId - }); - // Sort and categorize all collected artifacts const androidArtifacts = allArtifacts .filter(a => a.name.includes('android')) From 4f6863f31736f3bc5b0378d12538c101a6908963 Mon Sep 17 00:00:00 2001 From: Uruk Date: Tue, 30 Sep 2025 00:01:15 +0200 Subject: [PATCH 14/25] feat: add direct pull request trigger to artifact comment workflow Enables the workflow to run directly on pull request events (opened, synchronize, reopened) in addition to the existing workflow_run and manual dispatch triggers. Provides immediate status updates in PR checks and improves user experience by showing workflow progress directly in the pull request interface rather than only after completion of upstream workflows. --- .github/workflows/artifact-comment.yml | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/workflows/artifact-comment.yml b/.github/workflows/artifact-comment.yml index f7d5ff04..4d7df677 100644 --- a/.github/workflows/artifact-comment.yml +++ b/.github/workflows/artifact-comment.yml @@ -6,6 +6,8 @@ concurrency: on: workflow_dispatch: # Allow manual testing + pull_request: # Show in PR checks and provide status updates + types: [opened, synchronize, reopened] workflow_run: workflows: - "๐Ÿค– Android APK Build (Phone + TV)" @@ -16,7 +18,7 @@ on: jobs: comment-artifacts: - if: github.event_name == 'workflow_dispatch' || (github.event_name == 'workflow_run' && github.event.workflow_run.event == 'pull_request') + if: github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request' || (github.event_name == 'workflow_run' && github.event.workflow_run.event == 'pull_request') runs-on: ubuntu-latest permissions: contents: read @@ -28,7 +30,7 @@ jobs: uses: actions/github-script@v8 with: script: | - // Handle both workflow_run and manual dispatch events + // Handle workflow_run, pull_request, and manual dispatch events let pr; if (context.eventName === 'workflow_run') { @@ -45,6 +47,10 @@ jobs: } pr = pullRequests[0]; + } else if (context.eventName === 'pull_request') { + // Direct PR event + pr = context.payload.pull_request; + } else if (context.eventName === 'workflow_dispatch') { // Get current PR for manual testing const prNumber = context.payload.pull_request?.number || 1101; From 5b0d2f3f7bf158c019106edcb78be97ce6b89e02 Mon Sep 17 00:00:00 2001 From: Uruk Date: Tue, 30 Sep 2025 00:03:40 +0200 Subject: [PATCH 15/25] fix(ci): correct GitHub Actions run ID reference Fixes incorrect variable reference in artifact comment generation that was using undefined `runId` instead of the proper `context.runId` from the GitHub Actions context. Also adds descriptive job name for better workflow visibility. --- .github/workflows/artifact-comment.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/artifact-comment.yml b/.github/workflows/artifact-comment.yml index 4d7df677..1e63db57 100644 --- a/.github/workflows/artifact-comment.yml +++ b/.github/workflows/artifact-comment.yml @@ -19,6 +19,7 @@ on: jobs: comment-artifacts: if: github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request' || (github.event_name == 'workflow_run' && github.event.workflow_run.event == 'pull_request') + name: ๐Ÿ“ฆ Post Build Artifacts runs-on: ubuntu-latest permissions: contents: read @@ -179,7 +180,7 @@ jobs: commentBody += `โณ **No artifacts available yet** - builds are still in progress or haven't completed successfully.\n\n`; } - commentBody += `*Auto-generated by [GitHub Actions](https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId})*`; + commentBody += `*Auto-generated by [GitHub Actions](https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId})*`; commentBody += `\n`; // Find existing bot comment to update From e985adf06226e06fb58a2cca6b246ed6145a63cf Mon Sep 17 00:00:00 2001 From: Uruk Date: Tue, 30 Sep 2025 00:22:51 +0200 Subject: [PATCH 16/25] feat: improve build status UI with progressive updates Restructures the artifact comment workflow to display build progress in real-time with individual platform/device status tracking. Changes the status table from workflow-based to target-based (Android Phone/TV, iOS Phone/TV) with dedicated status indicators and download links that update as builds complete. Improves user experience by showing pending builds with appropriate messaging instead of waiting for all builds to finish before displaying any information. --- .github/workflows/artifact-comment.yml | 111 +++++++++++++------------ 1 file changed, 59 insertions(+), 52 deletions(-) diff --git a/.github/workflows/artifact-comment.yml b/.github/workflows/artifact-comment.yml index 1e63db57..6a36d0f5 100644 --- a/.github/workflows/artifact-comment.yml +++ b/.github/workflows/artifact-comment.yml @@ -85,10 +85,18 @@ jobs: console.log(`Found ${buildRuns.length} build workflow runs for this commit`); - // Collect artifacts from all completed successful builds + // Collect artifacts and statuses from all builds (completed and in-progress) let allArtifacts = []; let buildStatuses = {}; + // Define all expected build targets + const expectedBuilds = { + 'Android Phone': { platform: 'Android', device: 'Phone', emoji: '๐Ÿ“ฑ', pattern: 'android.*phone' }, + 'Android TV': { platform: 'Android', device: 'TV', emoji: '๐Ÿ“บ', pattern: 'android.*tv' }, + 'iOS Phone': { platform: 'iOS', device: 'Phone', emoji: '๐Ÿ“ฑ', pattern: 'ios.*phone' }, + 'iOS TV': { platform: 'iOS', device: 'TV', emoji: '๐Ÿ“บ', pattern: 'ios.*tv' } + }; + for (const run of buildRuns) { buildStatuses[run.name] = { status: run.status, @@ -97,6 +105,7 @@ jobs: runId: run.id }; + // Collect artifacts from any completed successful builds if (run.conclusion === 'success') { try { const { data: artifacts } = await github.rest.actions.listWorkflowRunArtifacts({ @@ -113,71 +122,69 @@ jobs: console.log(`Collected ${allArtifacts.length} total artifacts from all builds`); - // Sort and categorize all collected artifacts - const androidArtifacts = allArtifacts - .filter(a => a.name.includes('android')) - .sort((a, b) => a.name.localeCompare(b.name)); - const iosArtifacts = allArtifacts - .filter(a => a.name.includes('ios')) - .sort((a, b) => a.name.localeCompare(b.name)); - - // Build comment body with progressive status + // Build comment body with progressive status for individual builds let commentBody = `## ๐Ÿ“ฑ Build Status for PR #${pr.number}\n\n`; commentBody += `๐Ÿ”— **Commit**: [\`${pr.head.sha.substring(0, 7)}\`](https://github.com/${context.repo.owner}/${context.repo.repo}/commit/${pr.head.sha})\n\n`; - // Add build status table - commentBody += `### ๐Ÿ”ง Build Status\n\n`; - commentBody += `| Workflow | Status | Artifacts |\n`; - commentBody += `|----------|--------|-----------|\n`; + // Progressive build status and downloads table + commentBody += `### ๏ฟฝ Build Artifacts\n\n`; + commentBody += `| Platform | Device | Status | Download |\n`; + commentBody += `|----------|--------|--------|---------|\n`; - for (const [name, status] of Object.entries(buildStatuses)) { - const emoji = status.conclusion === 'success' ? 'โœ…' : - status.conclusion === 'failure' ? 'โŒ' : - status.status === 'in_progress' ? '๐Ÿ”„' : 'โณ'; - const statusText = status.conclusion || status.status || 'pending'; - const artifactCount = allArtifacts.filter(a => { - // Match artifacts to workflows based on naming patterns - if (name.includes('Android')) return a.name.includes('android'); - if (name.includes('iOS')) return a.name.includes('ios'); - return false; - }).length; - - commentBody += `| [${name}](${status.url}) | ${emoji} ${statusText} | ${artifactCount} |\n`; + // Process each expected build target individually + const buildTargets = [ + { name: 'Android Phone', platform: '๐Ÿค–', device: '๐Ÿ“ฑ', pattern: /android.*phone/i }, + { name: 'Android TV', platform: '๐Ÿค–', device: '๐Ÿ“บ', pattern: /android.*tv/i }, + { name: 'iOS Phone', platform: '๐ŸŽ', device: '๏ฟฝ', pattern: /ios.*phone/i }, + { name: 'iOS TV', platform: '๐ŸŽ', device: '๐Ÿ“บ', pattern: /ios.*tv/i } + ]; + + for (const target of buildTargets) { + // Find matching workflow run + const matchingRun = buildRuns.find(run => { + return (run.name.includes('Android') && target.name.includes('Android')) || + (run.name.includes('iOS') && target.name.includes('iOS')); + }); + + // Find matching artifact + const matchingArtifact = allArtifacts.find(artifact => + target.pattern.test(artifact.name) + ); + + let status = 'โณ Pending'; + let downloadLink = '*Waiting for build...*'; + + if (matchingRun) { + if (matchingRun.conclusion === 'success' && matchingArtifact) { + status = 'โœ… Complete'; + const nightlyLink = `https://nightly.link/${context.repo.owner}/${context.repo.repo}/actions/runs/${matchingArtifact.workflow_run.id}/${matchingArtifact.name}.zip`; + const fileType = target.name.includes('Android') ? 'APK' : 'IPA'; + downloadLink = `[๐Ÿ“ฅ Download ${fileType}](${nightlyLink})`; + } else if (matchingRun.conclusion === 'failure') { + status = `โŒ [Failed](${matchingRun.html_url})`; + downloadLink = '*Build failed*'; + } else if (matchingRun.status === 'in_progress') { + status = `๐Ÿ”„ [Building...](${matchingRun.html_url})`; + downloadLink = '*Build in progress...*'; + } else if (matchingRun.status === 'queued') { + status = `โณ [Queued](${matchingRun.html_url})`; + downloadLink = '*Waiting to start...*'; + } + } + + commentBody += `| ${target.platform} ${target.name.split(' ')[0]} | ${target.device} ${target.name.split(' ')[1]} | ${status} | ${downloadLink} |\n`; } commentBody += `\n`; - // Only show download table if there are artifacts + // Show installation instructions if we have any artifacts if (allArtifacts.length > 0) { - commentBody += `### ๐Ÿ“ฆ Available Downloads (${allArtifacts.length} artifacts)\n\n`; - - // Create table for better organization - commentBody += `| Platform | Device Type | Download Link |\n`; - commentBody += `|----------|-------------|---------------|\n`; - - // Add Android artifacts - androidArtifacts.forEach(artifact => { - const isTV = artifact.name.includes('tv'); - const deviceType = isTV ? '๐Ÿ“บ Android TV' : '๐Ÿ“ฑ Android Phone'; - const nightlyLink = `https://nightly.link/${context.repo.owner}/${context.repo.repo}/actions/runs/${artifact.workflow_run.id}/${artifact.name}.zip`; - commentBody += `| ๐Ÿค– Android | ${deviceType} | [๐Ÿ“ฅ Download APK](${nightlyLink}) |\n`; - }); - - // Add iOS artifacts - iosArtifacts.forEach(artifact => { - const isTV = artifact.name.includes('tv'); - const deviceType = isTV ? '๐Ÿ“บ Apple TV' : '๐Ÿ“ฑ iPhone/iPad'; - const nightlyLink = `https://nightly.link/${context.repo.owner}/${context.repo.repo}/actions/runs/${artifact.workflow_run.id}/${artifact.name}.zip`; - commentBody += `| ๐ŸŽ iOS | ${deviceType} | [๐Ÿ“ฅ Download IPA](${nightlyLink}) |\n`; - }); - - commentBody += `\n`; commentBody += `### ๐Ÿ”ง Installation Instructions\n\n`; commentBody += `- **Android APK**: Download and install directly on your device (enable "Install from unknown sources")\n`; commentBody += `- **iOS IPA**: Install using [AltStore](https://altstore.io/), [Sideloadly](https://sideloadly.io/), or Xcode\n\n`; commentBody += `> โš ๏ธ **Note**: Artifacts expire in 7 days from build date\n\n`; } else { - commentBody += `โณ **No artifacts available yet** - builds are still in progress or haven't completed successfully.\n\n`; + commentBody += `โณ **Builds are starting up...** This comment will update automatically as each build completes.\n\n`; } commentBody += `*Auto-generated by [GitHub Actions](https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId})*`; From 44e489f40c66deac09ae9e93d5640301aff57496 Mon Sep 17 00:00:00 2001 From: Uruk Date: Tue, 30 Sep 2025 00:29:13 +0200 Subject: [PATCH 17/25] refactor: improve workflow run tracking and status display Enhances the artifact comment workflow by switching from tracking all build runs to focusing on the most recent run per workflow type (Android/iOS). Changes include: - Increases pagination limit to capture more workflow runs - Sorts runs by creation time to identify latest builds - Simplifies status tracking by workflow platform rather than individual runs - Adds detailed logging for debugging build statuses - Improves error handling for artifact collection - Fixes emoji rendering issue in status display Reduces complexity while ensuring accurate status reporting for the latest builds. --- .github/workflows/artifact-comment.yml | 112 ++++++++++++++++--------- 1 file changed, 72 insertions(+), 40 deletions(-) diff --git a/.github/workflows/artifact-comment.yml b/.github/workflows/artifact-comment.yml index 6a36d0f5..1aa981f1 100644 --- a/.github/workflows/artifact-comment.yml +++ b/.github/workflows/artifact-comment.yml @@ -74,48 +74,79 @@ jobs: owner: context.repo.owner, repo: context.repo.repo, head_sha: pr.head.sha, - per_page: 20 + per_page: 30 }); - // Filter for build workflows only - const buildRuns = workflowRuns.workflow_runs.filter(run => - run.name.includes('Android APK Build') || - run.name.includes('iOS IPA Build') - ); + // Filter for build workflows only and sort by creation time (most recent first) + const buildRuns = workflowRuns.workflow_runs + .filter(run => + run.name.includes('Android APK Build') || + run.name.includes('iOS IPA Build') + ) + .sort((a, b) => new Date(b.created_at) - new Date(a.created_at)); console.log(`Found ${buildRuns.length} build workflow runs for this commit`); - // Collect artifacts and statuses from all builds (completed and in-progress) + // Log current status of each build for debugging + buildRuns.forEach(run => { + console.log(`- ${run.name}: ${run.status} (${run.conclusion || 'no conclusion yet'}) - Created: ${run.created_at}`); + }); + + // Collect artifacts and statuses from builds - get most recent run for each workflow type let allArtifacts = []; let buildStatuses = {}; - // Define all expected build targets - const expectedBuilds = { - 'Android Phone': { platform: 'Android', device: 'Phone', emoji: '๐Ÿ“ฑ', pattern: 'android.*phone' }, - 'Android TV': { platform: 'Android', device: 'TV', emoji: '๐Ÿ“บ', pattern: 'android.*tv' }, - 'iOS Phone': { platform: 'iOS', device: 'Phone', emoji: '๐Ÿ“ฑ', pattern: 'ios.*phone' }, - 'iOS TV': { platform: 'iOS', device: 'TV', emoji: '๐Ÿ“บ', pattern: 'ios.*tv' } - }; + // Get the most recent run for each workflow type + const latestAndroidRun = buildRuns.find(run => run.name.includes('Android APK Build')); + const latestIOSRun = buildRuns.find(run => run.name.includes('iOS IPA Build')); - for (const run of buildRuns) { - buildStatuses[run.name] = { - status: run.status, - conclusion: run.conclusion, - url: run.html_url, - runId: run.id + // Store status for each workflow type + if (latestAndroidRun) { + buildStatuses['Android'] = { + name: latestAndroidRun.name, + status: latestAndroidRun.status, + conclusion: latestAndroidRun.conclusion, + url: latestAndroidRun.html_url, + runId: latestAndroidRun.id, + created_at: latestAndroidRun.created_at }; - // Collect artifacts from any completed successful builds - if (run.conclusion === 'success') { + // Collect artifacts if completed successfully + if (latestAndroidRun.conclusion === 'success') { try { const { data: artifacts } = await github.rest.actions.listWorkflowRunArtifacts({ owner: context.repo.owner, repo: context.repo.repo, - run_id: run.id + run_id: latestAndroidRun.id }); allArtifacts.push(...artifacts.artifacts); } catch (error) { - console.log(`Failed to get artifacts for run ${run.id}:`, error.message); + console.log(`Failed to get Android artifacts for run ${latestAndroidRun.id}:`, error.message); + } + } + } + + if (latestIOSRun) { + buildStatuses['iOS'] = { + name: latestIOSRun.name, + status: latestIOSRun.status, + conclusion: latestIOSRun.conclusion, + url: latestIOSRun.html_url, + runId: latestIOSRun.id, + created_at: latestIOSRun.created_at + }; + + // Collect artifacts if completed successfully + if (latestIOSRun.conclusion === 'success') { + try { + const { data: artifacts } = await github.rest.actions.listWorkflowRunArtifacts({ + owner: context.repo.owner, + repo: context.repo.repo, + run_id: latestIOSRun.id + }); + allArtifacts.push(...artifacts.artifacts); + } catch (error) { + console.log(`Failed to get iOS artifacts for run ${latestIOSRun.id}:`, error.message); } } } @@ -123,11 +154,11 @@ jobs: console.log(`Collected ${allArtifacts.length} total artifacts from all builds`); // Build comment body with progressive status for individual builds - let commentBody = `## ๐Ÿ“ฑ Build Status for PR #${pr.number}\n\n`; + let commentBody = `## ๐Ÿ”ง Build Status for PR #${pr.number}\n\n`; commentBody += `๐Ÿ”— **Commit**: [\`${pr.head.sha.substring(0, 7)}\`](https://github.com/${context.repo.owner}/${context.repo.repo}/commit/${pr.head.sha})\n\n`; // Progressive build status and downloads table - commentBody += `### ๏ฟฝ Build Artifacts\n\n`; + commentBody += `### ๐Ÿ“ฆ Build Artifacts\n\n`; commentBody += `| Platform | Device | Status | Download |\n`; commentBody += `|----------|--------|--------|---------|\n`; @@ -135,16 +166,13 @@ jobs: const buildTargets = [ { name: 'Android Phone', platform: '๐Ÿค–', device: '๐Ÿ“ฑ', pattern: /android.*phone/i }, { name: 'Android TV', platform: '๐Ÿค–', device: '๐Ÿ“บ', pattern: /android.*tv/i }, - { name: 'iOS Phone', platform: '๐ŸŽ', device: '๏ฟฝ', pattern: /ios.*phone/i }, + { name: 'iOS Phone', platform: '๐ŸŽ', device: '๐Ÿ“ฑ', pattern: /ios.*phone/i }, { name: 'iOS TV', platform: '๐ŸŽ', device: '๐Ÿ“บ', pattern: /ios.*tv/i } ]; for (const target of buildTargets) { - // Find matching workflow run - const matchingRun = buildRuns.find(run => { - return (run.name.includes('Android') && target.name.includes('Android')) || - (run.name.includes('iOS') && target.name.includes('iOS')); - }); + // Find matching workflow status (using our simplified structure) + const matchingStatus = target.name.includes('Android') ? buildStatuses['Android'] : buildStatuses['iOS']; // Find matching artifact const matchingArtifact = allArtifacts.find(artifact => @@ -154,21 +182,25 @@ jobs: let status = 'โณ Pending'; let downloadLink = '*Waiting for build...*'; - if (matchingRun) { - if (matchingRun.conclusion === 'success' && matchingArtifact) { + if (matchingStatus) { + if (matchingStatus.conclusion === 'success' && matchingArtifact) { status = 'โœ… Complete'; const nightlyLink = `https://nightly.link/${context.repo.owner}/${context.repo.repo}/actions/runs/${matchingArtifact.workflow_run.id}/${matchingArtifact.name}.zip`; const fileType = target.name.includes('Android') ? 'APK' : 'IPA'; downloadLink = `[๐Ÿ“ฅ Download ${fileType}](${nightlyLink})`; - } else if (matchingRun.conclusion === 'failure') { - status = `โŒ [Failed](${matchingRun.html_url})`; + } else if (matchingStatus.conclusion === 'failure') { + status = `โŒ [Failed](${matchingStatus.url})`; downloadLink = '*Build failed*'; - } else if (matchingRun.status === 'in_progress') { - status = `๐Ÿ”„ [Building...](${matchingRun.html_url})`; + } else if (matchingStatus.status === 'in_progress') { + status = `๐Ÿ”„ [Building...](${matchingStatus.url})`; downloadLink = '*Build in progress...*'; - } else if (matchingRun.status === 'queued') { - status = `โณ [Queued](${matchingRun.html_url})`; + } else if (matchingStatus.status === 'queued') { + status = `โณ [Queued](${matchingStatus.url})`; downloadLink = '*Waiting to start...*'; + } else { + // Show any other status with timestamp for debugging + status = `๐Ÿ”„ [${matchingStatus.status}](${matchingStatus.url})`; + downloadLink = `*Status: ${matchingStatus.status}*`; } } From 2b761f15c82a0ae522bb3e3944f896aef4bf3846 Mon Sep 17 00:00:00 2001 From: Uruk Date: Tue, 30 Sep 2025 00:51:02 +0200 Subject: [PATCH 18/25] feat!: replace workflow_run with repository_dispatch for real-time build status updates Replaces the workflow_run trigger mechanism with repository_dispatch events to enable real-time build status communication between build workflows and the artifact comment system. Build workflows now actively notify the comment workflow when builds start, complete, or fail, providing immediate status updates rather than polling for completed workflows. Adds real-time payload processing to display current build status and target information in PR comments, improving visibility into ongoing build processes. BREAKING CHANGE: Changes the trigger mechanism from workflow_run to repository_dispatch, requiring build workflows to explicitly send status notifications. --- .github/workflows/artifact-comment.yml | 110 ++++++++++++++++++------- .github/workflows/build-android.yml | 61 ++++++++++++++ .github/workflows/build-ios.yml | 67 ++++++++++++++- 3 files changed, 207 insertions(+), 31 deletions(-) diff --git a/.github/workflows/artifact-comment.yml b/.github/workflows/artifact-comment.yml index 1aa981f1..0bfb99af 100644 --- a/.github/workflows/artifact-comment.yml +++ b/.github/workflows/artifact-comment.yml @@ -1,56 +1,61 @@ name: ๐Ÿ“ Artifact Comment on PR concurrency: - group: artifact-comment-${{ github.event.workflow_run.head_sha || github.sha }} + group: artifact-comment-${{ github.sha }} cancel-in-progress: true on: workflow_dispatch: # Allow manual testing pull_request: # Show in PR checks and provide status updates types: [opened, synchronize, reopened] - workflow_run: - workflows: - - "๐Ÿค– Android APK Build (Phone + TV)" - - "๐Ÿค– iOS IPA Build (Phone + TV)" + repository_dispatch: # Triggered by build workflows when they start/complete types: - - completed - - requested # Trigger when build starts + - build-started + - build-completed + - build-failed jobs: comment-artifacts: - if: github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request' || (github.event_name == 'workflow_run' && github.event.workflow_run.event == 'pull_request') + if: github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request' || github.event_name == 'repository_dispatch' name: ๐Ÿ“ฆ Post Build Artifacts runs-on: ubuntu-latest permissions: contents: read pull-requests: write actions: read + repository-projects: read steps: - name: ๐Ÿ” Get PR and Artifacts uses: actions/github-script@v8 with: script: | - // Handle workflow_run, pull_request, and manual dispatch events + // Handle repository_dispatch, pull_request, and manual dispatch events let pr; + let targetCommitSha; - if (context.eventName === 'workflow_run') { - // Find PR associated with this commit - const { data: pullRequests } = await github.rest.repos.listPullRequestsAssociatedWithCommit({ - owner: context.repo.owner, - repo: context.repo.repo, - commit_sha: github.event.workflow_run.head_sha - }); + if (context.eventName === 'repository_dispatch') { + // Triggered by build workflows - get PR info from payload + const payload = context.payload.client_payload; + console.log('Repository dispatch payload:', JSON.stringify(payload, null, 2)); - if (pullRequests.length === 0) { - console.log('No pull request found for commit:', github.event.workflow_run.head_sha); + if (!payload || !payload.pr_number) { + console.log('No PR information in repository_dispatch payload'); return; } - pr = pullRequests[0]; + + const { data: prData } = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: payload.pr_number + }); + pr = prData; + targetCommitSha = payload.commit_sha || pr.head.sha; } else if (context.eventName === 'pull_request') { // Direct PR event pr = context.payload.pull_request; + targetCommitSha = pr.head.sha; } else if (context.eventName === 'workflow_dispatch') { // Get current PR for manual testing @@ -61,19 +66,20 @@ jobs: pull_number: prNumber }); pr = prData; + targetCommitSha = pr.head.sha; } else { console.log('Unsupported event type:', context.eventName); return; } - console.log(`Processing PR #${pr.number} for commit ${pr.head.sha.substring(0, 7)}`); + console.log(`Processing PR #${pr.number} for commit ${targetCommitSha.substring(0, 7)}`); // Get all recent workflow runs for this PR to collect artifacts from multiple builds const { data: workflowRuns } = await github.rest.actions.listWorkflowRunsForRepo({ owner: context.repo.owner, repo: context.repo.repo, - head_sha: pr.head.sha, + head_sha: targetCommitSha, per_page: 30 }); @@ -151,11 +157,50 @@ jobs: } } + // Override with real-time data from repository_dispatch if available + if (context.eventName === 'repository_dispatch') { + const payload = context.payload.client_payload; + const workflowType = payload.workflow_name.includes('Android') ? 'Android' : 'iOS'; + + if (buildStatuses[workflowType]) { + // Update the existing status with real-time data + buildStatuses[workflowType].status = payload.status === 'in_progress' ? 'in_progress' : + payload.status === 'success' ? 'completed' : + payload.status === 'failure' ? 'completed' : + buildStatuses[workflowType].status; + buildStatuses[workflowType].conclusion = payload.status === 'success' ? 'success' : + payload.status === 'failure' ? 'failure' : + buildStatuses[workflowType].conclusion; + buildStatuses[workflowType].url = payload.run_url; + buildStatuses[workflowType].target = payload.target; + } else { + // Create new status entry for real-time updates + buildStatuses[workflowType] = { + name: payload.workflow_name, + status: payload.status === 'in_progress' ? 'in_progress' : + payload.status === 'success' ? 'completed' : + payload.status === 'failure' ? 'completed' : 'queued', + conclusion: payload.status === 'success' ? 'success' : + payload.status === 'failure' ? 'failure' : null, + url: payload.run_url, + runId: payload.run_id, + target: payload.target, + created_at: new Date().toISOString() + }; + } + } + console.log(`Collected ${allArtifacts.length} total artifacts from all builds`); // Build comment body with progressive status for individual builds let commentBody = `## ๐Ÿ”ง Build Status for PR #${pr.number}\n\n`; - commentBody += `๐Ÿ”— **Commit**: [\`${pr.head.sha.substring(0, 7)}\`](https://github.com/${context.repo.owner}/${context.repo.repo}/commit/${pr.head.sha})\n\n`; + commentBody += `๐Ÿ”— **Commit**: [\`${targetCommitSha.substring(0, 7)}\`](https://github.com/${context.repo.owner}/${context.repo.repo}/commit/${targetCommitSha})\n\n`; + + // Show event context for debugging (only for repository_dispatch) + if (context.eventName === 'repository_dispatch') { + const payload = context.payload.client_payload; + commentBody += `๐Ÿ”” **Real-time Update**: ${payload.workflow_name} (${payload.target}) - ${payload.status}\n\n`; + } // Progressive build status and downloads table commentBody += `### ๐Ÿ“ฆ Build Artifacts\n\n`; @@ -164,15 +209,24 @@ jobs: // Process each expected build target individually const buildTargets = [ - { name: 'Android Phone', platform: '๐Ÿค–', device: '๐Ÿ“ฑ', pattern: /android.*phone/i }, - { name: 'Android TV', platform: '๐Ÿค–', device: '๐Ÿ“บ', pattern: /android.*tv/i }, - { name: 'iOS Phone', platform: '๐ŸŽ', device: '๐Ÿ“ฑ', pattern: /ios.*phone/i }, - { name: 'iOS TV', platform: '๐ŸŽ', device: '๐Ÿ“บ', pattern: /ios.*tv/i } + { name: 'Android Phone', platform: '๐Ÿค–', device: '๐Ÿ“ฑ', workflowType: 'Android', target: 'phone' }, + { name: 'Android TV', platform: '๐Ÿค–', device: '๐Ÿ“บ', workflowType: 'Android', target: 'tv' }, + { name: 'iOS Phone', platform: '๐ŸŽ', device: '๐Ÿ“ฑ', workflowType: 'iOS', target: 'phone' }, + { name: 'iOS TV', platform: '๐ŸŽ', device: '๐Ÿ“บ', workflowType: 'iOS', target: 'tv' } ]; for (const target of buildTargets) { - // Find matching workflow status (using our simplified structure) - const matchingStatus = target.name.includes('Android') ? buildStatuses['Android'] : buildStatuses['iOS']; + // Find matching workflow status + let matchingStatus = buildStatuses[target.workflowType]; + + // For repository_dispatch events, check if this specific target matches + if (context.eventName === 'repository_dispatch' && matchingStatus) { + const payload = context.payload.client_payload; + if (payload.target !== target.target) { + // This update is for a different target, show default status + matchingStatus = null; + } + } // Find matching artifact const matchingArtifact = allArtifacts.find(artifact => diff --git a/.github/workflows/build-android.yml b/.github/workflows/build-android.yml index bc9b9ea1..d273f616 100644 --- a/.github/workflows/build-android.yml +++ b/.github/workflows/build-android.yml @@ -18,6 +18,7 @@ jobs: name: ๐Ÿ—๏ธ Build Android APK permissions: contents: read + repository-projects: write strategy: fail-fast: false @@ -25,6 +26,26 @@ jobs: target: [phone, tv] steps: + - name: ๐Ÿ“ข Notify artifact comment workflow (started) + if: github.event_name == 'pull_request' + uses: actions/github-script@v8 + with: + script: | + await github.rest.repos.createDispatchEvent({ + owner: context.repo.owner, + repo: context.repo.repo, + event_type: 'build-started', + client_payload: { + workflow_name: 'Android APK Build', + target: '${{ matrix.target }}', + status: 'in_progress', + pr_number: ${{ github.event.pull_request.number }}, + commit_sha: '${{ github.event.pull_request.head.sha }}', + run_id: ${{ github.run_id }}, + run_url: '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}' + } + }); + - name: ๐Ÿ“ฅ Checkout code uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 with: @@ -91,3 +112,43 @@ jobs: path: | android/app/build/outputs/apk/release/*.apk retention-days: 7 + + - name: ๐Ÿ”” Notify artifact comment workflow (success) + if: success() && github.event_name == 'pull_request' + uses: actions/github-script@v8 + with: + script: | + await github.rest.repos.createDispatchEvent({ + owner: context.repo.owner, + repo: context.repo.repo, + event_type: 'build-completed', + client_payload: { + workflow_name: 'Android APK Build', + target: '${{ matrix.target }}', + status: 'success', + pr_number: ${{ github.event.pull_request.number }}, + commit_sha: '${{ github.event.pull_request.head.sha }}', + run_id: ${{ github.run_id }}, + run_url: '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}' + } + }); + + - name: ๐Ÿ”” Notify artifact comment workflow (failure) + if: failure() && github.event_name == 'pull_request' + uses: actions/github-script@v8 + with: + script: | + await github.rest.repos.createDispatchEvent({ + owner: context.repo.owner, + repo: context.repo.repo, + event_type: 'build-failed', + client_payload: { + workflow_name: 'Android APK Build', + target: '${{ matrix.target }}', + status: 'failure', + pr_number: ${{ github.event.pull_request.number }}, + commit_sha: '${{ github.event.pull_request.head.sha }}', + run_id: ${{ github.run_id }}, + run_url: '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}' + } + }); diff --git a/.github/workflows/build-ios.yml b/.github/workflows/build-ios.yml index 98b587b3..27c5be6c 100644 --- a/.github/workflows/build-ios.yml +++ b/.github/workflows/build-ios.yml @@ -9,11 +9,11 @@ on: pull_request: branches: [develop, master] paths-ignore: - - '*.md' + - "*.md" push: branches: [develop, master] paths-ignore: - - '*.md' + - "*.md" jobs: build-ios: @@ -22,14 +22,35 @@ jobs: name: ๐Ÿ—๏ธ Build iOS IPA permissions: contents: read + repository-projects: write strategy: fail-fast: false matrix: target: [phone] -# target: [phone, tv] + # target: [phone, tv] steps: + - name: ๐Ÿ“ข Notify artifact comment workflow (started) + if: github.event_name == 'pull_request' + uses: actions/github-script@v8 + with: + script: | + await github.rest.repos.createDispatchEvent({ + owner: context.repo.owner, + repo: context.repo.repo, + event_type: 'build-started', + client_payload: { + workflow_name: 'iOS IPA Build', + target: '${{ matrix.target }}', + status: 'in_progress', + pr_number: ${{ github.event.pull_request.number }}, + commit_sha: '${{ github.event.pull_request.head.sha }}', + run_id: ${{ github.run_id }}, + run_url: '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}' + } + }); + - name: ๐Ÿ“ฅ Checkout code uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 with: @@ -93,3 +114,43 @@ jobs: name: streamyfin-ios-${{ matrix.target }}-ipa-${{ env.DATE_TAG }} path: build-*.ipa retention-days: 7 + + - name: ๐Ÿ”” Notify artifact comment workflow (success) + if: success() && github.event_name == 'pull_request' + uses: actions/github-script@v8 + with: + script: | + await github.rest.repos.createDispatchEvent({ + owner: context.repo.owner, + repo: context.repo.repo, + event_type: 'build-completed', + client_payload: { + workflow_name: 'iOS IPA Build', + target: '${{ matrix.target }}', + status: 'success', + pr_number: ${{ github.event.pull_request.number }}, + commit_sha: '${{ github.event.pull_request.head.sha }}', + run_id: ${{ github.run_id }}, + run_url: '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}' + } + }); + + - name: ๐Ÿ”” Notify artifact comment workflow (failure) + if: failure() && github.event_name == 'pull_request' + uses: actions/github-script@v8 + with: + script: | + await github.rest.repos.createDispatchEvent({ + owner: context.repo.owner, + repo: context.repo.repo, + event_type: 'build-failed', + client_payload: { + workflow_name: 'iOS IPA Build', + target: '${{ matrix.target }}', + status: 'failure', + pr_number: ${{ github.event.pull_request.number }}, + commit_sha: '${{ github.event.pull_request.head.sha }}', + run_id: ${{ github.run_id }}, + run_url: '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}' + } + }); From 788f420ce58997567cb54422f78a094937f25ec6 Mon Sep 17 00:00:00 2001 From: Uruk Date: Tue, 30 Sep 2025 00:58:25 +0200 Subject: [PATCH 19/25] refactor: migrate artifact comment system from repository_dispatch to workflow_run Replaces the repository_dispatch event system with workflow_run triggers to improve reliability and reduce complexity. The new approach automatically detects PR associations through commit SHA lookups rather than requiring manual payload construction. Removes redundant notification steps from build workflows and simplifies the concurrency group logic. Enhances manual testing support with improved PR discovery fallbacks. --- .github/workflows/artifact-comment.yml | 142 +++++++++++-------------- .github/workflows/build-android.yml | 61 ----------- .github/workflows/build-ios.yml | 61 ----------- 3 files changed, 61 insertions(+), 203 deletions(-) diff --git a/.github/workflows/artifact-comment.yml b/.github/workflows/artifact-comment.yml index 0bfb99af..c4773b62 100644 --- a/.github/workflows/artifact-comment.yml +++ b/.github/workflows/artifact-comment.yml @@ -1,29 +1,29 @@ name: ๐Ÿ“ Artifact Comment on PR concurrency: - group: artifact-comment-${{ github.sha }} + group: artifact-comment-${{ github.event.workflow_run.head_sha || github.sha }} cancel-in-progress: true on: workflow_dispatch: # Allow manual testing pull_request: # Show in PR checks and provide status updates types: [opened, synchronize, reopened] - repository_dispatch: # Triggered by build workflows when they start/complete + workflow_run: # Triggered when build workflows complete + workflows: + - "๐Ÿค– Android APK Build (Phone + TV)" + - "๐Ÿค– iOS IPA Build (Phone + TV)" types: - - build-started - - build-completed - - build-failed + - completed jobs: comment-artifacts: - if: github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request' || github.event_name == 'repository_dispatch' + if: github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request' || (github.event_name == 'workflow_run' && github.event.workflow_run.event == 'pull_request') name: ๐Ÿ“ฆ Post Build Artifacts runs-on: ubuntu-latest permissions: contents: read pull-requests: write actions: read - repository-projects: read steps: - name: ๐Ÿ” Get PR and Artifacts @@ -34,23 +34,23 @@ jobs: let pr; let targetCommitSha; - if (context.eventName === 'repository_dispatch') { - // Triggered by build workflows - get PR info from payload - const payload = context.payload.client_payload; - console.log('Repository dispatch payload:', JSON.stringify(payload, null, 2)); + if (context.eventName === 'workflow_run') { + // Find PR associated with this workflow run commit + console.log('Workflow run event:', context.payload.workflow_run.name); - if (!payload || !payload.pr_number) { - console.log('No PR information in repository_dispatch payload'); + const { data: pullRequests } = await github.rest.repos.listPullRequestsAssociatedWithCommit({ + owner: context.repo.owner, + repo: context.repo.repo, + commit_sha: context.payload.workflow_run.head_sha + }); + + if (pullRequests.length === 0) { + console.log('No pull request found for commit:', context.payload.workflow_run.head_sha); return; } - const { data: prData } = await github.rest.pulls.get({ - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: payload.pr_number - }); - pr = prData; - targetCommitSha = payload.commit_sha || pr.head.sha; + pr = pullRequests[0]; + targetCommitSha = context.payload.workflow_run.head_sha; } else if (context.eventName === 'pull_request') { // Direct PR event @@ -58,15 +58,45 @@ jobs: targetCommitSha = pr.head.sha; } else if (context.eventName === 'workflow_dispatch') { - // Get current PR for manual testing - const prNumber = context.payload.pull_request?.number || 1101; - const { data: prData } = await github.rest.pulls.get({ - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: prNumber - }); - pr = prData; - targetCommitSha = pr.head.sha; + // For manual testing, try to find PR for current branch/commit + console.log('Manual workflow dispatch triggered'); + + // First, try to find PRs associated with current commit + try { + const { data: pullRequests } = await github.rest.repos.listPullRequestsAssociatedWithCommit({ + owner: context.repo.owner, + repo: context.repo.repo, + commit_sha: context.sha + }); + + if (pullRequests.length > 0) { + pr = pullRequests[0]; + targetCommitSha = pr.head.sha; + console.log(`Found PR #${pr.number} for commit ${context.sha.substring(0, 7)}`); + } else { + // Fallback: get latest open PR + const { data: openPRs } = await github.rest.pulls.list({ + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + sort: 'updated', + direction: 'desc', + per_page: 1 + }); + + if (openPRs.length > 0) { + pr = openPRs[0]; + targetCommitSha = pr.head.sha; + console.log(`Using latest open PR #${pr.number} for manual testing`); + } else { + console.log('No open PRs found for manual testing'); + return; + } + } + } catch (error) { + console.log('Error finding PR for manual testing:', error.message); + return; + } } else { console.log('Unsupported event type:', context.eventName); @@ -157,52 +187,11 @@ jobs: } } - // Override with real-time data from repository_dispatch if available - if (context.eventName === 'repository_dispatch') { - const payload = context.payload.client_payload; - const workflowType = payload.workflow_name.includes('Android') ? 'Android' : 'iOS'; - - if (buildStatuses[workflowType]) { - // Update the existing status with real-time data - buildStatuses[workflowType].status = payload.status === 'in_progress' ? 'in_progress' : - payload.status === 'success' ? 'completed' : - payload.status === 'failure' ? 'completed' : - buildStatuses[workflowType].status; - buildStatuses[workflowType].conclusion = payload.status === 'success' ? 'success' : - payload.status === 'failure' ? 'failure' : - buildStatuses[workflowType].conclusion; - buildStatuses[workflowType].url = payload.run_url; - buildStatuses[workflowType].target = payload.target; - } else { - // Create new status entry for real-time updates - buildStatuses[workflowType] = { - name: payload.workflow_name, - status: payload.status === 'in_progress' ? 'in_progress' : - payload.status === 'success' ? 'completed' : - payload.status === 'failure' ? 'completed' : 'queued', - conclusion: payload.status === 'success' ? 'success' : - payload.status === 'failure' ? 'failure' : null, - url: payload.run_url, - runId: payload.run_id, - target: payload.target, - created_at: new Date().toISOString() - }; - } - } - console.log(`Collected ${allArtifacts.length} total artifacts from all builds`); // Build comment body with progressive status for individual builds let commentBody = `## ๐Ÿ”ง Build Status for PR #${pr.number}\n\n`; - commentBody += `๐Ÿ”— **Commit**: [\`${targetCommitSha.substring(0, 7)}\`](https://github.com/${context.repo.owner}/${context.repo.repo}/commit/${targetCommitSha})\n\n`; - - // Show event context for debugging (only for repository_dispatch) - if (context.eventName === 'repository_dispatch') { - const payload = context.payload.client_payload; - commentBody += `๐Ÿ”” **Real-time Update**: ${payload.workflow_name} (${payload.target}) - ${payload.status}\n\n`; - } - - // Progressive build status and downloads table + commentBody += `๐Ÿ”— **Commit**: [\`${targetCommitSha.substring(0, 7)}\`](https://github.com/${context.repo.owner}/${context.repo.repo}/commit/${targetCommitSha})\n\n`; // Progressive build status and downloads table commentBody += `### ๐Ÿ“ฆ Build Artifacts\n\n`; commentBody += `| Platform | Device | Status | Download |\n`; commentBody += `|----------|--------|--------|---------|\n`; @@ -217,16 +206,7 @@ jobs: for (const target of buildTargets) { // Find matching workflow status - let matchingStatus = buildStatuses[target.workflowType]; - - // For repository_dispatch events, check if this specific target matches - if (context.eventName === 'repository_dispatch' && matchingStatus) { - const payload = context.payload.client_payload; - if (payload.target !== target.target) { - // This update is for a different target, show default status - matchingStatus = null; - } - } + const matchingStatus = buildStatuses[target.workflowType]; // Find matching artifact const matchingArtifact = allArtifacts.find(artifact => diff --git a/.github/workflows/build-android.yml b/.github/workflows/build-android.yml index d273f616..bc9b9ea1 100644 --- a/.github/workflows/build-android.yml +++ b/.github/workflows/build-android.yml @@ -18,7 +18,6 @@ jobs: name: ๐Ÿ—๏ธ Build Android APK permissions: contents: read - repository-projects: write strategy: fail-fast: false @@ -26,26 +25,6 @@ jobs: target: [phone, tv] steps: - - name: ๐Ÿ“ข Notify artifact comment workflow (started) - if: github.event_name == 'pull_request' - uses: actions/github-script@v8 - with: - script: | - await github.rest.repos.createDispatchEvent({ - owner: context.repo.owner, - repo: context.repo.repo, - event_type: 'build-started', - client_payload: { - workflow_name: 'Android APK Build', - target: '${{ matrix.target }}', - status: 'in_progress', - pr_number: ${{ github.event.pull_request.number }}, - commit_sha: '${{ github.event.pull_request.head.sha }}', - run_id: ${{ github.run_id }}, - run_url: '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}' - } - }); - - name: ๐Ÿ“ฅ Checkout code uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 with: @@ -112,43 +91,3 @@ jobs: path: | android/app/build/outputs/apk/release/*.apk retention-days: 7 - - - name: ๐Ÿ”” Notify artifact comment workflow (success) - if: success() && github.event_name == 'pull_request' - uses: actions/github-script@v8 - with: - script: | - await github.rest.repos.createDispatchEvent({ - owner: context.repo.owner, - repo: context.repo.repo, - event_type: 'build-completed', - client_payload: { - workflow_name: 'Android APK Build', - target: '${{ matrix.target }}', - status: 'success', - pr_number: ${{ github.event.pull_request.number }}, - commit_sha: '${{ github.event.pull_request.head.sha }}', - run_id: ${{ github.run_id }}, - run_url: '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}' - } - }); - - - name: ๐Ÿ”” Notify artifact comment workflow (failure) - if: failure() && github.event_name == 'pull_request' - uses: actions/github-script@v8 - with: - script: | - await github.rest.repos.createDispatchEvent({ - owner: context.repo.owner, - repo: context.repo.repo, - event_type: 'build-failed', - client_payload: { - workflow_name: 'Android APK Build', - target: '${{ matrix.target }}', - status: 'failure', - pr_number: ${{ github.event.pull_request.number }}, - commit_sha: '${{ github.event.pull_request.head.sha }}', - run_id: ${{ github.run_id }}, - run_url: '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}' - } - }); diff --git a/.github/workflows/build-ios.yml b/.github/workflows/build-ios.yml index 27c5be6c..76e4a4d2 100644 --- a/.github/workflows/build-ios.yml +++ b/.github/workflows/build-ios.yml @@ -22,7 +22,6 @@ jobs: name: ๐Ÿ—๏ธ Build iOS IPA permissions: contents: read - repository-projects: write strategy: fail-fast: false @@ -31,26 +30,6 @@ jobs: # target: [phone, tv] steps: - - name: ๐Ÿ“ข Notify artifact comment workflow (started) - if: github.event_name == 'pull_request' - uses: actions/github-script@v8 - with: - script: | - await github.rest.repos.createDispatchEvent({ - owner: context.repo.owner, - repo: context.repo.repo, - event_type: 'build-started', - client_payload: { - workflow_name: 'iOS IPA Build', - target: '${{ matrix.target }}', - status: 'in_progress', - pr_number: ${{ github.event.pull_request.number }}, - commit_sha: '${{ github.event.pull_request.head.sha }}', - run_id: ${{ github.run_id }}, - run_url: '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}' - } - }); - - name: ๐Ÿ“ฅ Checkout code uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 with: @@ -114,43 +93,3 @@ jobs: name: streamyfin-ios-${{ matrix.target }}-ipa-${{ env.DATE_TAG }} path: build-*.ipa retention-days: 7 - - - name: ๐Ÿ”” Notify artifact comment workflow (success) - if: success() && github.event_name == 'pull_request' - uses: actions/github-script@v8 - with: - script: | - await github.rest.repos.createDispatchEvent({ - owner: context.repo.owner, - repo: context.repo.repo, - event_type: 'build-completed', - client_payload: { - workflow_name: 'iOS IPA Build', - target: '${{ matrix.target }}', - status: 'success', - pr_number: ${{ github.event.pull_request.number }}, - commit_sha: '${{ github.event.pull_request.head.sha }}', - run_id: ${{ github.run_id }}, - run_url: '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}' - } - }); - - - name: ๐Ÿ”” Notify artifact comment workflow (failure) - if: failure() && github.event_name == 'pull_request' - uses: actions/github-script@v8 - with: - script: | - await github.rest.repos.createDispatchEvent({ - owner: context.repo.owner, - repo: context.repo.repo, - event_type: 'build-failed', - client_payload: { - workflow_name: 'iOS IPA Build', - target: '${{ matrix.target }}', - status: 'failure', - pr_number: ${{ github.event.pull_request.number }}, - commit_sha: '${{ github.event.pull_request.head.sha }}', - run_id: ${{ github.run_id }}, - run_url: '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}' - } - }); From fb0a70690eed20d82950d50097b214b9269b2d26 Mon Sep 17 00:00:00 2001 From: Uruk Date: Tue, 30 Sep 2025 01:20:11 +0200 Subject: [PATCH 20/25] refactor: consolidate build workflows into unified app builder Merges separate Android and iOS build workflows into a single "Build Apps" workflow to reduce duplication and simplify maintenance. Updates the artifact comment workflow to handle both the new unified workflow and legacy separate workflows for backward compatibility during transition. Removes redundant workflow files while preserving all existing functionality. --- .github/workflows/artifact-comment.yml | 54 ++--- .github/workflows/build-android.yml | 93 -------- .github/workflows/build-apps.yml | 280 +++++++++++++++++++++++++ .github/workflows/build-ios.yml | 95 --------- 4 files changed, 309 insertions(+), 213 deletions(-) delete mode 100644 .github/workflows/build-android.yml create mode 100644 .github/workflows/build-apps.yml delete mode 100644 .github/workflows/build-ios.yml diff --git a/.github/workflows/artifact-comment.yml b/.github/workflows/artifact-comment.yml index c4773b62..08710fe8 100644 --- a/.github/workflows/artifact-comment.yml +++ b/.github/workflows/artifact-comment.yml @@ -10,8 +10,7 @@ on: types: [opened, synchronize, reopened] workflow_run: # Triggered when build workflows complete workflows: - - "๐Ÿค– Android APK Build (Phone + TV)" - - "๐Ÿค– iOS IPA Build (Phone + TV)" + - "๐Ÿ—๏ธ Build Apps" types: - completed @@ -116,6 +115,7 @@ jobs: // Filter for build workflows only and sort by creation time (most recent first) const buildRuns = workflowRuns.workflow_runs .filter(run => + run.name.includes('Build Apps') || run.name.includes('Android APK Build') || run.name.includes('iOS IPA Build') ) @@ -132,57 +132,61 @@ jobs: let allArtifacts = []; let buildStatuses = {}; - // Get the most recent run for each workflow type + // Get the most recent run for the unified apps workflow + const latestAppsRun = buildRuns.find(run => run.name.includes('Build Apps')); const latestAndroidRun = buildRuns.find(run => run.name.includes('Android APK Build')); const latestIOSRun = buildRuns.find(run => run.name.includes('iOS IPA Build')); - // Store status for each workflow type - if (latestAndroidRun) { + // Store status for unified workflow (preferred) or fallback to separate workflows + const appsWorkflowRun = latestAppsRun || latestAndroidRun; + if (appsWorkflowRun) { buildStatuses['Android'] = { - name: latestAndroidRun.name, - status: latestAndroidRun.status, - conclusion: latestAndroidRun.conclusion, - url: latestAndroidRun.html_url, - runId: latestAndroidRun.id, - created_at: latestAndroidRun.created_at + name: appsWorkflowRun.name, + status: appsWorkflowRun.status, + conclusion: appsWorkflowRun.conclusion, + url: appsWorkflowRun.html_url, + runId: appsWorkflowRun.id, + created_at: appsWorkflowRun.created_at }; // Collect artifacts if completed successfully - if (latestAndroidRun.conclusion === 'success') { + if (appsWorkflowRun.conclusion === 'success') { try { const { data: artifacts } = await github.rest.actions.listWorkflowRunArtifacts({ owner: context.repo.owner, repo: context.repo.repo, - run_id: latestAndroidRun.id + run_id: appsWorkflowRun.id }); allArtifacts.push(...artifacts.artifacts); } catch (error) { - console.log(`Failed to get Android artifacts for run ${latestAndroidRun.id}:`, error.message); + console.log(`Failed to get apps artifacts for run ${appsWorkflowRun.id}:`, error.message); } } } - if (latestIOSRun) { + // For iOS, use the same workflow run (since it's all in one now) or fallback to separate + const iosWorkflowRun = latestAppsRun || latestIOSRun; + if (iosWorkflowRun) { buildStatuses['iOS'] = { - name: latestIOSRun.name, - status: latestIOSRun.status, - conclusion: latestIOSRun.conclusion, - url: latestIOSRun.html_url, - runId: latestIOSRun.id, - created_at: latestIOSRun.created_at + name: iosWorkflowRun.name, + status: iosWorkflowRun.status, + conclusion: iosWorkflowRun.conclusion, + url: iosWorkflowRun.html_url, + runId: iosWorkflowRun.id, + created_at: iosWorkflowRun.created_at }; - // Collect artifacts if completed successfully - if (latestIOSRun.conclusion === 'success') { + // Only collect artifacts if not already collected from apps workflow + if (!latestAppsRun && iosWorkflowRun.conclusion === 'success') { try { const { data: artifacts } = await github.rest.actions.listWorkflowRunArtifacts({ owner: context.repo.owner, repo: context.repo.repo, - run_id: latestIOSRun.id + run_id: iosWorkflowRun.id }); allArtifacts.push(...artifacts.artifacts); } catch (error) { - console.log(`Failed to get iOS artifacts for run ${latestIOSRun.id}:`, error.message); + console.log(`Failed to get iOS artifacts for run ${iosWorkflowRun.id}:`, error.message); } } } diff --git a/.github/workflows/build-android.yml b/.github/workflows/build-android.yml deleted file mode 100644 index bc9b9ea1..00000000 --- a/.github/workflows/build-android.yml +++ /dev/null @@ -1,93 +0,0 @@ -name: ๐Ÿค– Android APK Build (Phone + TV) - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -on: - workflow_dispatch: - pull_request: - branches: [develop, master] - push: - branches: [develop, master] - -jobs: - build-android: - if: (!contains(github.event.head_commit.message, '[skip ci]')) - runs-on: ubuntu-24.04 - name: ๐Ÿ—๏ธ Build Android APK - permissions: - contents: read - - strategy: - fail-fast: false - matrix: - target: [phone, tv] - - steps: - - name: ๐Ÿ“ฅ Checkout code - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - with: - ref: ${{ github.event.pull_request.head.sha || github.sha }} - fetch-depth: 0 - submodules: recursive - show-progress: false - - - name: ๐Ÿž Setup Bun - uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2 - with: - bun-version: latest - - - name: ๐Ÿ’พ Cache Bun dependencies - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 - with: - path: ~/.bun/install/cache - key: ${{ runner.os }}-${{ runner.arch }}-bun-develop-${{ hashFiles('bun.lock') }} - restore-keys: | - ${{ runner.os }}-${{ runner.arch }}-bun-develop - ${{ runner.os }}-bun-develop - - - name: ๐Ÿ“ฆ Install dependencies and reload submodules - run: | - bun install --frozen-lockfile - bun run submodule-reload - - - name: ๐Ÿ’พ Cache Gradle global - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 - with: - path: | - ~/.gradle/caches - ~/.gradle/wrapper - key: ${{ runner.os }}-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }} - restore-keys: ${{ runner.os }}-gradle-develop - - - name: ๐Ÿ› ๏ธ Generate project files - run: | - if [ "${{ matrix.target }}" = "tv" ]; then - bun run prebuild:tv - else - bun run prebuild - fi - - - name: ๐Ÿ’พ Cache project Gradle (.gradle) - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 - with: - path: android/.gradle - key: ${{ runner.os }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }} - restore-keys: ${{ runner.os }}-android-gradle-develop - - - name: ๐Ÿš€ Build APK - env: - EXPO_TV: ${{ matrix.target == 'tv' && 1 || 0 }} - run: bun run build:android:local - - - name: ๐Ÿ“… Set date tag - run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV - - - name: ๐Ÿ“ค Upload APK artifact - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 - with: - name: streamyfin-android-${{ matrix.target }}-apk-${{ env.DATE_TAG }} - path: | - android/app/build/outputs/apk/release/*.apk - retention-days: 7 diff --git a/.github/workflows/build-apps.yml b/.github/workflows/build-apps.yml new file mode 100644 index 00000000..ad770414 --- /dev/null +++ b/.github/workflows/build-apps.yml @@ -0,0 +1,280 @@ +name: ๐Ÿ—๏ธ Build Apps + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +on: + workflow_dispatch: + pull_request: + branches: [develop, master] + push: + branches: [develop, master] + +jobs: + build-android-phone: + if: (!contains(github.event.head_commit.message, '[skip ci]')) + runs-on: ubuntu-24.04 + name: ๐Ÿค– Build Android APK (Phone) + permissions: + contents: read + + steps: + - name: ๐Ÿ“ฅ Checkout code + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + fetch-depth: 0 + submodules: recursive + show-progress: false + + - name: ๐Ÿž Setup Bun + uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2 + with: + bun-version: latest + + - name: ๐Ÿ’พ Cache Bun dependencies + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + with: + path: ~/.bun/install/cache + key: ${{ runner.os }}-${{ runner.arch }}-bun-develop-${{ hashFiles('bun.lock') }} + restore-keys: | + ${{ runner.os }}-${{ runner.arch }}-bun-develop + ${{ runner.os }}-bun-develop + + - name: ๐Ÿ“ฆ Install dependencies and reload submodules + run: | + bun install --frozen-lockfile + bun run submodule-reload + + - name: ๐Ÿ’พ Cache Gradle global + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-gradle- + + - name: ๐Ÿ› ๏ธ Generate project files + run: bun run prebuild + + - name: ๐Ÿ’พ Cache project Gradle (.gradle) + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + with: + path: android/.gradle + key: ${{ runner.os }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }} + restore-keys: ${{ runner.os }}-android-gradle-develop + + - name: ๐Ÿš€ Build APK + env: + EXPO_TV: 0 + run: bun run build:android:local + + - name: ๐Ÿ“… Set date tag + run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV + + - name: ๐Ÿ“ค Upload APK artifact + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: streamyfin-android-phone-apk-${{ env.DATE_TAG }} + path: | + android/app/build/outputs/apk/release/*.apk + retention-days: 7 + + build-android-tv: + if: (!contains(github.event.head_commit.message, '[skip ci]')) + runs-on: ubuntu-24.04 + name: ๐Ÿค– Build Android APK (TV) + permissions: + contents: read + + steps: + - name: ๐Ÿ“ฅ Checkout code + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + fetch-depth: 0 + submodules: recursive + show-progress: false + + - name: ๐Ÿž Setup Bun + uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2 + with: + bun-version: latest + + - name: ๐Ÿ’พ Cache Bun dependencies + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + with: + path: ~/.bun/install/cache + key: ${{ runner.os }}-${{ runner.arch }}-bun-develop-${{ hashFiles('bun.lock') }} + restore-keys: | + ${{ runner.os }}-${{ runner.arch }}-bun-develop + ${{ runner.os }}-bun-develop + + - name: ๐Ÿ“ฆ Install dependencies and reload submodules + run: | + bun install --frozen-lockfile + bun run submodule-reload + + - name: ๐Ÿ’พ Cache Gradle global + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-gradle- + + - name: ๐Ÿ› ๏ธ Generate project files + run: bun run prebuild:tv + + - name: ๐Ÿ’พ Cache project Gradle (.gradle) + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + with: + path: android/.gradle + key: ${{ runner.os }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }} + restore-keys: ${{ runner.os }}-android-gradle-develop + + - name: ๐Ÿš€ Build APK + env: + EXPO_TV: 1 + run: bun run build:android:local + + - name: ๐Ÿ“… Set date tag + run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV + + - name: ๐Ÿ“ค Upload APK artifact + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: streamyfin-android-tv-apk-${{ env.DATE_TAG }} + path: | + android/app/build/outputs/apk/release/*.apk + retention-days: 7 + + build-ios-phone: + if: (!contains(github.event.head_commit.message, '[skip ci]') && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'streamyfin/streamyfin')) + runs-on: macos-15 + name: ๐ŸŽ Build iOS IPA (Phone) + permissions: + contents: read + + steps: + - name: ๐Ÿ“ฅ Checkout code + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + fetch-depth: 0 + submodules: recursive + show-progress: false + + - name: ๐Ÿž Setup Bun + uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2 + with: + bun-version: latest + + - name: ๐Ÿ’พ Cache Bun dependencies + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + with: + path: ~/.bun/install/cache + key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }} + restore-keys: | + ${{ runner.os }}-bun-cache + + - name: ๐Ÿ“ฆ Install dependencies and reload submodules + run: | + bun install --frozen-lockfile + bun run submodule-reload + + - name: ๐Ÿ› ๏ธ Generate project files + run: bun run prebuild + + - name: ๐Ÿ—๏ธ Setup EAS + uses: expo/expo-github-action@main + with: + eas-version: latest + token: ${{ secrets.EXPO_TOKEN }} + eas-cache: true + + - name: โš™๏ธ Ensure iOS SDKs installed + run: xcodebuild -downloadPlatform iOS + + - name: ๐Ÿš€ Build iOS app + env: + EXPO_TV: 0 + 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 + + - name: ๐Ÿ“ค Upload IPA artifact + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: streamyfin-ios-phone-ipa-${{ env.DATE_TAG }} + path: build-*.ipa + retention-days: 7 + + # Disabled for now - uncomment when ready to build iOS TV + # build-ios-tv: + # if: (!contains(github.event.head_commit.message, '[skip ci]') && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'streamyfin/streamyfin')) + # runs-on: macos-15 + # name: ๐ŸŽ Build iOS IPA (TV) + # permissions: + # contents: read + # + # steps: + # - name: ๐Ÿ“ฅ Checkout code + # uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + # with: + # ref: ${{ github.event.pull_request.head.sha || github.sha }} + # fetch-depth: 0 + # submodules: recursive + # show-progress: false + # + # - name: ๐Ÿž Setup Bun + # uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2 + # with: + # bun-version: latest + # + # - name: ๐Ÿ’พ Cache Bun dependencies + # uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + # with: + # path: ~/.bun/install/cache + # key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }} + # restore-keys: | + # ${{ runner.os }}-bun-cache + # + # - name: ๐Ÿ“ฆ Install dependencies and reload submodules + # run: | + # bun install --frozen-lockfile + # bun run submodule-reload + # + # - name: ๐Ÿ› ๏ธ Generate project files + # run: bun run prebuild:tv + # + # - name: ๐Ÿ—๏ธ Setup EAS + # uses: expo/expo-github-action@main + # with: + # eas-version: latest + # token: ${{ secrets.EXPO_TOKEN }} + # eas-cache: true + # + # - name: โš™๏ธ Ensure tvOS SDKs installed + # run: xcodebuild -downloadPlatform tvOS + # + # - name: ๐Ÿš€ Build iOS app + # env: + # EXPO_TV: 1 + # 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 + # + # - name: ๐Ÿ“ค Upload IPA artifact + # uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + # with: + # name: streamyfin-ios-tv-ipa-${{ env.DATE_TAG }} + # path: build-*.ipa + # retention-days: 7 diff --git a/.github/workflows/build-ios.yml b/.github/workflows/build-ios.yml deleted file mode 100644 index 76e4a4d2..00000000 --- a/.github/workflows/build-ios.yml +++ /dev/null @@ -1,95 +0,0 @@ -name: ๐Ÿค– iOS IPA Build (Phone + TV) - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -on: - workflow_dispatch: - pull_request: - branches: [develop, master] - paths-ignore: - - "*.md" - push: - branches: [develop, master] - paths-ignore: - - "*.md" - -jobs: - build-ios: - if: (!contains(github.event.head_commit.message, '[skip ci]') && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'streamyfin/streamyfin')) - runs-on: macos-15 - name: ๐Ÿ—๏ธ Build iOS IPA - permissions: - contents: read - - strategy: - fail-fast: false - matrix: - target: [phone] - # target: [phone, tv] - - steps: - - name: ๐Ÿ“ฅ Checkout code - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - with: - ref: ${{ github.event.pull_request.head.sha || github.sha }} - fetch-depth: 0 - submodules: recursive - show-progress: false - - - name: ๐Ÿž Setup Bun - uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2 - with: - bun-version: latest - - - name: ๐Ÿ’พ Cache Bun dependencies - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 - with: - path: ~/.bun/install/cache - key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }} - restore-keys: | - ${{ runner.os }}-bun-cache - - - name: ๐Ÿ“ฆ Install dependencies and reload submodules - run: | - bun install --frozen-lockfile - bun run submodule-reload - - - name: ๐Ÿ› ๏ธ Generate project files - run: | - if [ "${{ matrix.target }}" = "tv" ]; then - bun run prebuild:tv - else - bun run prebuild - fi - - - name: ๐Ÿ—๏ธ Setup EAS - uses: expo/expo-github-action@main - with: - eas-version: latest - token: ${{ secrets.EXPO_TOKEN }} - eas-cache: true - - - name: โš™๏ธ Ensure iOS/tvOS SDKs installed - run: | - if [ "${{ matrix.target }}" = "tv" ]; then - xcodebuild -downloadPlatform tvOS - else - xcodebuild -downloadPlatform iOS - fi - - - name: ๐Ÿš€ Build iOS app - env: - EXPO_TV: ${{ matrix.target == 'tv' && 1 || 0 }} - 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 - - - name: ๐Ÿ“ค Upload IPA artifact - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 - with: - name: streamyfin-ios-${{ matrix.target }}-ipa-${{ env.DATE_TAG }} - path: build-*.ipa - retention-days: 7 From 312a59c5b0a1369b7aa27374284d2cdf9a3fa0a9 Mon Sep 17 00:00:00 2001 From: Uruk Date: Tue, 30 Sep 2025 01:42:17 +0200 Subject: [PATCH 21/25] refactor(ci): improve build workflow handling and artifact collection logic Restructures the GitHub Actions workflow to better handle consolidated vs separate build workflows. Changes the artifact collection to trigger on workflow completion rather than just success, improving visibility of failed builds. Adds explicit fallback logic for backward compatibility with separate Android and iOS workflows. Introduces artifact pattern matching for more reliable build target identification and adds special handling to disable iOS TV builds. Enhances debugging output to show which workflow type is being used and lists all discovered artifacts. --- .github/workflows/artifact-comment.yml | 133 +++++++++++++++++-------- 1 file changed, 90 insertions(+), 43 deletions(-) diff --git a/.github/workflows/artifact-comment.yml b/.github/workflows/artifact-comment.yml index 08710fe8..0e9eea6c 100644 --- a/.github/workflows/artifact-comment.yml +++ b/.github/workflows/artifact-comment.yml @@ -137,62 +137,105 @@ jobs: const latestAndroidRun = buildRuns.find(run => run.name.includes('Android APK Build')); const latestIOSRun = buildRuns.find(run => run.name.includes('iOS IPA Build')); - // Store status for unified workflow (preferred) or fallback to separate workflows - const appsWorkflowRun = latestAppsRun || latestAndroidRun; - if (appsWorkflowRun) { + // For the consolidated workflow, both Android and iOS share the same run + if (latestAppsRun) { + // Both platforms use same workflow run status buildStatuses['Android'] = { - name: appsWorkflowRun.name, - status: appsWorkflowRun.status, - conclusion: appsWorkflowRun.conclusion, - url: appsWorkflowRun.html_url, - runId: appsWorkflowRun.id, - created_at: appsWorkflowRun.created_at + name: latestAppsRun.name, + status: latestAppsRun.status, + conclusion: latestAppsRun.conclusion, + url: latestAppsRun.html_url, + runId: latestAppsRun.id, + created_at: latestAppsRun.created_at }; - // Collect artifacts if completed successfully - if (appsWorkflowRun.conclusion === 'success') { + buildStatuses['iOS'] = { + name: latestAppsRun.name, + status: latestAppsRun.status, + conclusion: latestAppsRun.conclusion, + url: latestAppsRun.html_url, + runId: latestAppsRun.id, + created_at: latestAppsRun.created_at + }; + + // Collect artifacts if workflow has completed (regardless of success/failure) + if (latestAppsRun.status === 'completed') { try { const { data: artifacts } = await github.rest.actions.listWorkflowRunArtifacts({ owner: context.repo.owner, repo: context.repo.repo, - run_id: appsWorkflowRun.id + run_id: latestAppsRun.id }); allArtifacts.push(...artifacts.artifacts); } catch (error) { - console.log(`Failed to get apps artifacts for run ${appsWorkflowRun.id}:`, error.message); + console.log(`Failed to get apps artifacts for run ${latestAppsRun.id}:`, error.message); + } + } + } else { + // Fallback to separate workflows (for backward compatibility) + if (latestAndroidRun) { + buildStatuses['Android'] = { + name: latestAndroidRun.name, + status: latestAndroidRun.status, + conclusion: latestAndroidRun.conclusion, + url: latestAndroidRun.html_url, + runId: latestAndroidRun.id, + created_at: latestAndroidRun.created_at + }; + + if (latestAndroidRun.conclusion === 'success') { + try { + const { data: artifacts } = await github.rest.actions.listWorkflowRunArtifacts({ + owner: context.repo.owner, + repo: context.repo.repo, + run_id: latestAndroidRun.id + }); + allArtifacts.push(...artifacts.artifacts); + } catch (error) { + console.log(`Failed to get Android artifacts for run ${latestAndroidRun.id}:`, error.message); + } } } - } - - // For iOS, use the same workflow run (since it's all in one now) or fallback to separate - const iosWorkflowRun = latestAppsRun || latestIOSRun; - if (iosWorkflowRun) { - buildStatuses['iOS'] = { - name: iosWorkflowRun.name, - status: iosWorkflowRun.status, - conclusion: iosWorkflowRun.conclusion, - url: iosWorkflowRun.html_url, - runId: iosWorkflowRun.id, - created_at: iosWorkflowRun.created_at - }; - // Only collect artifacts if not already collected from apps workflow - if (!latestAppsRun && iosWorkflowRun.conclusion === 'success') { - try { - const { data: artifacts } = await github.rest.actions.listWorkflowRunArtifacts({ - owner: context.repo.owner, - repo: context.repo.repo, - run_id: iosWorkflowRun.id - }); - allArtifacts.push(...artifacts.artifacts); - } catch (error) { - console.log(`Failed to get iOS artifacts for run ${iosWorkflowRun.id}:`, error.message); + if (latestIOSRun) { + buildStatuses['iOS'] = { + name: latestIOSRun.name, + status: latestIOSRun.status, + conclusion: latestIOSRun.conclusion, + url: latestIOSRun.html_url, + runId: latestIOSRun.id, + created_at: latestIOSRun.created_at + }; + + if (latestIOSRun.conclusion === 'success') { + try { + const { data: artifacts } = await github.rest.actions.listWorkflowRunArtifacts({ + owner: context.repo.owner, + repo: context.repo.repo, + run_id: latestIOSRun.id + }); + allArtifacts.push(...artifacts.artifacts); + } catch (error) { + console.log(`Failed to get iOS artifacts for run ${latestIOSRun.id}:`, error.message); + } } } } console.log(`Collected ${allArtifacts.length} total artifacts from all builds`); + // Debug: Show which workflow we're using and its status + if (latestAppsRun) { + console.log(`Using consolidated workflow: ${latestAppsRun.name} (${latestAppsRun.status}/${latestAppsRun.conclusion})`); + } else { + console.log(`Using separate workflows - Android: ${latestAndroidRun?.name || 'none'}, iOS: ${latestIOSRun?.name || 'none'}`); + } + + // Debug: List all artifacts found + allArtifacts.forEach(artifact => { + console.log(`- Artifact: ${artifact.name} (from run ${artifact.workflow_run.id})`); + }); + // Build comment body with progressive status for individual builds let commentBody = `## ๐Ÿ”ง Build Status for PR #${pr.number}\n\n`; commentBody += `๐Ÿ”— **Commit**: [\`${targetCommitSha.substring(0, 7)}\`](https://github.com/${context.repo.owner}/${context.repo.repo}/commit/${targetCommitSha})\n\n`; // Progressive build status and downloads table @@ -202,10 +245,10 @@ jobs: // Process each expected build target individually const buildTargets = [ - { name: 'Android Phone', platform: '๐Ÿค–', device: '๐Ÿ“ฑ', workflowType: 'Android', target: 'phone' }, - { name: 'Android TV', platform: '๐Ÿค–', device: '๐Ÿ“บ', workflowType: 'Android', target: 'tv' }, - { name: 'iOS Phone', platform: '๐ŸŽ', device: '๐Ÿ“ฑ', workflowType: 'iOS', target: 'phone' }, - { name: 'iOS TV', platform: '๐ŸŽ', device: '๐Ÿ“บ', workflowType: 'iOS', target: 'tv' } + { name: 'Android Phone', platform: '๐Ÿค–', device: '๐Ÿ“ฑ', workflowType: 'Android', target: 'phone', artifactPattern: /android.*phone/i }, + { name: 'Android TV', platform: '๐Ÿค–', device: '๐Ÿ“บ', workflowType: 'Android', target: 'tv', artifactPattern: /android.*tv/i }, + { name: 'iOS Phone', platform: '๐ŸŽ', device: '๐Ÿ“ฑ', workflowType: 'iOS', target: 'phone', artifactPattern: /ios.*phone/i }, + { name: 'iOS TV', platform: '๐ŸŽ', device: '๐Ÿ“บ', workflowType: 'iOS', target: 'tv', artifactPattern: /ios.*tv/i } ]; for (const target of buildTargets) { @@ -214,13 +257,17 @@ jobs: // Find matching artifact const matchingArtifact = allArtifacts.find(artifact => - target.pattern.test(artifact.name) + target.artifactPattern.test(artifact.name) ); let status = 'โณ Pending'; let downloadLink = '*Waiting for build...*'; - if (matchingStatus) { + // Special case for iOS TV - show as disabled + if (target.name === 'iOS TV') { + status = '๐Ÿ’ค Disabled'; + downloadLink = '*Disabled for now*'; + } else if (matchingStatus) { if (matchingStatus.conclusion === 'success' && matchingArtifact) { status = 'โœ… Complete'; const nightlyLink = `https://nightly.link/${context.repo.owner}/${context.repo.repo}/actions/runs/${matchingArtifact.workflow_run.id}/${matchingArtifact.name}.zip`; From 0a41962ddfd0f9c4f2553ba4b906febe28bbf90f Mon Sep 17 00:00:00 2001 From: Uruk Date: Tue, 30 Sep 2025 01:49:38 +0200 Subject: [PATCH 22/25] fix: improve build status handling and artifact links Replaces nightly.link with direct GitHub artifact URLs for better reliability. Adds handling for cancelled builds and edge cases where workflows complete but artifacts aren't immediately available or conclusions are pending. Improves status messages to provide more detailed information for unexpected build states. --- .github/workflows/artifact-comment.yml | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/.github/workflows/artifact-comment.yml b/.github/workflows/artifact-comment.yml index 0e9eea6c..61a94b0a 100644 --- a/.github/workflows/artifact-comment.yml +++ b/.github/workflows/artifact-comment.yml @@ -270,22 +270,33 @@ jobs: } else if (matchingStatus) { if (matchingStatus.conclusion === 'success' && matchingArtifact) { status = 'โœ… Complete'; - const nightlyLink = `https://nightly.link/${context.repo.owner}/${context.repo.repo}/actions/runs/${matchingArtifact.workflow_run.id}/${matchingArtifact.name}.zip`; + const directLink = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${matchingArtifact.workflow_run.id}/artifacts/${matchingArtifact.id}`; const fileType = target.name.includes('Android') ? 'APK' : 'IPA'; - downloadLink = `[๐Ÿ“ฅ Download ${fileType}](${nightlyLink})`; + downloadLink = `[๐Ÿ“ฅ Download ${fileType}](${directLink})`; } else if (matchingStatus.conclusion === 'failure') { status = `โŒ [Failed](${matchingStatus.url})`; downloadLink = '*Build failed*'; + } else if (matchingStatus.conclusion === 'cancelled') { + status = `โšช [Cancelled](${matchingStatus.url})`; + downloadLink = '*Build cancelled*'; } else if (matchingStatus.status === 'in_progress') { status = `๐Ÿ”„ [Building...](${matchingStatus.url})`; downloadLink = '*Build in progress...*'; } else if (matchingStatus.status === 'queued') { status = `โณ [Queued](${matchingStatus.url})`; downloadLink = '*Waiting to start...*'; + } else if (matchingStatus.status === 'completed' && !matchingStatus.conclusion) { + // Workflow completed but conclusion not yet available (rare edge case) + status = `๐Ÿ”„ [Finishing...](${matchingStatus.url})`; + downloadLink = '*Finalizing build...*'; + } else if (matchingStatus.status === 'completed' && matchingStatus.conclusion === 'success' && !matchingArtifact) { + // Build succeeded but artifacts not yet available + status = `โณ [Processing artifacts...](${matchingStatus.url})`; + downloadLink = '*Preparing download...*'; } else { - // Show any other status with timestamp for debugging - status = `๐Ÿ”„ [${matchingStatus.status}](${matchingStatus.url})`; - downloadLink = `*Status: ${matchingStatus.status}*`; + // Fallback for any unexpected states + status = `โ“ [${matchingStatus.status}/${matchingStatus.conclusion || 'pending'}](${matchingStatus.url})`; + downloadLink = `*Status: ${matchingStatus.status}, Conclusion: ${matchingStatus.conclusion || 'pending'}*`; } } From 71a3c5e92bfbea1e84cf8c901544a0a125b3995f Mon Sep 17 00:00:00 2001 From: Uruk Date: Tue, 30 Sep 2025 01:57:18 +0200 Subject: [PATCH 23/25] feat: improve GitHub workflow status tracking Enhances artifact comment workflow to provide more accurate build status reporting by tracking individual job statuses within consolidated workflows rather than using workflow-level status. Excludes cancelled workflow runs from consideration and prioritizes active runs over completed ones when determining build status. Maps specific job names to build targets (Android Phone, Android TV, iOS Phone) to provide granular status information for each platform and device combination. Improves artifact collection logic to gather artifacts when any job completes successfully, not just when entire workflow completes. --- .github/workflows/artifact-comment.yml | 129 ++++++++++++++++++------- 1 file changed, 94 insertions(+), 35 deletions(-) diff --git a/.github/workflows/artifact-comment.yml b/.github/workflows/artifact-comment.yml index 61a94b0a..0a8cc576 100644 --- a/.github/workflows/artifact-comment.yml +++ b/.github/workflows/artifact-comment.yml @@ -112,54 +112,113 @@ jobs: per_page: 30 }); - // Filter for build workflows only and sort by creation time (most recent first) + // Filter for build workflows only, exclude cancelled runs, and sort by creation time (most recent first) const buildRuns = workflowRuns.workflow_runs .filter(run => - run.name.includes('Build Apps') || + (run.name.includes('Build Apps') || run.name.includes('Android APK Build') || - run.name.includes('iOS IPA Build') + run.name.includes('iOS IPA Build')) && + run.conclusion !== 'cancelled' // Ignore cancelled runs ) .sort((a, b) => new Date(b.created_at) - new Date(a.created_at)); - console.log(`Found ${buildRuns.length} build workflow runs for this commit`); + console.log(`Found ${buildRuns.length} non-cancelled build workflow runs for this commit`); // Log current status of each build for debugging buildRuns.forEach(run => { console.log(`- ${run.name}: ${run.status} (${run.conclusion || 'no conclusion yet'}) - Created: ${run.created_at}`); }); - // Collect artifacts and statuses from builds - get most recent run for each workflow type + // Collect artifacts and statuses from builds - prioritize active runs over completed ones let allArtifacts = []; let buildStatuses = {}; - // Get the most recent run for the unified apps workflow - const latestAppsRun = buildRuns.find(run => run.name.includes('Build Apps')); - const latestAndroidRun = buildRuns.find(run => run.name.includes('Android APK Build')); - const latestIOSRun = buildRuns.find(run => run.name.includes('iOS IPA Build')); + // Get the most relevant run for each workflow type (prioritize in_progress over completed) + const findBestRun = (nameFilter) => { + const matchingRuns = buildRuns.filter(run => run.name.includes(nameFilter)); + // First try to find an in-progress run + const inProgressRun = matchingRuns.find(run => run.status === 'in_progress'); + if (inProgressRun) return inProgressRun; + // Then try to find a queued run + const queuedRun = matchingRuns.find(run => run.status === 'queued'); + if (queuedRun) return queuedRun; + // Finally fall back to most recent completed run + return matchingRuns[0]; // Already sorted by most recent first + }; - // For the consolidated workflow, both Android and iOS share the same run + const latestAppsRun = findBestRun('Build Apps'); + const latestAndroidRun = findBestRun('Android APK Build'); + const latestIOSRun = findBestRun('iOS IPA Build'); + + // For the consolidated workflow, get individual job statuses if (latestAppsRun) { - // Both platforms use same workflow run status - buildStatuses['Android'] = { - name: latestAppsRun.name, - status: latestAppsRun.status, - conclusion: latestAppsRun.conclusion, - url: latestAppsRun.html_url, - runId: latestAppsRun.id, - created_at: latestAppsRun.created_at - }; + console.log(`Getting individual job statuses for run ${latestAppsRun.id}`); - buildStatuses['iOS'] = { - name: latestAppsRun.name, - status: latestAppsRun.status, - conclusion: latestAppsRun.conclusion, - url: latestAppsRun.html_url, - runId: latestAppsRun.id, - created_at: latestAppsRun.created_at - }; + try { + // Get all jobs for this workflow run + const { data: jobs } = await github.rest.actions.listJobsForWorkflowRun({ + owner: context.repo.owner, + repo: context.repo.repo, + run_id: latestAppsRun.id + }); + + console.log(`Found ${jobs.jobs.length} jobs in workflow run`); + jobs.jobs.forEach(job => { + console.log(`- Job: ${job.name} | Status: ${job.status} | Conclusion: ${job.conclusion || 'none'}`); + }); + + // Map job names to our build targets + const jobMappings = { + 'Android Phone': ['๐Ÿค– Build Android APK (Phone)', 'build-android-phone'], + 'Android TV': ['๐Ÿค– Build Android APK (TV)', 'build-android-tv'], + 'iOS Phone': ['๐ŸŽ Build iOS IPA (Phone)', 'build-ios-phone'] + }; + + // Create individual status for each job + for (const [platform, jobNames] of Object.entries(jobMappings)) { + const job = jobs.jobs.find(j => + jobNames.some(name => j.name.includes(name) || j.name === name) + ); + + if (job) { + buildStatuses[platform] = { + name: job.name, + status: job.status, + conclusion: job.conclusion, + url: job.html_url, + runId: latestAppsRun.id, + created_at: job.started_at || latestAppsRun.created_at + }; + console.log(`Mapped ${platform} to job: ${job.name} (${job.status}/${job.conclusion || 'none'})`); + } else { + console.log(`No job found for ${platform}, using workflow status as fallback`); + buildStatuses[platform] = { + name: latestAppsRun.name, + status: latestAppsRun.status, + conclusion: latestAppsRun.conclusion, + url: latestAppsRun.html_url, + runId: latestAppsRun.id, + created_at: latestAppsRun.created_at + }; + } + } + + } catch (error) { + console.log(`Failed to get jobs for run ${latestAppsRun.id}:`, error.message); + // Fallback to workflow-level status + buildStatuses['Android Phone'] = buildStatuses['Android TV'] = buildStatuses['iOS Phone'] = { + name: latestAppsRun.name, + status: latestAppsRun.status, + conclusion: latestAppsRun.conclusion, + url: latestAppsRun.html_url, + runId: latestAppsRun.id, + created_at: latestAppsRun.created_at + }; + } - // Collect artifacts if workflow has completed (regardless of success/failure) - if (latestAppsRun.status === 'completed') { + // Collect artifacts if any job has completed successfully + if (latestAppsRun.status === 'completed' || + Object.values(buildStatuses).some(status => status.conclusion === 'success')) { try { const { data: artifacts } = await github.rest.actions.listWorkflowRunArtifacts({ owner: context.repo.owner, @@ -245,15 +304,15 @@ jobs: // Process each expected build target individually const buildTargets = [ - { name: 'Android Phone', platform: '๐Ÿค–', device: '๐Ÿ“ฑ', workflowType: 'Android', target: 'phone', artifactPattern: /android.*phone/i }, - { name: 'Android TV', platform: '๐Ÿค–', device: '๐Ÿ“บ', workflowType: 'Android', target: 'tv', artifactPattern: /android.*tv/i }, - { name: 'iOS Phone', platform: '๐ŸŽ', device: '๐Ÿ“ฑ', workflowType: 'iOS', target: 'phone', artifactPattern: /ios.*phone/i }, - { name: 'iOS TV', platform: '๐ŸŽ', device: '๐Ÿ“บ', workflowType: 'iOS', target: 'tv', artifactPattern: /ios.*tv/i } + { name: 'Android Phone', platform: '๐Ÿค–', device: '๐Ÿ“ฑ', statusKey: 'Android Phone', artifactPattern: /android.*phone/i }, + { name: 'Android TV', platform: '๐Ÿค–', device: '๐Ÿ“บ', statusKey: 'Android TV', artifactPattern: /android.*tv/i }, + { name: 'iOS Phone', platform: '๐ŸŽ', device: '๐Ÿ“ฑ', statusKey: 'iOS Phone', artifactPattern: /ios.*phone/i }, + { name: 'iOS TV', platform: '๐ŸŽ', device: '๐Ÿ“บ', statusKey: 'iOS TV', artifactPattern: /ios.*tv/i } ]; for (const target of buildTargets) { - // Find matching workflow status - const matchingStatus = buildStatuses[target.workflowType]; + // Find matching job status directly + const matchingStatus = buildStatuses[target.statusKey]; // Find matching artifact const matchingArtifact = allArtifacts.find(artifact => From c6ad06b0843fb90d7e06f3c443aee0056888fbc3 Mon Sep 17 00:00:00 2001 From: Uruk Date: Tue, 30 Sep 2025 02:15:25 +0200 Subject: [PATCH 24/25] fix: improve workflow run detection for cancelled jobs Enhances the artifact comment workflow to better handle cancelled workflow runs by checking individual job statuses instead of dismissing entire workflows. Previously, workflows marked as cancelled at the top level were completely ignored, even if some jobs within them were still running or completed successfully. Now prioritizes active jobs over cancelled ones and validates that at least one job is actually running before processing a workflow run, preventing false negatives while still filtering out truly cancelled workflows. --- .github/workflows/artifact-comment.yml | 38 ++++++++++++++++++++++---- 1 file changed, 32 insertions(+), 6 deletions(-) diff --git a/.github/workflows/artifact-comment.yml b/.github/workflows/artifact-comment.yml index 0a8cc576..d032dd67 100644 --- a/.github/workflows/artifact-comment.yml +++ b/.github/workflows/artifact-comment.yml @@ -112,13 +112,12 @@ jobs: per_page: 30 }); - // Filter for build workflows only, exclude cancelled runs, and sort by creation time (most recent first) + // Filter for build workflows only, include active runs even if marked as cancelled const buildRuns = workflowRuns.workflow_runs .filter(run => (run.name.includes('Build Apps') || run.name.includes('Android APK Build') || - run.name.includes('iOS IPA Build')) && - run.conclusion !== 'cancelled' // Ignore cancelled runs + run.name.includes('iOS IPA Build')) ) .sort((a, b) => new Date(b.created_at) - new Date(a.created_at)); @@ -133,16 +132,28 @@ jobs: let allArtifacts = []; let buildStatuses = {}; - // Get the most relevant run for each workflow type (prioritize in_progress over completed) + // Get the most relevant run for each workflow type (prioritize active over cancelled) const findBestRun = (nameFilter) => { const matchingRuns = buildRuns.filter(run => run.name.includes(nameFilter)); + // First try to find an in-progress run const inProgressRun = matchingRuns.find(run => run.status === 'in_progress'); if (inProgressRun) return inProgressRun; + // Then try to find a queued run const queuedRun = matchingRuns.find(run => run.status === 'queued'); if (queuedRun) return queuedRun; - // Finally fall back to most recent completed run + + // Check if the workflow is completed but has non-cancelled jobs + const completedRuns = matchingRuns.filter(run => run.status === 'completed'); + for (const run of completedRuns) { + // We'll check individual jobs later to see if they're actually running + if (run.conclusion !== 'cancelled') { + return run; + } + } + + // Finally fall back to most recent run (even if cancelled at workflow level) return matchingRuns[0]; // Already sorted by most recent first }; @@ -152,7 +163,7 @@ jobs: // For the consolidated workflow, get individual job statuses if (latestAppsRun) { - console.log(`Getting individual job statuses for run ${latestAppsRun.id}`); + console.log(`Getting individual job statuses for run ${latestAppsRun.id} (status: ${latestAppsRun.status}, conclusion: ${latestAppsRun.conclusion || 'none'})`); try { // Get all jobs for this workflow run @@ -167,6 +178,21 @@ jobs: console.log(`- Job: ${job.name} | Status: ${job.status} | Conclusion: ${job.conclusion || 'none'}`); }); + // Check if we have any actually running jobs (not cancelled) + const activeJobs = jobs.jobs.filter(job => + job.status === 'in_progress' || + job.status === 'queued' || + (job.status === 'completed' && job.conclusion !== 'cancelled') + ); + + console.log(`Found ${activeJobs.length} active (non-cancelled) jobs out of ${jobs.jobs.length} total jobs`); + + // If no jobs are actually running, skip this workflow + if (activeJobs.length === 0 && latestAppsRun.conclusion === 'cancelled') { + console.log('All jobs are cancelled, skipping this workflow run'); + return; // Exit early + } + // Map job names to our build targets const jobMappings = { 'Android Phone': ['๐Ÿค– Build Android APK (Phone)', 'build-android-phone'], From 48cb0b70139de20906293ced4c51f561a62dbcde Mon Sep 17 00:00:00 2001 From: Uruk Date: Tue, 30 Sep 2025 12:17:38 +0200 Subject: [PATCH 25/25] fix: prevent permission errors when workflow runs from forks Adds fork detection to skip comment operations when running from external repositories, preventing 403 permission errors. Implements early exit when pull request or workflow run originates from a fork, and wraps comment operations in try-catch to handle remaining permission issues gracefully by logging build status instead. --- .github/workflows/artifact-comment.yml | 84 +++++++++++++++++--------- 1 file changed, 57 insertions(+), 27 deletions(-) diff --git a/.github/workflows/artifact-comment.yml b/.github/workflows/artifact-comment.yml index d032dd67..e6d902b1 100644 --- a/.github/workflows/artifact-comment.yml +++ b/.github/workflows/artifact-comment.yml @@ -29,6 +29,17 @@ jobs: uses: actions/github-script@v8 with: script: | + // Check if we're running from a fork + const isFromFork = context.payload.pull_request?.head?.repo?.full_name !== context.repo.owner + '/' + context.repo.repo; + const workflowFromFork = context.payload.workflow_run?.head_repository?.full_name !== context.repo.owner + '/' + context.repo.repo; + + if (isFromFork || workflowFromFork) { + console.log('๐Ÿšซ Workflow running from fork - skipping comment creation to avoid permission errors'); + console.log('Fork repository:', context.payload.pull_request?.head?.repo?.full_name || context.payload.workflow_run?.head_repository?.full_name); + console.log('Target repository:', context.repo.owner + '/' + context.repo.repo); + return; + } + // Handle repository_dispatch, pull_request, and manual dispatch events let pr; let targetCommitSha; @@ -403,34 +414,53 @@ jobs: commentBody += `*Auto-generated by [GitHub Actions](https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId})*`; commentBody += `\n`; - // Find existing bot comment to update - const { data: comments } = await github.rest.issues.listComments({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: pr.number - }); - - const botComment = comments.find(comment => - comment.user.type === 'Bot' && - comment.body.includes('') - ); - - if (botComment) { - // Update existing comment - await github.rest.issues.updateComment({ + // Try to find existing bot comment to update (with permission check) + try { + const { data: comments } = await github.rest.issues.listComments({ owner: context.repo.owner, repo: context.repo.repo, - comment_id: botComment.id, - body: commentBody + issue_number: pr.number }); - console.log(`โœ… Updated comment ${botComment.id} on PR #${pr.number}`); - } else { - // Create new comment - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: pr.number, - body: commentBody - }); - console.log(`โœ… Created new comment on PR #${pr.number}`); + + const botComment = comments.find(comment => + comment.user.type === 'Bot' && + comment.body.includes('') + ); + + if (botComment) { + // Update existing comment + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: botComment.id, + body: commentBody + }); + console.log(`โœ… Updated comment ${botComment.id} on PR #${pr.number}`); + } else { + // Create new comment + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number, + body: commentBody + }); + console.log(`โœ… Created new comment on PR #${pr.number}`); + } + } catch (error) { + if (error.status === 403) { + console.log('๐Ÿšซ Permission denied - likely running from a fork. Skipping comment creation.'); + console.log('Error details:', error.message); + + // Log the build status instead of commenting + console.log('๐Ÿ“Š Build Status Summary:'); + for (const target of buildTargets) { + const matchingStatus = buildStatuses[target.statusKey]; + if (matchingStatus) { + console.log(`- ${target.name}: ${matchingStatus.status}/${matchingStatus.conclusion || 'none'}`); + } + } + } else { + // Re-throw other errors + throw error; + } }