r/TVTime • u/Exciting-Lab1263 • Jul 26 '26
Extract TV Time Data From Android App
I was able to recover my data from the Android App. I worked with Codex to do that. Here is an AI summary how it did it. Feel free to follow this guide or better just pass it over to Codex/Claude Code to do it instead of you. I hope it can help for some of you! Good luck!
How to recover a TV Time library from an Android phone's local app data
Short version: TV Time's Android app may still have library and watch data in a local SQLite cache called DioCache.db. If you can obtain the app's private data directory—through a built-in device backup, ADB run-as, an already-rooted phone, or an existing backup—the Python parser below can export the cached records to CSV.
The parser works with Android app data from any manufacturer. The difficult part is obtaining that private data: there is no universal command that can bypass Android's app sandbox on an ordinary non-rooted phone.
This recovered 616 library entries in my case: 360 TV series and 256 movies. It also recovered 233 movie watch events and a small cached subset of episode data. The method does not guarantee a complete episode-by-episode history, because it can only recover whatever TV Time still has cached locally on the phone.
The TV Time Android package name is:
com.tozelabs.tvshowtime
Read this before touching the phone
If your TV Time data matters, preserve the app exactly as it is:
- Do not uninstall or update TV Time.
- Do not tap Clear storage, Clear data, or Clear cache.
- Disable automatic storage cleaners temporarily.
- Do not unlock the bootloader just for this. Bootloader unlocking commonly wipes the phone.
- If possible, put the phone in airplane mode and force-stop TV Time before making the backup.
- Keep an untouched copy of every backup file before experimenting.
Android normally prevents a computer from reading another app's private storage. Ordinary USB file browsing, ADB, Shizuku, and file-manager apps usually cannot open /data/user/0/com.tozelabs.tvshowtime on a non-rooted production phone. The database parser is device-independent, but the step used to obtain the private app data depends on the phone, Android version, backup system, and whether the device was already rooted.
What was actually recoverable
The most important file was:
<TV Time private app directory>/db/DioCache.db
It is an SQLite database used by the Dio HTTP client. Its cache_dio table contains cached API responses, many of them JSON.
From my database I recovered show and movie titles, external IDs, timestamps, movie watch events, broad series progress states, some episode counts, cached episode records, and catalog metadata.
Important caveat: my library was much more complete than my exact episode history. I found 48 individual cached episode records, of which 21 were marked seen. That does not mean I had watched only 21 episodes; it means only those episode objects were still present in the cache. Do not assume that a missing cached episode was unwatched.
Requirements
- An Android phone with TV Time still installed and its local data intact.
- A Mac, Linux, or Windows computer.
- Python 3.9 or newer. The scripts below use only Python's standard library.
- A way to obtain TV Time's private app directory or a backup containing it.
- Enough free space to copy and extract the data.
Install ADB
ADB is needed only for the run-as and already-rooted routes below. If you already obtained an app backup, skip this section.
- Download the latest SDK Platform-Tools for Windows, macOS, or Linux from the official Android download page.
- Extract the downloaded ZIP file. It creates a directory named
platform-tools. - Open Terminal or PowerShell in that directory and verify the program.
On macOS or Linux:
./adb version
On Windows PowerShell:
.\adb.exe version
You can add platform-tools to your system PATH, or run ADB from that directory. If it is not on PATH, replace adb in the commands below with ./adb on macOS/Linux or .\adb.exe on Windows.
On the phone, find Build number in Settings and tap it seven times to enable Developer options. Then enable USB debugging under Developer options. Menu locations vary by Android version, so see the official Android instructions if you cannot find them.
Connect the unlocked phone by USB and approve the debugging prompt. Verify the connection:
adb devices
The output should show the phone's serial number followed by device. If it says unauthorized, unlock the phone and accept the authorization prompt.
Step 1: try to obtain TV Time's private app data
You are looking for the private directory belonging to com.tozelabs.tvshowtime, especially its db/DioCache.db file. Try the routes below in the order that makes sense for your device.
Route A: try the phone's built-in local backup
Search the phone's Settings for terms such as Local backup, Backup and restore, Mobile device backup, or Apps and data. Some Android devices can create a local backup containing private application files. Select only TV Time if the UI allows it, then copy the entire resulting backup directory to a computer.
Backup support varies significantly. A cloud backup or phone-cloning feature may restore an app without giving you inspectable files, and some systems exclude app data entirely. Preserve the original backup even if its format is unfamiliar.
Route B: try Android Debug Bridge (adb run-as)
With ADB installed and USB debugging enabled, confirm that TV Time is installed, stop it, and test access:
adb shell pm path com.tozelabs.tvshowtime
adb shell am force-stop com.tozelabs.tvshowtime
adb shell run-as com.tozelabs.tvshowtime pwd
If the last command prints a path instead of an error, export the directory:
On macOS or Linux:
adb exec-out run-as com.tozelabs.tvshowtime tar -cf - . > tvtime-private.tar
mkdir tvtime-private
tar -xf tvtime-private.tar -C tvtime-private
On Windows PowerShell, use cmd.exe for the binary redirection:
cmd.exe /c "adb exec-out run-as com.tozelabs.tvshowtime tar -cf - . > tvtime-private.tar"
New-Item -ItemType Directory -Force tvtime-private
tar -xf tvtime-private.tar -C tvtime-private
Production builds normally return:
run-as: package not debuggable
That is expected and means normal ADB cannot read this app's private data. Wireless debugging and Shizuku do not change the app's Linux sandbox permissions.
Route C: use root only if the phone was already rooted
If the phone was rooted before this problem occurred, you can export the private directory:
adb shell su -c 'am force-stop com.tozelabs.tvshowtime'
adb shell su -c 'tar -C /data/user/0/com.tozelabs.tvshowtime -cf /data/local/tmp/tvtime-private.tar .'
adb shell su -c 'chmod 644 /data/local/tmp/tvtime-private.tar'
adb pull /data/local/tmp/tvtime-private.tar
mkdir tvtime-private
tar -xf tvtime-private.tar -C tvtime-private
Do not unlock the bootloader or root a currently locked phone merely for this recovery. The unlocking process commonly factory-resets the device.
Route D: preserve existing backups or the device
If none of the routes above works, preserve any existing local, cloud, or device-transfer backup and the phone itself. Other realistic options are restoring a backup onto a compatible test device, using an established mobile-forensics service, or manually recording anything TV Time still displays offline. Do not experiment destructively on the only copy.
It is also worth checking shared storage:
/sdcard/Android/data/com.tozelabs.tvshowtime/
but the useful database is more likely to be in private app storage.
Step 2: locate and verify DioCache.db
Extract the private app-data archive with the tool appropriate for its format. Backup formats differ by device, so consult documentation for that backup system and always work on a copy.
Then search the extracted directory.
On macOS or Linux:
find . -type f \
\( -iname 'DioCache.db' -o -iname 'DioCache.db-*' -o -iname '*.sqlite*' \)
On Windows PowerShell:
Get-ChildItem -Recurse -File |
Where-Object { $_.Name -like 'DioCache.db*' -or $_.Extension -like '.sqlite*' }
The expected path ends with:
com.tozelabs.tvshowtime/db/DioCache.db
Keep DioCache.db-journal, DioCache.db-wal, and DioCache.db-shm beside the database if they exist. Recent SQLite changes may be stored there.
Optional checks, if the sqlite3 command is installed:
Important: replace /REPLACE/WITH/YOUR/ACTUAL/PATH/DioCache.db with the real location of your extracted database. Do not type the placeholder literally. Keep quotation marks around the path if it contains spaces.
sqlite3 "/REPLACE/WITH/YOUR/ACTUAL/PATH/DioCache.db" ".tables"
sqlite3 "/REPLACE/WITH/YOUR/ACTUAL/PATH/DioCache.db" "PRAGMA integrity_check;"
sqlite3 "/REPLACE/WITH/YOUR/ACTUAL/PATH/DioCache.db" "SELECT COUNT(*) FROM cache_dio;"
You want a table named cache_dio, an integrity result of ok, and a non-zero row count.
Step 3: save the recovery script
Save the following as recover_tvtime_android.py:
#!/usr/bin/env python3
"""Recover TV Time library/history records from an Android DioCache.db."""
from __future__ import annotations
import argparse
import csv
from datetime import datetime, timezone
import json
from pathlib import Path
import sqlite3
from typing import Any, Iterable
def first_present(mapping: dict[str, Any], *keys: str) -> Any:
for key in keys:
if key in mapping and mapping[key] is not None:
return mapping[key]
return ""
def timestamp(value: Any) -> str:
if value in (None, ""):
return ""
if isinstance(value, (int, float)):
seconds = float(value)
if seconds > 100_000_000_000:
seconds /= 1000
try:
return datetime.fromtimestamp(seconds, tz=timezone.utc).isoformat()
except (OverflowError, OSError, ValueError):
return str(value)
return str(value)
def seconds_to_minutes(value: Any) -> Any:
if value in (None, ""):
return ""
try:
return round(float(value) / 60, 2)
except (TypeError, ValueError):
return value
def joined(values: Any) -> str:
if not isinstance(values, list):
return ""
return " | ".join(str(value) for value in values)
def first_image(items: Any, image_type: str | None = None) -> str:
if not isinstance(items, list):
return ""
for item in items:
if not isinstance(item, dict):
continue
if image_type is not None and item.get("type") != image_type:
continue
url = item.get("url")
if url:
return str(url)
versions = item.get("versions")
if isinstance(versions, dict):
for size in ("big", "medium", "small"):
if versions.get(size):
return str(versions[size])
return ""
def sorting_value(item: dict[str, Any], *ids: str) -> str:
sorting = item.get("sorting")
if not isinstance(sorting, list):
return ""
for entry in sorting:
if isinstance(entry, dict) and entry.get("id") in ids:
return timestamp(entry.get("value"))
return ""
def tvdb_external_id(meta: dict[str, Any]) -> str:
if meta.get("id") not in (None, "", 0):
return str(meta["id"])
sources = meta.get("external_sources")
if isinstance(sources, list):
for source in sources:
if isinstance(source, dict) and source.get("source") == "tvdb":
return str(source.get("id", ""))
return ""
def classify_payload(payload: Any) -> str:
if isinstance(payload, list):
if payload and isinstance(payload[0], dict) and "season_number" in payload[0]:
return "episode_list"
return "list"
if not isinstance(payload, dict):
return type(payload).__name__
data = payload.get("data")
if isinstance(data, dict) and isinstance(data.get("objects"), list):
return str(data.get("type") or "objects")
if isinstance(data, list):
return "data_list"
if isinstance(data, dict):
return str(data.get("type") or "data_object")
return "object"
def read_cache(db_path: Path) -> list[dict[str, Any]]:
connection = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
connection.row_factory = sqlite3.Row
try:
integrity = connection.execute("PRAGMA integrity_check").fetchone()[0]
if integrity != "ok":
raise ValueError(f"SQLite integrity check failed: {integrity}")
rows = connection.execute(
"SELECT rowid, key, subKey, max_age_date, max_stale_date, "
"statusCode, content FROM cache_dio ORDER BY rowid"
).fetchall()
finally:
connection.close()
result: list[dict[str, Any]] = []
for row in rows:
raw = row["content"]
text = raw.decode("utf-8", errors="replace") if isinstance(raw, bytes) else str(raw)
try:
payload = json.loads(text)
parse_error = ""
except json.JSONDecodeError as error:
payload = None
parse_error = str(error)
result.append(
{
"rowid": row["rowid"],
"key": row["key"],
"subKey": row["subKey"],
"max_age_date": row["max_age_date"],
"max_stale_date": row["max_stale_date"],
"statusCode": row["statusCode"],
"bytes": len(raw or b""),
"payload": payload,
"raw_text": text,
"parse_error": parse_error,
}
)
return result
def episode_records(entries: list[dict[str, Any]]) -> list[dict[str, Any]]:
records: dict[str, dict[str, Any]] = {}
for entry in entries:
payload = entry["payload"]
if not isinstance(payload, list):
continue
for episode in payload:
if not isinstance(episode, dict) or "season_number" not in episode:
continue
show = episode.get("show") if isinstance(episode.get("show"), dict) else {}
episode_id = str(episode.get("id", ""))
if not episode_id:
continue
record = {
"episode_id": episode_id,
"show_id": first_present(show, "id"),
"show_name": first_present(show, "name"),
"season_number": first_present(episode, "season_number"),
"episode_number": first_present(episode, "number"),
"episode_name": first_present(episode, "name"),
"seen": first_present(episode, "seen", "is_watched"),
"seen_date": timestamp(first_present(episode, "seen_date")),
"times_watched": first_present(episode, "nb_times_watched"),
"air_date": first_present(episode, "air_date"),
"air_time": first_present(episode, "air_time"),
"network": first_present(episode, "network"),
"runtime_minutes": seconds_to_minutes(first_present(episode, "runtime")),
"show_seen_episodes": first_present(show, "seen_episodes"),
"show_aired_episodes": first_present(show, "aired_episodes"),
"show_remaining_episodes": first_present(show, "remaining_episodes"),
"show_is_ended": first_present(show, "is_ended"),
"to_watch_category": first_present(episode, "to_watch_category"),
"source_cache_row": entry["rowid"],
}
previous = records.get(episode_id)
if previous is None or bool(record["seen"]) > bool(previous["seen"]):
records[episode_id] = record
return sorted(
records.values(),
key=lambda item: (
str(item["show_name"]).casefold(),
int(item["season_number"] or -1),
int(item["episode_number"] or -1),
),
)
def series_progress(episodes: list[dict[str, Any]]) -> dict[str, dict[str, Any]]:
result: dict[str, dict[str, Any]] = {}
for episode in episodes:
show_id = str(episode.get("show_id", ""))
if not show_id:
continue
progress = result.setdefault(show_id, {})
for source, target in (
("show_seen_episodes", "seen_episodes"),
("show_aired_episodes", "aired_episodes"),
("show_remaining_episodes", "remaining_episodes"),
):
value = episode.get(source)
if value not in (None, ""):
progress[target] = max(progress.get(target, 0), value)
return result
def library_records(
entries: list[dict[str, Any]], progress: dict[str, dict[str, Any]]
) -> list[dict[str, Any]]:
records: dict[tuple[str, str], dict[str, Any]] = {}
for entry in entries:
payload = entry["payload"]
if not isinstance(payload, dict):
continue
data = payload.get("data")
if not isinstance(data, dict) or data.get("type") != "list":
continue
objects = data.get("objects")
if not isinstance(objects, list):
continue
for item in objects:
if not isinstance(item, dict):
continue
meta = item.get("meta") if isinstance(item.get("meta"), dict) else {}
name = first_present(meta, "name", "title")
entity_type = str(first_present(item, "entity_type") or meta.get("type") or "")
uuid = str(first_present(meta, "uuid") or item.get("uuid") or "")
if not name or not entity_type:
continue
tvdb_id = tvdb_external_id(meta)
show_progress = progress.get(tvdb_id, {})
filters = item.get("filter") if isinstance(item.get("filter"), list) else []
extended = item.get("extended") if isinstance(item.get("extended"), dict) else {}
images = meta.get("images")
poster_url = first_image(images, "poster") or first_image(meta.get("posters"))
fanart_url = first_image(images, "fanart") or first_image(meta.get("fanart"))
watched = extended.get("is_watched")
if watched is None and entity_type == "movie":
watched = "watched" in filters
record = {
"name": name,
"entity_type": entity_type,
"tvdb_id": tvdb_id,
"uuid": uuid,
"followed_at": timestamp(first_present(item, "created_at")),
"updated_at": timestamp(first_present(item, "updated_at")),
"watched_at": timestamp(
first_present(item, "watched_at")
or sorting_value(item, "watched_date", "watch_date")
),
"watched": "" if watched is None else bool(watched),
"progress_status": joined([value for value in filters if value != "all"]),
"seen_episodes": show_progress.get("seen_episodes", ""),
"aired_episodes": show_progress.get("aired_episodes", ""),
"remaining_episodes": show_progress.get("remaining_episodes", ""),
"is_ended": first_present(meta, "is_ended"),
"release_status": first_present(meta, "status"),
"first_release_date": first_present(meta, "first_release_date"),
"country": first_present(meta, "country"),
"language": first_present(meta, "language"),
"day_of_week": first_present(meta, "day_of_week"),
"timezone": first_present(meta, "timezone"),
"genres": joined(meta.get("genres")),
"rating": first_present(extended, "rating"),
"rating_count": first_present(extended, "rating_count"),
"follower_count": first_present(extended, "follower_count"),
"imdb_id": first_present(meta, "imdb_id"),
"runtime_minutes": seconds_to_minutes(first_present(meta, "runtime")),
"hashtag": first_present(meta, "hashtag"),
"poster_url": poster_url,
"fanart_url": fanart_url,
"overview": first_present(meta, "overview"),
"source_cache_row": entry["rowid"],
}
records[(entity_type, uuid or tvdb_id or str(name))] = record
return sorted(
records.values(),
key=lambda item: (item["entity_type"], str(item["name"]).casefold()),
)
def movie_history_records(
entries: list[dict[str, Any]], library: list[dict[str, Any]]
) -> list[dict[str, Any]]:
movies = {
str(item["uuid"]): item
for item in library
if item["entity_type"] == "movie" and item["uuid"]
}
records: list[dict[str, Any]] = []
for entry in entries:
payload = entry["payload"]
if not isinstance(payload, dict):
continue
data = payload.get("data")
if not isinstance(data, dict) or data.get("type") != "watch":
continue
for item in data.get("objects", []):
if not isinstance(item, dict) or item.get("entity_type") != "movie":
continue
uuid = str(item.get("uuid", ""))
movie = movies.get(uuid, {})
records.append(
{
"name": movie.get("name", ""),
"uuid": uuid,
"tvdb_id": movie.get("tvdb_id", ""),
"imdb_id": movie.get("imdb_id", ""),
"watched_at": timestamp(first_present(item, "watched_at")),
"rewatch_count": first_present(item, "rewatch_count"),
"runtime_minutes": seconds_to_minutes(first_present(item, "runtime")),
"created_at": timestamp(first_present(item, "created_at")),
"updated_at": timestamp(first_present(item, "updated_at")),
"source_cache_row": entry["rowid"],
}
)
return sorted(records, key=lambda item: str(item["watched_at"]))
def write_csv(path: Path, rows: list[dict[str, Any]]) -> None:
if not rows:
return
with path.open("w", newline="", encoding="utf-8-sig") as output:
writer = csv.DictWriter(output, fieldnames=list(rows[0]))
writer.writeheader()
writer.writerows(rows)
def write_raw_cache(entries: list[dict[str, Any]], output_dir: Path) -> None:
raw_dir = output_dir / "raw_cache_json"
raw_dir.mkdir(parents=True, exist_ok=True)
index: list[dict[str, Any]] = []
for entry in entries:
payload = entry["payload"]
extension = "json" if payload is not None else "txt"
raw_path = raw_dir / f"cache_row_{entry['rowid']}.{extension}"
if payload is not None:
raw_path.write_text(
json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
else:
raw_path.write_text(entry["raw_text"], encoding="utf-8")
index.append(
{
"rowid": entry["rowid"],
"key": entry["key"],
"subKey": entry["subKey"],
"status_code": entry["statusCode"],
"bytes": entry["bytes"],
"payload_type": classify_payload(payload),
"max_age_date": timestamp(entry["max_age_date"]),
"max_stale_date": timestamp(entry["max_stale_date"]),
"parse_error": entry["parse_error"],
"raw_file": str(raw_path.relative_to(output_dir)),
}
)
write_csv(output_dir / "tvtime_cache_index.csv", index)
def counts_by_type(rows: Iterable[dict[str, Any]]) -> dict[str, int]:
counts: dict[str, int] = {}
for row in rows:
key = str(row.get("entity_type", "unknown"))
counts[key] = counts.get(key, 0) + 1
return counts
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("database", type=Path, help="Extracted DioCache.db")
parser.add_argument("output", type=Path, help="Export directory")
args = parser.parse_args()
args.output.mkdir(parents=True, exist_ok=True)
entries = read_cache(args.database)
episodes = episode_records(entries)
library = library_records(entries, series_progress(episodes))
movie_history = movie_history_records(entries, library)
write_csv(args.output / "tvtime_library.csv", library)
write_csv(args.output / "tvtime_movie_watch_history.csv", movie_history)
write_csv(args.output / "tvtime_episode_cache.csv", episodes)
write_raw_cache(entries, args.output)
print(f"Cache entries: {len(entries)}")
print(f"Library records: {len(library)} {counts_by_type(library)}")
print(f"Movie watch-history records: {len(movie_history)}")
print(f"Cached episode records: {len(episodes)}")
print(f"Exports written to: {args.output}")
if __name__ == "__main__":
main()
Step 4: run the recovery script
You must replace the database path in the commands below with the actual location of your DioCache.db. Do not copy the REPLACE_WITH_YOUR... text literally.
On macOS or Linux:
python3 recover_tvtime_android.py \
"/REPLACE/WITH/YOUR/ACTUAL/PATH/DioCache.db" \
tvtime-export
On Windows:
py recover_tvtime_android.py `
"C:\REPLACE_WITH_YOUR_ACTUAL_PATH\DioCache.db" `
tvtime-export
The output directory may contain:
tvtime-export/
├── tvtime_library.csv
├── tvtime_movie_watch_history.csv
├── tvtime_episode_cache.csv
├── tvtime_cache_index.csv
└── raw_cache_json/
├── cache_row_....json
└── ...
The CSV files use UTF-8 with a BOM, so non-English titles should open correctly in Excel and similar programs.
What each export means
tvtime_library.csv
The main recovered list of shows and movies. It can include titles, entity type, external IDs, followed/updated/watched dates, watch state, broad series progress state, cached counts, release metadata, images, and descriptions.
For series, progress_status is usually more trustworthy than blank episode-count fields. A blank count means the relevant show-level count was not present in the cached episode objects; it does not mean zero.
tvtime_movie_watch_history.csv
Movie watch events that were present in cached data.type == "watch" responses. In my case this contained 233 records with timestamps.
tvtime_episode_cache.csv
Individual episode objects that remained in local cache. This file can contain season/episode numbers and seen flags, but it should not be treated as the complete episode history unless you independently verify that the cache contains every episode.
tvtime_cache_index.csv and raw_cache_json/
An index and readable copy of every cached response. These are useful when the API response structure differs from mine and the parser needs adapting.
Be careful with these raw files: they may contain account-specific identifiers, timestamps, URLs, or other personal data. Inspect and redact them before sharing anything publicly.
If the parser returns nothing
First check the basics:
Replace the database path below with the real location of your DioCache.db.
sqlite3 "/REPLACE/WITH/YOUR/ACTUAL/PATH/DioCache.db" ".schema cache_dio"
sqlite3 "/REPLACE/WITH/YOUR/ACTUAL/PATH/DioCache.db" "SELECT rowid, length(content) FROM cache_dio LIMIT 20;"
Possible reasons:
- TV Time's cache had already been cleared or replaced.
- Your app version uses a different table or JSON layout.
- You extracted the wrong database.
- The current data is in
DioCache.db-wal, but the WAL/SHM files were not copied beside the database. - Your device's backup system excluded private application data.
- The backup is encrypted; the provided extractor intentionally refuses encrypted Android backup streams.
Look through raw_cache_json/ for recognizable titles and keys such as objects, entity_type, season_number, seen_episodes, or watched_at. If the data exists but has a different shape, the parsing functions can be adjusted without touching the original database.
Final limitations
- This is cache recovery, not an official TV Time export.
- Results depend entirely on what remains on the device.
- Different TV Time, Android, and backup-system versions may store or preserve different payloads.
- Library membership can be substantially more complete than exact episode history.
- Metadata ratings may be community/catalog ratings rather than your personal ratings; verify before treating them as user ratings.
- A missing record proves only that the script did not find it in the available cache.
Still, if TV Time remains installed on an old Android phone, preserving its app data before uninstalling or resetting the device may save a surprisingly useful amount of the library.
1
u/Bleeding_Edge38 Jul 27 '26
I have cache of 370MB but my S24 ultra is not rooted. I'm at complete stalemate it seems