Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ name: CI

on:
push:
branches-ignore: [gh-pages]
pull_request:

permissions:
Expand Down
28 changes: 11 additions & 17 deletions .github/workflows/pages.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,14 @@ on:
workflow_dispatch:

permissions:
contents: read
pages: write
id-token: write
contents: write

concurrency:
group: pages
group: pages-production
cancel-in-progress: true

jobs:
build:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
Expand All @@ -24,16 +22,12 @@ jobs:
enable-cache: true
- name: Build tvtime-core wheel into web/dist
run: ./scripts/build-web.sh
- uses: actions/upload-pages-artifact@v3
- name: Deploy to gh-pages root
uses: JamesIves/github-pages-deploy-action@v4
with:
path: web

deploy:
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- id: deployment
uses: actions/deploy-pages@v4
folder: web
branch: gh-pages
clean: true
# Keep open PR previews when production redeploys.
clean-exclude: |
pr-preview/
32 changes: 32 additions & 0 deletions .github/workflows/preview.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
name: PR preview

on:
pull_request:
types: [opened, reopened, synchronize, closed]

permissions:
contents: write
pull-requests: write

concurrency:
group: preview-${{ github.event.pull_request.number }}
cancel-in-progress: true

jobs:
preview:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build the site
if: github.event.action != 'closed'
uses: astral-sh/setup-uv@v6
with:
enable-cache: true
- if: github.event.action != 'closed'
run: ./scripts/build-web.sh
- name: Deploy / update / remove the PR preview
uses: rossjrw/pr-preview-action@v1
with:
source-dir: web
preview-branch: gh-pages
umbrella-dir: pr-preview
22 changes: 18 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
# TV Time Data Recovery

