A streaming service had a Fire TV app built with a white-label app builder platform. We wanted to extract every piece of data it uses – content feeds, video URLs, thumbnails, API endpoints, the intro video, every asset – so we could rebuild the app from scratch with our own architecture.

Final haul: 1,222 files, 461MB. Every thumbnail, every banner, every website image, every HLS stream manifest, every API response.

Step 1: Identify the App

The Fire TV device had the app installed. First question: what’s the actual package name?

adb shell pm list packages -3
package:com.tubitv.ott
package:com.whitelabel.app7f2k9x
package:com.streamco.firetv.debug
package:com.netflix.ninja

com.whitelabel.app7f2k9x – a white-label app. The package name tells us it’s built on a common app builder platform.

Step 2: Pull the APK

ADB can pull any installed APK directly from the device:

APK_PATH=$(adb shell pm path com.whitelabel.app7f2k9x | sed 's/package://')
adb pull "$APK_PATH" existing.apk

Got a 7.8MB APK. This is compiled bytecode, not source, but it contains all string literals baked into the binary.

Step 3: Extract Strings from DEX

Android apps compile Kotlin/Java to DEX (Dalvik Executable) format. The strings command extracts readable ASCII text from any binary file. We unzipped the APK and ran strings on every DEX file:

unzip existing.apk -d app_apk/
strings app_apk/classes*.dex | grep -iE "https?://" | sort -u

This revealed the platform’s API endpoints:

https://appbuilder.example.com/API/V1/business/register.php
https://appbuilder.example.com/API/V1/business/authenticate.php
https://api.appbuilder.example.com/API/V1/business/collect.php
https://appbuilder.example.com/API/V1/content/get_login_settings.php?channel=

The channel= parameter confirmed the app fetches its configuration from the platform’s servers at runtime, parameterized by channel ID.

Step 4: Map the Internal Class Structure

The platform uses an internal SDK package name. Searching for it revealed the full app architecture:

strings app_apk/classes*.dex | grep "internalsdkname" | sort -u

Key findings:

com/internalsdkname/firetv/ui/BrowseActivity$playIntroVideo$1
com/internalsdkname/firetv/ui/BrowseActivity$loadContent$1
com/internalsdkname/firetv/ui/DetailActivity
com/internalsdkname/firetv/ui/SearchActivity
com/internalsdkname/firetv/ui/SeriesActivity
com/internalsdkname/firetv/ui/PlaybackActivity
com/internalsdkname/firetv/ui/LiveStreamActivity
com/internalsdkname/firetv/data/ChannelRepository$getChannel$2
com/internalsdkname/firetv/data/ApiClient

The playIntroVideo method confirmed the intro video feature. Further searching:

strings app_apk/classes*.dex | grep -iE "intro.*video|introVideo" | sort -u
getIntroVideoUrl
introVideoUrl
onIntroVideoEnded
playIntroVideo
skipIntroVideo

The intro video URL isn’t hardcoded – it comes from the API at runtime via getIntroVideoUrl.

Step 5: Hit the Platform API

The login settings endpoint is unauthenticated – it tells the app how to configure itself:

curl "https://appbuilder.example.com/API/V1/content/get_login_settings.php?channel=842"
{
    "success": true,
    "login_mode": "disabled",
    "login_background_url": "",
    "auth_endpoint": "https://appbuilder.example.com/API/V1/business/authenticate.php",
    "api_key": "[REDACTED - 64 char hex hash]"
}

Login is disabled – it’s a free app. We got the API key. But the content endpoint needs session auth:

curl "https://appbuilder.example.com/API/V1/content/get_content.php?channel=842" \
  -H "X-API-KEY: [REDACTED]"
{"Status":"Error","Message":"Not authenticated"}

We searched the DEX for how the app authenticates:

strings app_apk/classes*.dex | grep -iE "api_key|apiKey|x-api-key|authorization" | sort -u
X-API-KEY
getApiKey
KEY_API_KEY
loginApiKey

