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.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
|
||||
|
||||
sub loadLibraries()
|
||||
|
|
@ -268,6 +273,8 @@ function addHomeSection(sectionType as string) as boolean
|
|||
' Continue Watching items
|
||||
if sectionType = "resume"
|
||||
createContinueWatchingRow()
|
||||
' Smithflix: AI picks sit right below Continue Watching
|
||||
createAiPicksRow()
|
||||
return true
|
||||
end if
|
||||
|
||||
|
|
@ -1011,6 +1018,64 @@ sub updateMyListItems()
|
|||
setRowItemSize()
|
||||
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
|
||||
'
|
||||
sub updateContinueWatchingItems()
|
||||
|
|
|
|||
|
|
@ -701,6 +701,64 @@ function loadMyList() as object
|
|||
return results
|
||||
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
|
||||
results = []
|
||||
|
||||
|
|
@ -842,6 +900,12 @@ sub loadItems()
|
|||
return
|
||||
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")
|
||||
m.top.content = loadPlaylists()
|
||||
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>
|
||||
<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>
|
||||
</message>
|
||||
<message>
|
||||
|
|
|
|||
2
manifest
|
|
@ -1,6 +1,6 @@
|
|||
## Channel Details
|
||||
|
||||
title=jellyfin
|
||||
title=Smithflix
|
||||
major_version=3
|
||||
minor_version=2
|
||||
build_version=5
|
||||
|
|
|
|||