[![CI](https://github.com/burgesQ/tvtime/actions/workflows/ci.yml/badge.svg)](https://github.com/burgesQ/tvtime/actions/workflows/ci.yml)
[![Deploy Pages](https://github.com/burgesQ/tvtime/actions/workflows/pages.yml/badge.svg)](https://github.com/burgesQ/tvtime/actions/workflows/pages.yml)
[![Web app](https://img.shields.io/badge/web%20app-burgesq.github.io%2Ftvtime-2f6f4f)](https://burgesq.github.io/tvtime/)
[![License: WTFPL](https://img.shields.io/badge/License-WTFPL-brightgreen.svg)](LICENSE)

Recover your library and watch history from the TV Time Android app's local cache
(`DioCache.db`) and export it to CSV.

Expand All @@ -8,16 +13,25 @@ API responses it has seen. When the online service is unavailable, that cache is
often the only remaining copy of your data. This project parses it back into
usable CSV files.

If that cache (`DioCache.db`) has been cleared and reads as empty, the tool also
recovers a **name-level library and your "up next" list** from the app's Flutter
preferences (`shared_prefs/FlutterSharedPreferences.xml` and
`HomeWidgetPreferences.xml`). You can hand it the whole `tvtime-private.tar` you
pulled from the phone and it will find and merge whatever it can.

> Not affiliated with or endorsed by TV Time. This recovers only data that is still
> cached locally on your device; it is **not** an official export and completeness
> depends entirely on what your device kept.

## Three ways to use it

- **Web app** (recommended, no install): upload your `DioCache.db` and download CSVs.
The file is processed **entirely in your browser** and is never uploaded anywhere.
- **CLI**: `tvtime-recover /path/to/DioCache.db ./tvtime-export`
- **Library** (`tvtime-core`): `recover(db_bytes)` for use in your own scripts.
- **Web app** (recommended, no install): upload your `DioCache.db`, the whole
`tvtime-private.tar`, or your `shared_prefs` files and download CSVs. Everything is
processed **entirely in your browser** and is never uploaded anywhere.
- **CLI**: `tvtime-recover <inputs...> --out ./tvtime-export` (or `--zip out.zip`),
where each input is a `DioCache.db`, a `tvtime-private` tar/zip, or a prefs `.xml`.
- **Library** (`tvtime-core`): `recover_files({name: bytes})` (or `recover(db_bytes)`
for a single DioCache) for use in your own scripts.

## Getting your DioCache.db

Expand Down
19 changes: 19 additions & 0 deletions docs/extraction-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,3 +105,22 @@ sqlite3 "/path/to/DioCache.db" "SELECT COUNT(*) FROM cache_dio;"

You want a `cache_dio` table, an integrity result of `ok`, and a non-zero row count.
Then feed `DioCache.db` to the [web app](../web/index.html) or the CLI.

## If `cache_dio` is empty

`DioCache.db` is only an HTTP cache, so it is frequently empty (a `SELECT COUNT(*)
FROM cache_dio;` of `0`). Do not give up — the app keeps a durable, name-level copy of
your library and your "up next" list in its Flutter preferences:

```
shared_prefs/FlutterSharedPreferences.xml # library names by category (braze_* keys)
shared_prefs/HomeWidgetPreferences.xml # the "up next" episodes (showsWatchList)
```

The tool reads these too. The easiest path is to hand it the **whole**
`tvtime-private.tar` (or `.zip`) you extracted — it will locate `DioCache.db` and the
`shared_prefs` files inside and merge whatever it finds. You can also select the two
XML files directly.

> Note: this recovers show and movie **names** (plus your up-next episodes), not a full
> per-episode watch history with timestamps — that only ever lived in `DioCache.db`.
2 changes: 1 addition & 1 deletion packages/tvtime-cli/pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "tvtime-cli"
version = "0.1.0"
version = "0.2.0"
description = "Command-line tool to recover TV Time data from a DioCache.db"
readme = "README.md"
requires-python = ">=3.10"
Expand Down
37 changes: 22 additions & 15 deletions packages/tvtime-cli/src/tvtime_cli/__init__.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
"""Command-line front end for TV Time data recovery.

Reads a ``DioCache.db`` and writes the recovered records either as CSV files in
an output directory or as a single ZIP archive.
Reads one or more inputs — a ``DioCache.db``, a ``tvtime-private`` tar/zip, or
loose ``shared_prefs`` XML files — and writes the recovered records either as
CSV files in a directory or as a single ZIP.
"""

from __future__ import annotations
Expand All @@ -10,21 +11,26 @@
import sys
from pathlib import Path

from tvtime_core import recover
from tvtime_core import recover_files

__all__ = ["main"]


def _build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="tvtime-recover",
description="Recover TV Time data from an Android DioCache.db into CSV.",
description="Recover TV Time data from an Android DioCache.db or shared_prefs into CSV.",
)
parser.add_argument("database", type=Path, help="Path to the extracted DioCache.db")
parser.add_argument(
"output",
"inputs",
type=Path,
nargs="+",
help="Inputs: a DioCache.db, a tvtime-private tar/zip, or shared_prefs .xml files",
)
parser.add_argument(
"-o",
"--out",
type=Path,
nargs="?",
help="Directory to write the CSV exports into",
)
parser.add_argument(
Expand All @@ -51,25 +57,26 @@ def main(argv: list[str] | None = None) -> int:
parser = _build_parser()
args = parser.parse_args(argv)

if args.zip_path is None and args.output is None:
if args.zip_path is None and args.out is None:
parser.print_usage(sys.stderr)
print("tvtime-recover: provide an output directory or --zip PATH", file=sys.stderr)
print("tvtime-recover: provide an output directory (--out) or --zip PATH", file=sys.stderr)
return 2

result = recover(args.database.read_bytes())
files = {str(path): path.read_bytes() for path in args.inputs}
result = recover_files(files)
summary = result.summary()

if args.zip_path is not None:
args.zip_path.parent.mkdir(parents=True, exist_ok=True)
args.zip_path.write_bytes(result.to_zip_bytes())
destination: Path = args.zip_path
else:
_write_directory(result.to_csv_map(), result.raw_cache, args.output)
destination = args.output
_write_directory(result.to_csv_map(), result.raw_cache, args.out)
destination = args.out

print(f"Cache entries: {summary['cache_entries']}")
print(f"Library records: {summary['library']} {summary['library_by_type']}")
print(f"Inputs read: {len(files)}")
print(f"Library records: {summary['library']} {summary['library_by_source']}")
print(f"Movie watch-history records: {summary['movie_history']}")
print(f"Cached episode records: {summary['episodes']}")
print(f"Cached/up-next episode records: {summary['episodes']}")
print(f"Exports written to: {destination}")
return 0
15 changes: 14 additions & 1 deletion packages/tvtime-cli/tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ def test_cli_writes_export_directory(tmp_path: Path, capsys: pytest.CaptureFixtu
_make_db(db_path)
out_dir = tmp_path / "export"

exit_code = main([str(db_path), str(out_dir)])
exit_code = main([str(db_path), "--out", str(out_dir)])

assert exit_code == 0
assert (out_dir / "tvtime_library.csv").exists()
Expand All @@ -69,6 +69,19 @@ def test_cli_writes_single_zip(tmp_path: Path) -> None:
assert "tvtime_library.csv" in archive.namelist()


def test_cli_accepts_multiple_inputs(tmp_path: Path) -> None:
db_a = tmp_path / "a.db"
db_b = tmp_path / "b.db"
_make_db(db_a)
_make_db(db_b)
out_dir = tmp_path / "export"

exit_code = main([str(db_a), str(db_b), "--out", str(out_dir)])

assert exit_code == 0
assert (out_dir / "tvtime_library.csv").exists()


def test_cli_requires_an_output(tmp_path: Path) -> None:
db_path = tmp_path / "DioCache.db"
_make_db(db_path)
Expand Down
2 changes: 1 addition & 1 deletion packages/tvtime-core/pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "tvtime-core"
version = "0.1.0"
version = "0.2.0"
description = "Parse a TV Time Android DioCache.db into CSV/ZIP exports (stdlib only)"
readme = "README.md"
requires-python = ">=3.10"
Expand Down
3 changes: 2 additions & 1 deletion packages/tvtime-core/src/tvtime_core/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,6 @@

from tvtime_core.models import RecoveryResult
from tvtime_core.parser import recover
from tvtime_core.sources import recover_files

__all__ = ["RecoveryResult", "recover"]
__all__ = ["RecoveryResult", "recover", "recover_files"]
27 changes: 21 additions & 6 deletions packages/tvtime-core/src/tvtime_core/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,25 +16,39 @@


def _rows_to_csv(rows: list[dict[str, Any]]) -> str:
"""Render rows as CSV text (BOM-free). Empty input yields an empty string."""
"""Render rows as CSV text (BOM-free). Empty input yields an empty string.

Rows may be heterogeneous (they can come from different recovery sources);
the header is the union of keys in first-seen order and missing cells blank.
"""
if not rows:
return ""
fieldnames: list[str] = []
for row in rows:
for key in row:
if key not in fieldnames:
fieldnames.append(key)
buffer = io.StringIO()
writer = csv.DictWriter(buffer, fieldnames=list(rows[0]))
writer = csv.DictWriter(buffer, fieldnames=fieldnames, restval="")
writer.writeheader()
writer.writerows(rows)
return buffer.getvalue()


def counts_by_type(rows: list[dict[str, Any]]) -> dict[str, int]:
"""Count rows by their ``entity_type`` field."""
def _counts_by(rows: list[dict[str, Any]], key: str, default: str) -> dict[str, int]:
"""Count rows by a given field."""
counts: dict[str, int] = {}
for row in rows:
key = str(row.get("entity_type", "unknown"))
counts[key] = counts.get(key, 0) + 1
value = str(row.get(key, default))
counts[value] = counts.get(value, 0) + 1
return counts


def counts_by_type(rows: list[dict[str, Any]]) -> dict[str, int]:
"""Count rows by their ``entity_type`` field."""
return _counts_by(rows, "entity_type", "unknown")


@dataclass
class RecoveryResult:
"""Structured output of :func:`tvtime_core.parser.recover`."""
Expand Down Expand Up @@ -71,6 +85,7 @@ def summary(self) -> dict[str, Any]:
"cache_entries": len(self.cache_index),
"library": len(self.library),
"library_by_type": counts_by_type(self.library),
"library_by_source": _counts_by(self.library, "source", "dio_cache"),
"movie_history": len(self.movie_history),
"episodes": len(self.episodes),
}
Loading
Loading