Tried X-API-KEY header – got "Email is required". The authenticate endpoint needs a user login, not just the API key. The app probably uses an anonymous device token we couldn’t replicate. Dead end for the content API.

Step 6: The Local JSON Feeds

The client’s web repo had JSON feed files that the app consumes. These are the actual content databases:

python3 -c "
import json
with open('public/android.json') as f:
    data = json.load(f)
print(f'{len(data[\"items\"])} items')
"
195 items

Every item has a real HLS stream URL and thumbnail:

{
  "id": "617",
  "type": "Show",
  "title": "Episode Title Here",
  "sources": [
    {
      "type": "HLS",
      "url": "https://vz-XXXXXXXX-XXX.b-cdn.net/[uuid]/playlist.m3u8"
    }
  ],
  "images": [
    {
      "poster_16x9": "https://imagedelivery.net/[account-hash]/[image-id]/public"
    }
  ],
  "description": "...",
  "duration_sec": 1584
}

The Roku feed had even richer data – 29 series with full episode listings:

python3 -c "
import json
with open('public/roku2.json') as f:
    data = json.load(f)
for s in data.get('series', [])[:5]:
    seasons = s.get('seasons', [])
    eps = sum(len(season.get('episodes', [])) for season in seasons)
    print(f'  {s[\"title\"]} ({eps} episodes)')
"
  Show A (7 episodes)
  Show B (18 episodes)
  Show C (10 episodes)
  Show D (7 episodes)
  Show E (5 episodes)

Step 7: The Backend API

The web backend exposes REST endpoints. We found the routes by reading the Express.js source:

grep "app.use" backend/server.js
app.use("/api/v1/feed", feedRouter);
app.use("/api/v1/auth", authRoutes);
app.use("/api/v1/db", dbRoutes);
app.use("/api/v1/stream", streamRouter);
...

The DB routes accept POST without auth for read operations:

curl -X POST "https://backend.example.com/api/v1/db/getallshows" \
  -H "Content-Type: application/json"
{
  "success": true,
  "message": "dataretrieved",
  "data": [
    {
      "showid": 12,
      "showname": "Some Show",
      "bannerurl": "https://imagedelivery.net/[account-hash]/[image-id]/public",
      "description": "Some Show"
    },
    ...
  ]
}

61 shows returned. The feed endpoint also works:

curl "https://backend.example.com/api/v1/feed/roku"

This returned the live stream URL:

https://player.viloud.tv/embed/channel/[channel-hash]

The video types endpoint gave us the content taxonomy:

curl -X POST "https://backend.example.com/api/v1/db/videotypes"
{"data": [
  {"typeid": 1, "typename": "Show"},
  {"typeid": 2, "typename": "Movie"},
  {"typeid": 3, "typename": "Short"},
  {"typeid": 4, "typename": "Ad"},
  {"typeid": 5, "typename": "Meditation"},
  {"typeid": 6, "typename": "Promo"}
]}

Step 8: Network Analysis on Device

To confirm what servers the app talks to, we read the kernel’s TCP connection table while the app was running:

adb shell cat /proc/net/tcp6

The hex IP addresses in the output map to remote servers. We decoded them:

import socket
hex_ip = '7FD95434'
b = bytes.fromhex(hex_ip)
ip = f'{b[3]}.{b[2]}.{b[1]}.{b[0]}'
host = socket.gethostbyaddr(ip)[0]

Result: the app connects to the platform API server (behind Cloudflare), plus various CloudFront and AWS EC2 instances for CDN content.

We confirmed this by checking what the platform domain resolves to:

dig appbuilder.example.com +short
172.67.XXX.XXX
104.21.XX.XXX

Matches the connection table. The app’s UID was confirmed via dumpsys connectivity.

Step 9: Scrape the Website

The main site is WordPress. Initial curl attempts on subpages returned empty because WordPress redirects without a proper User-Agent:

# This returns 0 bytes:
curl -s "https://streamingsite.example.com/about" -o about.html

# This works:
curl -sL -A "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36" \
  "https://streamingsite.example.com/about" -o about.html

