Rebrand as Smithflix and add AI picks row
- Smithflix name, channel posters, splash screens and logos - 'Because you watched ...' row (the user's AI playlist) below Continue Watching - Build workflow publishing a sideloadable smithflix-roku.zip Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011TdkZRTCV92L7gbQX2Z5Ay
54
.github/workflows/smithflix-roku-build.yaml
vendored
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
name: Smithflix Roku / Build
|
||||||
|
|
||||||
|
# Builds the Smithflix Roku channel on every push and attaches a sideloadable
|
||||||
|
# smithflix-roku.zip to the "latest" release of this repo.
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [master]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v7
|
||||||
|
with:
|
||||||
|
node-version: "24"
|
||||||
|
cache: "npm"
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
- name: Zip channel
|
||||||
|
run: |
|
||||||
|
cd build/staging
|
||||||
|
zip -qr ../../smithflix-roku.zip .
|
||||||
|
cd ../..
|
||||||
|
ls -la smithflix-roku.zip
|
||||||
|
|
||||||
|
- name: Publish to the "latest" release
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
run: |
|
||||||
|
gh release delete latest --yes --cleanup-tag || true
|
||||||
|
gh release create latest smithflix-roku.zip \
|
||||||
|
--title "Smithflix Roku ${GITHUB_SHA::7}" \
|
||||||
|
--notes "Sideload on a Roku in developer mode, or package it there for a beta channel." \
|
||||||
|
--target "${GITHUB_SHA}"
|
||||||
|
|
||||||
|
- name: Keep a copy with the run
|
||||||
|
uses: actions/upload-artifact@v7
|
||||||
|
with:
|
||||||
|
name: smithflix-roku
|
||||||
|
path: smithflix-roku.zip
|
||||||
32
.github/workflows/test.yml
vendored
|
|
@ -1,32 +0,0 @@
|
||||||
name: Build
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [master]
|
|
||||||
pull_request:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
test:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v7
|
|
||||||
|
|
||||||
- name: Setup Node.js
|
|
||||||
uses: actions/setup-node@v7
|
|
||||||
with:
|
|
||||||
node-version: "24"
|
|
||||||
cache: "npm"
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: npm ci
|
|
||||||
|
|
||||||
- name: Build jellyfin-roku client project
|
|
||||||
run: npm run build
|
|
||||||
|
|
||||||
- name: Save build output as artifact
|
|
||||||
uses: actions/upload-artifact@v7
|
|
||||||
with:
|
|
||||||
name: Jellyfin-Roku-dev-${{ github.sha }}
|
|
||||||
path: ${{ github.workspace }}/build/staging
|
|
||||||
|
|
@ -52,6 +52,11 @@ sub init()
|
||||||
|
|
||||||
m.LoadMyListTask = createObject("roSGNode", "LoadItemsTask")
|
m.LoadMyListTask = createObject("roSGNode", "LoadItemsTask")
|
||||||
m.LoadMyListTask.itemsToLoad = "mylist"
|
m.LoadMyListTask.itemsToLoad = "mylist"
|
||||||
|
|
||||||
|
' Smithflix: AI picks row (title becomes the playlist name once loaded)
|
||||||
|
m.LoadAiPicksTask = createObject("roSGNode", "LoadItemsTask")
|
||||||
|
m.LoadAiPicksTask.itemsToLoad = "aipicks"
|
||||||
|
m.aiPicksTitle = "Picked for You"
|
||||||
end sub
|
end sub
|
||||||
|
|
||||||
sub loadLibraries()
|
sub loadLibraries()
|
||||||
|
|
@ -268,6 +273,8 @@ function addHomeSection(sectionType as string) as boolean
|
||||||
' Continue Watching items
|
' Continue Watching items
|
||||||
if sectionType = "resume"
|
if sectionType = "resume"
|
||||||
createContinueWatchingRow()
|
createContinueWatchingRow()
|
||||||
|
' Smithflix: AI picks sit right below Continue Watching
|
||||||
|
createAiPicksRow()
|
||||||
return true
|
return true
|
||||||
end if
|
end if
|
||||||
|
|
||||||
|
|
@ -1011,6 +1018,64 @@ sub updateMyListItems()
|
||||||
setRowItemSize()
|
setRowItemSize()
|
||||||
end sub
|
end sub
|
||||||
|
|
||||||
|
' createAiPicksRow: Smithflix - placeholder row for the user's AI picks playlist
|
||||||
|
'
|
||||||
|
sub createAiPicksRow()
|
||||||
|
m.expectedRowCount++
|
||||||
|
|
||||||
|
if not sectionExists(m.aiPicksTitle)
|
||||||
|
aiRow = m.top.content.CreateChild("HomeRow")
|
||||||
|
aiRow.title = m.aiPicksTitle
|
||||||
|
aiRow.imageWidth = homeRowItemSizes.MOVIE_POSTER[0]
|
||||||
|
aiRow.cursorSize = homeRowItemSizes.MOVIE_POSTER
|
||||||
|
end if
|
||||||
|
|
||||||
|
m.LoadAiPicksTask.observeField("content", "updateAiPicksItems")
|
||||||
|
m.LoadAiPicksTask.control = "RUN"
|
||||||
|
end sub
|
||||||
|
|
||||||
|
' updateAiPicksItems: Smithflix - fills the AI picks row, titled with the playlist name
|
||||||
|
'
|
||||||
|
sub updateAiPicksItems()
|
||||||
|
m.processedRowCount++
|
||||||
|
itemData = m.LoadAiPicksTask.content
|
||||||
|
metadata = m.LoadAiPicksTask.metadata
|
||||||
|
m.LoadAiPicksTask.unobserveField("content")
|
||||||
|
m.LoadAiPicksTask.content = []
|
||||||
|
|
||||||
|
oldTitle = m.aiPicksTitle
|
||||||
|
|
||||||
|
if not isValidAndNotEmpty(itemData)
|
||||||
|
removeHomeSection(oldTitle)
|
||||||
|
return
|
||||||
|
end if
|
||||||
|
|
||||||
|
newTitle = oldTitle
|
||||||
|
if isValid(metadata) and isValid(metadata.title) then newTitle = metadata.title
|
||||||
|
|
||||||
|
row = CreateObject("roSGNode", "HomeRow")
|
||||||
|
row.title = newTitle
|
||||||
|
row.imageWidth = homeRowItemSizes.MOVIE_POSTER[0]
|
||||||
|
row.cursorSize = homeRowItemSizes.MOVIE_POSTER
|
||||||
|
|
||||||
|
for each item in itemData
|
||||||
|
item.usePoster = true
|
||||||
|
item.imageWidth = row.imageWidth
|
||||||
|
row.appendChild(item)
|
||||||
|
end for
|
||||||
|
|
||||||
|
m.aiPicksTitle = newTitle
|
||||||
|
|
||||||
|
if sectionExists(oldTitle)
|
||||||
|
m.top.content.replaceChild(row, getSectionIndex(oldTitle))
|
||||||
|
setRowItemSize()
|
||||||
|
return
|
||||||
|
end if
|
||||||
|
|
||||||
|
m.top.content.insertChild(row, getSectionIndex(tr("Continue Watching")) + 1)
|
||||||
|
setRowItemSize()
|
||||||
|
end sub
|
||||||
|
|
||||||
' updateContinueWatchingItems: Processes LoadContinueWatchingTask content. Removes, Creates, or Updates continue watching row as needed
|
' updateContinueWatchingItems: Processes LoadContinueWatchingTask content. Removes, Creates, or Updates continue watching row as needed
|
||||||
'
|
'
|
||||||
sub updateContinueWatchingItems()
|
sub updateContinueWatchingItems()
|
||||||
|
|
|
||||||
|
|
@ -701,6 +701,64 @@ function loadMyList() as object
|
||||||
return results
|
return results
|
||||||
end function
|
end function
|
||||||
|
|
||||||
|
' Smithflix: loads the user's "Because you watched ..." playlist (built nightly by the
|
||||||
|
' jellyfin-ai-playlists service). The playlist name is returned in m.top.metadata.title.
|
||||||
|
function loadAiPicks() as object
|
||||||
|
results = []
|
||||||
|
|
||||||
|
playlists = api.items.Get({
|
||||||
|
"userid": m.global.session.user.id,
|
||||||
|
"includeItemTypes": "Playlist",
|
||||||
|
"recursive": true,
|
||||||
|
"nameStartsWith": "Because you watched",
|
||||||
|
"sortBy": "DateCreated",
|
||||||
|
"sortOrder": "Descending",
|
||||||
|
"limit": 1
|
||||||
|
})
|
||||||
|
|
||||||
|
if not isValid(playlists) or not isValidAndNotEmpty(playlists.items) then return results
|
||||||
|
|
||||||
|
playlist = playlists.items[0]
|
||||||
|
playlistID = playlist.LookupCI("id")
|
||||||
|
if not isValid(playlistID) then return results
|
||||||
|
|
||||||
|
m.top.metadata = { title: playlist.LookupCI("name") }
|
||||||
|
|
||||||
|
pickData = api.items.Get({
|
||||||
|
UserId: m.global.session.user.id,
|
||||||
|
ImageTypeLimit: 1,
|
||||||
|
EnableImageTypes: `${ImageType.PRIMARY}, ${ImageType.BACKDROP}, ${ImageType.THUMB}`,
|
||||||
|
Limit: 30,
|
||||||
|
EnableTotalRecordCount: false,
|
||||||
|
ParentId: playlistID
|
||||||
|
})
|
||||||
|
|
||||||
|
if not isChainValid(pickData, "Items") then return results
|
||||||
|
|
||||||
|
for each item in pickData.Items
|
||||||
|
tmp = CreateObject("roSGNode", "HomeData")
|
||||||
|
tmp.posterURL = ItemImageURL(item, ImageType.PRIMARY, imageSizes.HOME_TILE)
|
||||||
|
json = {
|
||||||
|
Id: item.LookupCI("Id"),
|
||||||
|
name: item.LookupCI("name"),
|
||||||
|
Type: item.LookupCI("Type"),
|
||||||
|
SeriesName: item.LookupCI("SeriesName"),
|
||||||
|
SeriesId: item.LookupCI("SeriesId"),
|
||||||
|
ProductionYear: item.LookupCI("ProductionYear"),
|
||||||
|
Status: item.LookupCI("Status"),
|
||||||
|
EndDate: item.LookupCI("EndDate"),
|
||||||
|
OfficialRating: item.LookupCI("OfficialRating"),
|
||||||
|
CollectionType: item.LookupCI("CollectionType"),
|
||||||
|
UserData: item.LookupCI("UserData")
|
||||||
|
}
|
||||||
|
json.Append(ItemImageFields(item))
|
||||||
|
tmp.json = json
|
||||||
|
results.push(tmp)
|
||||||
|
end for
|
||||||
|
|
||||||
|
return results
|
||||||
|
end function
|
||||||
|
|
||||||
function loadPlaylists() as object
|
function loadPlaylists() as object
|
||||||
results = []
|
results = []
|
||||||
|
|
||||||
|
|
@ -842,6 +900,12 @@ sub loadItems()
|
||||||
return
|
return
|
||||||
end if
|
end if
|
||||||
|
|
||||||
|
' Smithflix: per-user AI picks playlist
|
||||||
|
if isStringEqual(m.top.itemsToLoad, "aipicks")
|
||||||
|
m.top.content = loadAiPicks()
|
||||||
|
return
|
||||||
|
end if
|
||||||
|
|
||||||
if isStringEqual(m.top.itemsToLoad, "playlists")
|
if isStringEqual(m.top.itemsToLoad, "playlists")
|
||||||
m.top.content = loadPlaylists()
|
m.top.content = loadPlaylists()
|
||||||
return
|
return
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 6.6 KiB After Width: | Height: | Size: 7.5 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 7.5 KiB |
|
Before Width: | Height: | Size: 4.9 KiB After Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 4 KiB |
BIN
images/logo.png
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 7.8 KiB |
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 11 KiB |
|
|
@ -561,7 +561,7 @@
|
||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
<source>Pick a Jellyfin server from the local network</source>
|
<source>Pick a Jellyfin server from the local network</source>
|
||||||
<translation>Select an available Jellyfin server from your local network:</translation>
|
<translation>Select an available Smithflix server from your local network:</translation>
|
||||||
<extracomment>Instructions on initial app launch when the user is asked to pick a server from a list</extracomment>
|
<extracomment>Instructions on initial app launch when the user is asked to pick a server from a list</extracomment>
|
||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
|
|
|
||||||
2
manifest
|
|
@ -1,6 +1,6 @@
|
||||||
## Channel Details
|
## Channel Details
|
||||||
|
|
||||||
title=jellyfin
|
title=Smithflix
|
||||||
major_version=3
|
major_version=3
|
||||||
minor_version=2
|
minor_version=2
|
||||||
build_version=5
|
build_version=5
|
||||||
|
|
|
||||||