We scraped every page: home, about, all-shows, blog, schedule, shop, login, activate, privacy, terms, FAQ, podcast, live broadcasts, and individual show/movie pages:

for page in "" "about-us" "all-shows" "blog" "broadcast-schedule-2" \
  "contact" "faq" "premium" "activate" "terms" "privacy" "shows" \
  "schedule" "live" "join" "login" "movies-style" "podcast" "shop-page"; do
  curl -sL -A "$UA" -o "page-${page:-home}.html" "https://streamingsite.example.com/$page"
done

Then extracted all internal URLs from the homepage to find pages we might have missed:

python3 -c "
import re
with open('page-home.html') as f:
    html = f.read()
urls = set(re.findall(r'https://streamingsite\.example\.com/[^\s\"<>]+', html))
for u in sorted(urls):
    print(u)
"

This found individual show pages like /tv-show/some-show, /tv-show/another-show, /movie/some-movie, etc. We scraped those too.

Step 10: Download All Media Assets

The JSON feeds gave us URLs. We needed the actual files.

Thumbnails – extracted all image URLs from the android feed and downloaded in parallel:

python3 -c "
import json
with open('android-feed.json') as f:
    data = json.load(f)
for item in data['items']:
    for img in item.get('images', []):
        for v in img.values():
            if v: print(f'{item[\"id\"]}\t{v}')
" > /tmp/thumb_urls.txt

while IFS=$'\t' read -r id url; do
  curl -sL -o "thumbnails/${id}.jpg" "$url" &
done < /tmp/thumb_urls.txt
wait

181 thumbnails, 50MB.

Show banners – same approach from the API response:

python3 -c "
import json
with open('api-allshows.json') as f:
    shows = json.load(f)
for s in shows['data']:
    if s.get('bannerurl'):
        print(f'{s[\"showid\"]}\t{s[\"bannerurl\"]}')
" > /tmp/banner_urls.txt

while IFS=$'\t' read -r id url; do
  curl -sL -o "banners/${id}.jpg" "$url" &
done < /tmp/banner_urls.txt
wait

58 banners, 20MB.

Series episode thumbnails from the Roku feed – 55 images, 17MB.

HLS manifests – we downloaded the .m3u8 playlist files for all 195 video streams. These are small text files that point to the actual .ts video segments on the CDN:

while IFS=$'\t' read -r id url; do
  curl -sL -o "videos/${id}.m3u8" "$url" &
done < /tmp/video_urls.txt
wait

195 manifests, 784KB.

Full video downloads – we also downloaded every video at source quality using ffmpeg to mux the HLS streams into MP4:

while IFS=$'\t' read -r id url; do
  ffmpeg -y -i "$url" -c copy -bsf:a aac_adtstoasc "videos-full/${id}.mp4"
done < /tmp/video_urls.txt

195 videos at 1080p source quality. Still downloading as of this writing.

Website assets – extracted every src="..." and href="..." URL pointing to wp-content/ from all scraped HTML pages:

python3 << 'EOF'
import re, glob
urls = set()
for f in glob.glob('page-*.html') + glob.glob('shows/*.html') + glob.glob('movies/*.html'):
    with open(f) as fh:
        urls.update(re.findall(r'src="(https://streamingsite\.example\.com/wp-content/[^"]+)"', fh.read()))
# Filter to actual media files
for url in urls:
    if any(url.endswith(ext) for ext in ['.png', '.jpg', '.jpeg', '.webp', '.svg', '.gif']):
        print(url)
EOF

Downloaded 652 images in parallel – every logo, background, show poster, hero image, and icon from the entire website. 360MB total.

Step 11: Cracking the Intro Video

This was the hardest piece. The get_content.php endpoint we’d been trying requires session auth – email + password login. Dead end.

But buried in the DEX strings was another endpoint we’d overlooked:

strings app_apk/classes*.dex | grep "channel_api"
channel_api_v1.1.php

A versioned API endpoint, different from the get_content.php path. We hit it with the API key:

curl -s "https://appbuilder.example.com/channel_api_v1.1.php?channel=842" \
  -H "X-API-KEY: [REDACTED]"

598KB JSON response. The entire channel configuration in one call:

{
  "available": true,
  "channelCode": 842,
  "branding": {
    "churchName": "StreamCo",
    "brandColor": "#0f172a",
    "logoUrl": "https://appbuilder.s3.us-east-2.amazonaws.com/branding/logos/[id]/...",
    "customBackgroundUrl": "https://appbuilder.s3.us-east-2.amazonaws.com/branding/backgrounds/..."
  },
  "liveFeed": {
    "title": "Broadcast",
    "m3u8Url": "https://app.viloud.tv/hls/channel/[channel-hash].m3u8",
    "thumbnailUrl": "https://appbuilder.s3.us-east-2.amazonaws.com/assets/[id]/..."
  },
  "watermark": {
    "url": "https://appbuilder.s3.us-east-2.amazonaws.com/watermarks/[id]/...",
    "size": "small",
    "positionX": "right",
    "positionY": "top"
  },
  "introVideoUrl": "https://vz-XXXXXXXX-XXX.b-cdn.net/[uuid]/play_720p.mp4?token=...&expires=...",
  "categories": [
    {"name": "Featured", "content": [...]},
    {"name": "Last Week's Top 10", "content": [...]},
    {"name": "New Releases", "content": [...]},
    {"name": "TV Shows", "content": [...]},
    {"name": "Films", "content": [...]},
    {"name": "Shorts", "content": [...]},
    {"name": "Meditations", "content": [...]}
  ]
}

There it was – introVideoUrl as a top-level field. A CDN URL with a signed token.

We verified it was accessible:

curl -sI "$INTRO_URL"
HTTP/2 200
content-type: video/mp4
content-length: 5560228
server: BunnyCDN-IL1-718

5.3MB MP4, served from the CDN. Downloaded:

curl -sL -o intro-video.mp4 "$INTRO_URL"

The key insight: we’d been trying get_content.php (which needs session auth), but the app actually uses channel_api_v1.1.php (which only needs the X-API-KEY header). The versioned endpoint was in the DEX strings all along – we just didn’t recognize it as an API path until we searched more broadly.

This single endpoint is the master configuration for the entire app. Everything the SDK needs to render the UI comes from this one call: branding, categories, content items with video URLs and thumbnails, live feed, watermark, intro video, and menu text.

We also downloaded all the branding assets:

# Logo, background, watermark, live feed thumbnail
curl -sL -o branding/logo.png "$LOGO_URL"
curl -sL -o branding/background.jpg "$BG_URL"
curl -sL -o branding/watermark.png "$WATERMARK_URL"

Final Inventory

Category Files Size Source
Content thumbnails 181 50MB Cloudflare Images
Show banners 58 20MB Backend API
Series thumbnails 55 17MB Roku feed
HLS manifests 195 784KB Bunny CDN
Full videos 195 ~100GB+ ffmpeg from HLS streams
Website images 652 360MB WordPress wp-content
Website HTML 38 4MB Scraped pages
JSON data 42 1.2MB Feeds + API responses + channel config
Branding assets 4 8.3MB S3 bucket
Intro video 1 5.4MB channel_api_v1.1.php
APK 1 7.5MB Device via ADB
Total 1,222+ 461MB+ (videos still downloading)

CDN Infrastructure

Service Purpose
Bunny CDN HLS video streaming + intro video
Cloudflare Images Thumbnails and posters
Viloud Live stream HLS
AWS S3 Branding assets (logo, background, watermark)
WordPress Website assets
App Builder Platform App config API

The Master Endpoint

Everything the Fire TV app needs comes from one call:

GET https://appbuilder.example.com/channel_api_v1.1.php?channel=842
X-API-KEY: [REDACTED]

This returns: branding, 7 content categories with full item details, live feed URL, intro video URL, watermark config, and all menu text. The SDK calls this once on launch and builds the entire UI from the response.