refactor index schema and metadata format and directory tree

This commit is contained in:
2026-05-30 01:05:27 +00:00
parent 550f67531a
commit 900fcea638
23 changed files with 879 additions and 458 deletions
+8
View File
@@ -40,6 +40,14 @@ uv run --extra gui python -m mediahive.winmain /path/to/media/folder
This launches the same pywebview-based desktop flow used by the Windows build.
## Migrate Existing Index Snapshots
```bash
uv run python scripts/indexmigr.py /path/to/media/root --write
```
This applies versioned snapshot migrations to `.mediahive/index.json` outside the main application. Use it before starting a newer build against an older index.
## Notes
- The selected media folder is scanned continuously by the backend.
+5 -3
View File
@@ -35,9 +35,11 @@ Each active root gets an isolated `RootContext` managed by the `Supervisor`:
### Item IDs
Every `Movie.id` and `Series.id` is namespaced with its `root_id`:
- Format: `{root_id}:{content_hash}`
- Old snapshots are auto-migrated on load: IDs lacking the prefix get it prepended.
`root_id` is stored separately on each item.
- `Movie.id` uses a slug built from the movie title and year, for example `spider-man-no-way-home-2021`.
- `Series.id` uses a slug built from the series title, for example `lost`.
- Legacy snapshot migrations are handled by `scripts/indexmigr.py`, not during app startup.
## API
+9 -7
View File
@@ -176,7 +176,9 @@ import { ref, computed, onMounted, onUnmounted, watch } from "vue"
import { useRouter, useRoute } from "vue-router"
import type {
Movie,
MovieUi,
Series,
SeriesUi,
MediaItem,
EpisodeWithSeries,
TaskInfo,
@@ -732,7 +734,7 @@ function showDetail(item: MediaItem) {
if (item.type === "episode") {
// For episodes, play directly if possible, otherwise show the series
const epData = item.data as EpisodeWithSeries
const playableFile = Object.values(epData.episode.torrents || {})[0]?.playable_file
const playableFile = Object.values(epData.episode.files || {})[0]?.playable_file
if (playableFile) {
handlePlay(playableFile)
} else {
@@ -800,10 +802,10 @@ function focusDetailEntryTarget(item: MediaItem): boolean {
}
// Convert raw data to MediaItem format
function movieToMediaItem(movie: Movie): MediaItem {
function movieToMediaItem(movie: MovieUi): MediaItem {
// Get resolution from first torrent if available
const torrents = Object.values(movie.torrents || {})
const resolution = torrents.length > 0 ? torrents[0].resolution : null
const files = Object.values(movie.files || {})
const resolution = files.length > 0 ? files[0].resolution : null
return {
id: movie.id,
@@ -819,7 +821,7 @@ function movieToMediaItem(movie: Movie): MediaItem {
}
}
function seriesToMediaItem(series: Series): MediaItem {
function seriesToMediaItem(series: SeriesUi): MediaItem {
// For series, collect reel images from all episodes
const reelImages: string[] = []
const reelSourceSets: string[][] = []
@@ -1249,7 +1251,7 @@ function reloadPage() {
function findRootIdForPath(filePath: string): string | null {
if (!mediaIndex.value) return null
for (const movie of mediaIndex.value.movies) {
for (const torrent of Object.values(movie.torrents || {})) {
for (const torrent of Object.values(movie.files || {})) {
if (torrent.playable_file === filePath) {
return torrent.root_id || movie.root_id
}
@@ -1258,7 +1260,7 @@ function findRootIdForPath(filePath: string): string | null {
for (const series of mediaIndex.value.series) {
for (const season of series.seasons || []) {
for (const episode of season.episodes || []) {
for (const torrent of Object.values(episode.torrents || {})) {
for (const torrent of Object.values(episode.files || {})) {
if (torrent.playable_file === filePath) {
return torrent.root_id || series.root_id
}
+1 -1
View File
@@ -570,7 +570,7 @@ function getRatingClass(item: MediaItem): string {
function getResolution(item: MediaItem): string | null {
if (item.type === "movies") {
const movie = item.data as Movie
return Object.values(movie.torrents || {})[0]?.resolution ?? null
return Object.values(movie.files || {})[0]?.resolution ?? null
}
return null
}
+15 -6
View File
@@ -124,7 +124,7 @@
>
<img
v-if="castMember.profile_path && !castMember.profile_path.startsWith('/')"
:src="getCoverUrl(castMember.profile_path, item.root_id)"
:src="getCastProfileUrl(castMember.profile_path)"
:alt="castMember.name"
class="cast-photo"
/>
@@ -202,7 +202,7 @@
<script setup lang="ts">
import { computed, ref, watch, onMounted, onUnmounted, nextTick } from "vue"
import type { CastMember, MediaItem, Movie, Series, Torrent } from "../types"
import type { CastMember, MediaItem, Movie, MovieUi, Series, Torrent } from "../types"
import {
getCoverUrl,
getVideoPreviewUrl,
@@ -499,7 +499,7 @@ function getShowreelSourceAttributes(path: string): VideoSourceAttributes {
const movieVersions = computed((): Torrent[] => {
if (props.item.type !== "movies") return []
const movie = props.item.data as Movie
return sortTorrentsByPreference(Object.values(movie.torrents || {}))
return sortTorrentsByPreference(Object.values(movie.files || {}))
})
// Page backdrop background
@@ -583,6 +583,15 @@ function getCastPlaceholderUrl(gender?: CastMember["gender"]): string {
return gender === "female" ? castPlaceholderFemaleUrl : castPlaceholderMaleUrl
}
function getCastProfileUrl(profilePath: string | null): string {
if (!profilePath) return ""
if (profilePath.includes("/")) {
return getCoverUrl(profilePath, props.item.root_id)
}
const castPath = `.mediahive/people/${profilePath}`
return getCoverUrl(castPath, props.item.root_id)
}
function formatRuntime(minutes: number): string {
const hours = Math.floor(minutes / 60)
const mins = minutes % 60
@@ -656,7 +665,7 @@ function handlePlayVersion(filePath: string | null) {
function handleVersionContextMenu(event: MouseEvent, version: Torrent) {
event.preventDefault()
event.stopPropagation()
const rootId = version.root_id || ((props.item.data as Movie).root_id ?? props.item.root_id)
const rootId = version.root_id || ((props.item.data as MovieUi).root_id ?? props.item.root_id)
versionActionMenu.value = {
visible: true,
x: event.clientX,
@@ -675,7 +684,7 @@ function handleVersionContextMenu(event: MouseEvent, version: Torrent) {
function handleVersionShortcutKeydown(event: KeyboardEvent, version: Torrent) {
if (!version.playable_file) return
const rootId = version.root_id || ((props.item.data as Movie).root_id ?? props.item.root_id)
const rootId = version.root_id || ((props.item.data as MovieUi).root_id ?? props.item.root_id)
const key = event.key.toLowerCase()
if (key === "e" && (event.metaKey || event.ctrlKey)) {
event.preventDefault()
@@ -718,7 +727,7 @@ function handlePlay(filePath: string | null) {
function handleVersionActivate(version: Torrent, event: MouseEvent | KeyboardEvent) {
if (!version.playable_file) return
const rootId = version.root_id || ((props.item.data as Movie).root_id ?? props.item.root_id)
const rootId = version.root_id || ((props.item.data as MovieUi).root_id ?? props.item.root_id)
if (event.altKey) {
handleOpenFolder(version.playable_file, rootId)
return
@@ -218,7 +218,7 @@ function normalizeQualityBadge(value: string): string {
const hasDolbyVision = computed(() => {
return (
props.torrent.has_dolby_vision === true ||
props.torrent.dovi === true ||
hasAnyTag(
dolbyVisionPattern,
props.torrent.quality,
@@ -231,7 +231,7 @@ const hasDolbyVision = computed(() => {
const hasDolbyAtmos = computed(() => {
return (
props.torrent.has_dolby_atmos === true ||
props.torrent.atmos === true ||
hasAnyTag(
dolbyAtmosPattern,
props.torrent.quality,
@@ -244,7 +244,7 @@ const hasDolbyAtmos = computed(() => {
const hasHdr = computed(() => {
return (
props.torrent.is_hdr === true ||
props.torrent.hdr === true ||
hasAnyTag(
hdrPattern,
props.torrent.quality,
+5 -5
View File
@@ -148,9 +148,9 @@
<div class="context-menu-header">
{{ contextMenu.episode.name || `Episode ${contextMenu.episode.episode_number}` }}
</div>
<div v-if="Object.values(contextMenu.episode.torrents || {}).length > 0">
<div v-if="Object.values(contextMenu.episode.files || {}).length > 0">
<ReleaseVersionCard
v-for="(torrent, index) in sortTorrentsByPreference(Object.values(contextMenu.episode.torrents || {}))"
v-for="(torrent, index) in sortTorrentsByPreference(Object.values(contextMenu.episode.files || {}))"
:key="index"
class="context-menu-version"
:torrent="torrent"
@@ -556,8 +556,8 @@ function handleEpisodeHover(key: string, isEntering: boolean) {
// Backdrop URL - only use backdrop_path, fall back to collage (handled in template)
const backdropUrl = computed(() => {
if (props.series.info?.backdrop_path) {
return getCoverUrl(props.series.info.backdrop_path, props.series.root_id)
if (props.series.backdrop_path) {
return getCoverUrl(props.series.backdrop_path, props.series.root_id)
}
return null
})
@@ -643,7 +643,7 @@ function truncate(text: string, maxLength: number): string {
// Handle play
function handlePlay(episode: Episode) {
const playableFile = Object.values(episode.torrents || {})[0]?.playable_file
const playableFile = Object.values(episode.files || {})[0]?.playable_file
if (playableFile) {
emit("play", playableFile)
}
+136 -31
View File
@@ -1,7 +1,9 @@
import { shallowRef, readonly, onUnmounted } from "vue"
import type {
Movie,
MovieUi,
Series,
SeriesUi,
Episode,
Season,
Torrent,
@@ -13,8 +15,8 @@ import type {
interface RootState {
rootId: string
ws: WebSocket | null
movieMap: Map<string, Movie>
seriesMap: Map<string, Series>
movieMap: Map<string, MovieUi>
seriesMap: Map<string, SeriesUi>
connected: boolean
initialized: boolean
pendingMessages: WsMessage[]
@@ -79,18 +81,33 @@ export function useMediaWebSocket() {
else if (res.includes("720") || res === "hd") score += 60
else if (res.includes("480") || res === "sd") score += 40
else if (res.includes("360")) score += 20
if (t.has_dolby_vision) score += 15
if (t.is_hdr) score += 10
if (t.has_dolby_atmos) score += 5
if (t.dovi) score += 15
if (t.hdr) score += 10
if (t.atmos) score += 5
return score
}
function annotateTorrents(
torrents: { [key: string]: Torrent },
function expandPlayablePath(fileKey: string, playableFile: string | null): string | null {
if (!playableFile) return fileKey
if (playableFile.startsWith("concat:") || playableFile.includes("://")) return playableFile
if (playableFile.startsWith(`${fileKey}/`)) return playableFile
if (playableFile.startsWith("/")) return playableFile.replace(/^\/+/, "")
return `${fileKey}/${playableFile}`
}
function annotateFiles(
files: { [key: string]: Torrent },
rootId: string | null,
): { [key: string]: Torrent } {
return Object.fromEntries(
Object.entries(torrents || {}).map(([k, t]) => [k, { ...t, root_id: t.root_id || rootId }]),
Object.entries(files || {}).map(([k, t]) => [
k,
{
...t,
playable_file: expandPlayablePath(k, t.playable_file || null),
root_id: t.root_id || rootId,
},
]),
)
}
@@ -112,13 +129,95 @@ export function useMediaWebSocket() {
return Object.fromEntries(sorted)
}
function mergeMovies(a: Movie, b: Movie): Movie {
const torrentsA = annotateTorrents(a.torrents, a.root_id)
const torrentsB = annotateTorrents(b.torrents, b.root_id)
function withMovieIdentity(id: string, movie: Movie, rootId: string): MovieUi {
return { ...normalizeMovie(movie), id, root_id: rootId }
}
function withSeriesIdentity(id: string, series: Series, rootId: string): SeriesUi {
return { ...normalizeSeries(series), id, root_id: rootId }
}
function normalizeCastMember(member: unknown): {
name: string
character: string | null
profile_path: string | null
gender: string | null
id: number | null
} {
if (Array.isArray(member)) {
return {
name: typeof member[0] === "string" ? member[0] : "",
character: typeof member[1] === "string" ? member[1] : null,
profile_path: typeof member[2] === "string" ? member[2] : null,
gender: typeof member[3] === "string" ? member[3] : null,
id: typeof member[4] === "number" ? member[4] : null,
}
}
const obj = member as {
name?: unknown
character?: unknown
profile_path?: unknown
gender?: unknown
id?: unknown
} | null
return {
name: typeof obj?.name === "string" ? obj.name : "",
character: typeof obj?.character === "string" ? obj.character : null,
profile_path: typeof obj?.profile_path === "string" ? obj.profile_path : null,
gender: typeof obj?.gender === "string" ? obj.gender : null,
id: typeof obj?.id === "number" ? obj.id : null,
}
}
function normalizeSimilarMember(member: unknown): { id: number; title: string } {
if (Array.isArray(member)) {
return {
id: typeof member[0] === "number" ? member[0] : 0,
title: typeof member[1] === "string" ? member[1] : "",
}
}
const obj = member as { id?: unknown; title?: unknown } | null
return {
id: typeof obj?.id === "number" ? obj.id : 0,
title: typeof obj?.title === "string" ? obj.title : "",
}
}
function normalizeInfo<T extends { cast?: unknown; similar?: unknown }>(info: T | null): T | null {
if (!info) return info
let next: T = info
if (Array.isArray((info as { cast?: unknown }).cast)) {
const cast = ((info as { cast?: unknown[] }).cast || []).map(normalizeCastMember)
next = { ...next, cast } as T
}
if (Array.isArray((info as { similar?: unknown }).similar)) {
const similar = ((info as { similar?: unknown[] }).similar || []).map(normalizeSimilarMember)
next = { ...next, similar } as T
}
return next
}
function normalizeMovie(movie: Movie): Movie {
return {
...movie,
info: normalizeInfo(movie.info),
}
}
function normalizeSeries(series: Series): Series {
return {
...series,
info: normalizeInfo(series.info),
}
}
function mergeMovies(a: MovieUi, b: MovieUi): MovieUi {
const filesA = annotateFiles(a.files, a.root_id)
const filesB = annotateFiles(b.files, b.root_id)
return {
...a,
id: getContentHash(a.id),
torrents: mergeTorrentDicts(torrentsA, torrentsB),
files: mergeTorrentDicts(filesA, filesB),
info: a.info || b.info,
cover_path: a.cover_path || b.cover_path,
backdrop_path: a.backdrop_path || b.backdrop_path,
@@ -135,11 +234,11 @@ export function useMediaWebSocket() {
rootIdA: string | null,
rootIdB: string | null,
): Episode {
const torrentsA = annotateTorrents(a.torrents, rootIdA)
const torrentsB = annotateTorrents(b.torrents, rootIdB)
const filesA = annotateFiles(a.files, rootIdA)
const filesB = annotateFiles(b.files, rootIdB)
return {
...a,
torrents: mergeTorrentDicts(torrentsA, torrentsB),
files: mergeTorrentDicts(filesA, filesB),
reel_image: a.reel_image || b.reel_image,
reel_sources: a.reel_sources?.length ? a.reel_sources : b.reel_sources,
}
@@ -162,7 +261,7 @@ export function useMediaWebSocket() {
} else {
episodeMap.set(ep.episode_number, {
...ep,
torrents: annotateTorrents(ep.torrents, rootIdB),
files: annotateFiles(ep.files, rootIdB),
})
}
}
@@ -173,14 +272,14 @@ export function useMediaWebSocket() {
}
}
function mergeSeries(a: Series, b: Series): Series {
function mergeSeries(a: SeriesUi, b: SeriesUi): SeriesUi {
const seasonMap = new Map<number, Season>()
for (const season of a.seasons || []) {
seasonMap.set(season.season_number, {
...season,
episodes: season.episodes.map((ep) => ({
...ep,
torrents: annotateTorrents(ep.torrents, a.root_id),
files: annotateFiles(ep.files, a.root_id),
})),
})
}
@@ -193,7 +292,7 @@ export function useMediaWebSocket() {
...season,
episodes: season.episodes.map((ep) => ({
...ep,
torrents: annotateTorrents(ep.torrents, b.root_id),
files: annotateFiles(ep.files, b.root_id),
})),
})
}
@@ -208,7 +307,7 @@ export function useMediaWebSocket() {
}
}
function mergeItemsByHash<T extends Movie | Series>(items: T[], mergeFn: (a: T, b: T) => T): T[] {
function mergeItemsByHash<T extends MovieUi | SeriesUi>(items: T[], mergeFn: (a: T, b: T) => T): T[] {
const map = new Map<string, T[]>()
for (const item of items) {
const hash = getContentHash(item.id)
@@ -232,8 +331,8 @@ export function useMediaWebSocket() {
}
function buildIndex(): MediaIndex {
const movies: Movie[] = []
const series: Series[] = []
const movies: MovieUi[] = []
const series: SeriesUi[] = []
for (const state of roots.value.values()) {
movies.push(...state.movieMap.values())
series.push(...state.seriesMap.values())
@@ -241,12 +340,8 @@ export function useMediaWebSocket() {
const mergedMovies = mergeItemsByHash(movies, mergeMovies)
const mergedSeries = mergeItemsByHash(series, mergeSeries)
return {
version: 0,
v: 1,
generated_at: new Date().toISOString(),
stats: {
total_movies: mergedMovies.length,
total_series: mergedSeries.length,
},
movies: mergedMovies,
series: mergedSeries,
}
@@ -283,8 +378,12 @@ export function useMediaWebSocket() {
case "init": {
state.movieMap.clear()
state.seriesMap.clear()
for (const m of msg.data.movies) state.movieMap.set(m.id, m)
for (const s of msg.data.series) state.seriesMap.set(s.id, s)
for (const [id, m] of Object.entries(msg.data.movies || {})) {
state.movieMap.set(id, withMovieIdentity(id, m, state.rootId))
}
for (const [id, s] of Object.entries(msg.data.series || {})) {
state.seriesMap.set(id, withSeriesIdentity(id, s, state.rootId))
}
state.initialized = true
// Replay any deltas that arrived before init completed.
@@ -304,9 +403,15 @@ export function useMediaWebSocket() {
}
case "upsert": {
if (msg.kind === "movie") {
state.movieMap.set(msg.item.id, msg.item as Movie)
state.movieMap.set(
msg.id,
withMovieIdentity(msg.id, msg.item as Movie, state.rootId),
)
} else {
state.seriesMap.set(msg.item.id, msg.item as Series)
state.seriesMap.set(
msg.id,
withSeriesIdentity(msg.id, msg.item as Series, state.rootId),
)
}
updateMergedState()
break
+3 -3
View File
@@ -103,9 +103,9 @@ export function sortTorrentsByPreference(torrents: Torrent[]): Torrent[] {
function detectHdrProfile(t: Torrent): HdrProfile {
const text = [t.title, t.quality, t.codec, t.audio].filter(Boolean).join(" ")
const hasDovi = t.has_dolby_vision || /dolby\s*vision|dovi|\bdv\b/i.test(text)
const hasHdr10Plus = /hdr10\+|hdr10plus/i.test(text)
const hasAnyHdr = t.is_hdr || hasHdr10Plus || hasDovi || /\bhdr\b/i.test(text)
const hasDovi = t.dovi || /dolby\s*vision|dovi|\bdv\b/i.test(text)
const hasHdr10Plus = t.hdr10plus || /hdr10\+|hdr10plus/i.test(text)
const hasAnyHdr = t.hdr || hasHdr10Plus || hasDovi || /\bhdr\b/i.test(text)
if (hasDovi) return "dovi"
if (hasHdr10Plus) return "hdr10plus"
+14 -14
View File
@@ -2,8 +2,8 @@
// This file is loaded as a Web Worker, not imported as a module.
import type {
Movie,
Series,
MovieUi,
SeriesUi,
MatchedPerson,
MatchedEpisode,
SearchMatchInfo,
@@ -15,8 +15,8 @@ import type {
export interface SearchIndexMessage {
type: "index"
movies: Movie[]
series: Series[]
movies: MovieUi[]
series: SeriesUi[]
}
export interface SearchQueryMessage {
@@ -55,8 +55,8 @@ export interface SearchResponseMessage {
// Worker state
// ---------------------------------------------------------------------------
let movies: Movie[] = []
let series: Series[] = []
let movies: MovieUi[] = []
let series: SeriesUi[] = []
// ---------------------------------------------------------------------------
// Normalization helpers (mirrored from App.vue)
@@ -204,9 +204,9 @@ function getBestScore(query: string, ...fields: (string | null | undefined)[]):
return bestScore
}
function getMoviePathScore(movie: Movie, query: string): number {
function getMoviePathScore(movie: MovieUi, query: string): number {
const torrentFields: (string | null | undefined)[] = []
for (const torrent of Object.values(movie.torrents || {})) {
for (const torrent of Object.values(movie.files || {})) {
torrentFields.push(torrent.title, torrent.playable_file)
}
let bestScore = 0
@@ -218,11 +218,11 @@ function getMoviePathScore(movie: Movie, query: string): number {
return bestScore
}
function getSeriesPathScore(series: Series, query: string): number {
function getSeriesPathScore(series: SeriesUi, query: string): number {
const torrentFields: (string | null | undefined)[] = []
for (const season of series.seasons || []) {
for (const episode of season.episodes || []) {
for (const torrent of Object.values(episode.torrents || {})) {
for (const torrent of Object.values(episode.files || {})) {
torrentFields.push(torrent.title, torrent.playable_file)
}
}
@@ -498,9 +498,9 @@ function formatMatchedPeople(people: PersonMatch[]): MatchedPerson[] {
// MediaItem conversion (lightweight, without data payload)
// ---------------------------------------------------------------------------
function movieToSearchResult(movie: Movie): SearchResultItem {
const torrents = Object.values(movie.torrents || {})
const resolution = torrents.length > 0 ? torrents[0].resolution : null
function movieToSearchResult(movie: MovieUi): SearchResultItem {
const files = Object.values(movie.files || {})
const resolution = files.length > 0 ? files[0].resolution : null
return {
id: movie.id,
title: movie.title || "Unknown",
@@ -514,7 +514,7 @@ function movieToSearchResult(movie: Movie): SearchResultItem {
}
}
function seriesToSearchResult(series: Series): SearchResultItem {
function seriesToSearchResult(series: SeriesUi): SearchResultItem {
const reelImages: string[] = []
const reelSourceSets: string[][] = []
for (const season of series.seasons || []) {
+33 -17
View File
@@ -7,12 +7,18 @@ export interface CastMember {
character?: string | null
profile_path: string | null
gender?: CastGender | null
id?: number | null
}
export interface Person {
name: string
profile_path: string | null
gender?: CastGender | null
}
export interface SimilarMedia {
id: number
title: string
poster_path: string | null
}
export interface Info {
@@ -28,8 +34,6 @@ export interface Info {
runtime: number | null
status: string | null
tagline: string | null
poster_path: string | null
backdrop_path: string | null
similar: SimilarMedia[] | null
keywords: string[] | null
cast: CastMember[] | null
@@ -50,9 +54,10 @@ export interface Torrent {
audio: string | null
audio_languages: string[] | null
subtitle_languages: string[] | null
is_hdr: boolean
has_dolby_vision: boolean
has_dolby_atmos: boolean
hdr?: boolean
dovi?: boolean
atmos?: boolean
hdr10plus?: boolean
encoder: string | null
size: number | null
added_at: number | null
@@ -60,7 +65,6 @@ export interface Torrent {
}
export interface Movie {
id: string
title: string | null
info: Info | null
year: number | null
@@ -69,8 +73,7 @@ export interface Movie {
backdrop_path: string | null
showreel_images: string[] | null
showreel_source_sets: string[][] | null
torrents: { [key: string]: Torrent }
root_id: string | null
files: { [key: string]: Torrent }
}
export interface Episode {
@@ -84,7 +87,7 @@ export interface Episode {
director: string | null
reel_image: string | null
reel_sources: string[] | null
torrents: { [key: string]: Torrent }
files: { [key: string]: Torrent }
}
export interface Season {
@@ -98,7 +101,6 @@ export interface Season {
}
export interface Series {
id: string
title: string | null
info: Info | null
alternative_titles: string[] | null
@@ -106,6 +108,15 @@ export interface Series {
cover_path: string | null
backdrop_path: string | null
seasons: Season[]
}
export interface MovieUi extends Movie {
id: string
root_id: string | null
}
export interface SeriesUi extends Series {
id: string
root_id: string | null
}
@@ -117,11 +128,10 @@ export interface MediaStats {
}
export interface MediaIndex {
version: number
v: number
generated_at: string
stats: MediaStats
movies: Movie[]
series: Series[]
movies: MovieUi[]
series: SeriesUi[]
}
export type MediaType = "movies" | "series" | "episode"
@@ -167,7 +177,7 @@ export interface MediaItem {
// Episode with parent series info for standalone display
export interface EpisodeWithSeries {
episode: Episode
series: Series
series: SeriesUi
seasonNumber: number
}
@@ -182,13 +192,19 @@ export interface TaskInfo {
// WebSocket message types (matching server msgspec tagged structs)
export interface WsInitMessage {
type: "init"
data: { movies: Movie[]; series: Series[] }
data: {
movies: Record<string, Movie>
series: Record<string, Series>
people?: Record<string, Person> | Record<number, Person>
}
}
export interface WsUpsertMessage {
type: "upsert"
kind: "movie" | "series"
id: string
item: Movie | Series
people?: Record<string, Person> | Record<number, Person>
}
export interface WsRemoveMessage {
+15 -6
View File
@@ -1,11 +1,12 @@
"""TMDb image downloading functions."""
import re
from pathlib import Path
import httpx
from aiopathlib import AsyncPath
from mediahive.hivescan.utils import get_media_folder_path, sanitize_filename
from mediahive.hivescan.utils import get_media_folder_path
# TMDb image configuration
TMDB_IMAGE_BASE = "https://image.tmdb.org/t/p"
@@ -124,20 +125,28 @@ async def download_cast_profile(
profile_path: str,
media_folder: Path,
cast_name: str,
cast_index: int,
person_id: int | None,
size: str = DEFAULT_PROFILE_SIZE,
) -> str | None:
"""Download a cached cast profile image from TMDb."""
if not profile_path:
return None
cast_dir = media_folder / "cast"
safe_name = sanitize_filename(cast_name) or f"cast-{cast_index + 1:02d}"
output_path = cast_dir / f"{cast_index + 1:02d}-{safe_name}.jpg"
# Shared people cache avoids duplicating identical actor images per title.
people_dir = media_folder.parent.parent / "people"
safe_name = _slugify_person_name(cast_name) or "Unknown"
person_suffix = str(person_id) if person_id is not None else "unknown"
output_path = people_dir / f"{safe_name}-{person_suffix}.jpg"
if await AsyncPath(output_path).exists():
return output_path.as_posix()
await AsyncPath(cast_dir).mkdir(parents=True, exist_ok=True)
await AsyncPath(people_dir).mkdir(parents=True, exist_ok=True)
url = f"{TMDB_IMAGE_BASE}/{size}{profile_path}"
return await _download_image(url, output_path, f"cast profile for {cast_name}")
def _slugify_person_name(name: str) -> str:
"""Slugify a person name preserving capitals and hyphens; use dots as separators."""
slug = re.sub(r"[^0-9A-Za-z-]+", ".", name)
return re.sub(r"\.+", ".", slug).strip(".")
+226 -117
View File
@@ -1,8 +1,8 @@
"""Media index generation — async generators for continuous scanning."""
import asyncio
import hashlib
import logging
import re
from collections.abc import AsyncIterator
from pathlib import Path
@@ -33,6 +33,8 @@ from mediahive.hivescan.tmdb_client import (
)
from mediahive.hivescan.utils import (
RESOLUTION_PRIORITY,
build_movie_id,
build_series_id,
get_added_timestamp,
get_directory_size,
get_media_folder_path,
@@ -46,13 +48,50 @@ from mediahive.models.data import (
Series,
Torrent,
)
from mediahive.models.tmdb import CastMember, EpisodeInfo, Info, SeasonInfo
from mediahive.models.tmdb import EpisodeInfo, Info, Person, SeasonInfo
logger = logging.getLogger("hivescan.indexer")
_HDR10PLUS_RE = re.compile(r"hdr10\+|hdr10plus", re.IGNORECASE)
def _infer_hdr10plus(*values: str | None) -> bool:
"""Infer HDR10+ from parsed release strings when probe data is ambiguous."""
text = " ".join(v for v in values if v)
return bool(_HDR10PLUS_RE.search(text))
def _compact_playable_file(file_key: str, playable_file: str | None) -> str | None:
"""Store playable paths compactly relative to the file key when possible."""
if not playable_file:
return None
if playable_file == file_key:
return None
prefix = f"{file_key}/"
if playable_file.startswith(prefix):
rel = playable_file[len(prefix) :]
return rel or None
return playable_file
def _expand_playable_file(file_key: str, playable_file: str | None) -> str | None:
"""Expand compact playable paths back to media-root-relative paths."""
if not playable_file:
return file_key
if playable_file.startswith("concat:") or "://" in playable_file:
return playable_file
prefix = f"{file_key}/"
if playable_file.startswith(prefix):
return playable_file
if playable_file.startswith("/"):
return playable_file.lstrip("/")
return f"{file_key}/{playable_file}"
async def _build_torrent_info(
item: ParsedContent, media_root: str | None = None
item: ParsedContent,
file_key: str,
media_root: str | None = None,
) -> Torrent:
"""Build torrent info for a single torrent."""
playable_file = await find_playable_file(item.path)
@@ -69,9 +108,17 @@ async def _build_torrent_info(
size = item.content_hash.size if item.content_hash else None
added_at = await get_added_timestamp(item.path)
playable_rel = make_relative_path(playable_file, media_root)
hdr10plus_from_text = _infer_hdr10plus(
item.title,
item.quality,
item.codec,
item.audio,
playable_rel,
)
return Torrent(
title=item.title,
playable_file=make_relative_path(playable_file, media_root),
playable_file=_compact_playable_file(file_key, playable_rel),
resolution=(probe_info.resolution if probe_info else None) or item.resolution,
quality=item.quality,
network=item.network,
@@ -79,48 +126,48 @@ async def _build_torrent_info(
audio=item.audio,
audio_languages=probe_info.audio_languages if probe_info else None,
subtitle_languages=probe_info.subtitle_languages if probe_info else None,
is_hdr=probe_info.is_hdr if probe_info else False,
has_dolby_vision=probe_info.has_dolby_vision if probe_info else False,
has_dolby_atmos=probe_info.has_dolby_atmos if probe_info else False,
hdr=probe_info.hdr if probe_info else False,
dovi=probe_info.dovi if probe_info else False,
atmos=probe_info.atmos if probe_info else False,
hdr10plus=(probe_info.hdr10plus if probe_info else False)
or hdr10plus_from_text,
encoder=item.encoder,
size=size,
added_at=added_at,
)
async def _cache_cast_profiles(
async def _cache_people_profiles(
info: Info | None,
people: dict[int, Person],
media_folder: Path,
media_root: str | None = None,
) -> Info | None:
"""Replace TMDb cast profile paths with cached local image paths."""
) -> tuple[Info | None, dict[int, Person]]:
"""Cache people profile images and keep people payload filename-only."""
_ = media_root
if not info or not info.cast:
return info
return info, people
cached_cast = []
for index, member in enumerate(info.cast):
local_profile_path = None
if member.profile_path:
downloaded_path = await download_cast_profile(
member.profile_path,
media_folder,
member.name,
index,
)
if downloaded_path:
local_profile_path = make_relative_path(downloaded_path, media_root)
cached_cast.append(
CastMember(
name=member.name,
character=member.character or None,
profile_path=local_profile_path,
gender=member.gender,
)
for cast_credit in info.cast:
if cast_credit.id is None:
continue
person = people.get(cast_credit.id)
if person is None or not person.profile_path:
continue
downloaded_path = await download_cast_profile(
person.profile_path,
media_folder,
person.name,
cast_credit.id,
)
if downloaded_path:
people[cast_credit.id] = Person(
name=person.name,
profile_path=Path(downloaded_path).name,
gender=person.gender,
)
info.cast = cached_cast
return info
return info, people
async def _collect_episode_files(
@@ -156,9 +203,10 @@ async def _collect_episode_files(
"probed_resolution": probe.resolution,
"audio_languages": probe.audio_languages,
"subtitle_languages": probe.subtitle_languages,
"is_hdr": probe.is_hdr,
"has_dolby_vision": probe.has_dolby_vision,
"has_dolby_atmos": probe.has_dolby_atmos,
"hdr": probe.hdr,
"dovi": probe.dovi,
"atmos": probe.atmos,
"hdr10plus": probe.hdr10plus,
"resolution": item.resolution,
"quality": item.quality,
"network": item.network,
@@ -203,9 +251,10 @@ async def _collect_episode_files(
"probed_resolution": probe.resolution,
"audio_languages": probe.audio_languages,
"subtitle_languages": probe.subtitle_languages,
"is_hdr": probe.is_hdr,
"has_dolby_vision": probe.has_dolby_vision,
"has_dolby_atmos": probe.has_dolby_atmos,
"hdr": probe.hdr,
"dovi": probe.dovi,
"atmos": probe.atmos,
"hdr10plus": probe.hdr10plus,
"resolution": item.resolution,
"quality": item.quality,
"network": item.network,
@@ -263,12 +312,20 @@ def _build_episodes_data(
series_title,
))
torrents = {}
files = {}
for f in episode_files:
relpath = make_relative_path(f["torrent_path"], media_root)
torrents[relpath] = Torrent(
playable_rel = make_relative_path(f["path"], media_root)
hdr10plus_from_text = _infer_hdr10plus(
f.get("torrent_title"),
f.get("quality"),
f.get("codec"),
f.get("audio"),
playable_rel,
)
files[relpath] = Torrent(
title=f["torrent_title"],
playable_file=make_relative_path(f["path"], media_root),
playable_file=_compact_playable_file(relpath, playable_rel),
resolution=f.get("probed_resolution") or f.get("resolution"),
quality=f.get("quality"),
network=f.get("network"),
@@ -276,9 +333,10 @@ def _build_episodes_data(
audio=f.get("audio"),
audio_languages=f.get("audio_languages"),
subtitle_languages=f.get("subtitle_languages"),
is_hdr=bool(f.get("is_hdr")),
has_dolby_vision=bool(f.get("has_dolby_vision")),
has_dolby_atmos=bool(f.get("has_dolby_atmos")),
hdr=bool(f.get("hdr")),
dovi=bool(f.get("dovi")),
atmos=bool(f.get("atmos")),
hdr10plus=bool(f.get("hdr10plus")) or hdr10plus_from_text,
encoder=f.get("encoder"),
size=f.get("size"),
)
@@ -294,7 +352,7 @@ def _build_episodes_data(
director=tmdb_ep.director if tmdb_ep else None,
reel_image=reel_path,
reel_sources=reel_sources or None,
torrents=torrents,
files=files,
)
episodes_data.append(episode_data)
@@ -384,17 +442,24 @@ async def _process_movies(
generate_showreels: bool,
media_root: str | None = None,
root_id: str | None = None,
) -> AsyncIterator[tuple[Movie, tuple[str, Path, str] | None]]:
) -> AsyncIterator[tuple[str, Movie, tuple[str, Path, str] | None, dict[int, Person]]]:
"""Async generator that processes all movies.
Yields:
Tuples of ``(Movie, showreel_task_or_None)`` as each movie is processed.
"""
_ = root_id
# In-memory cache for TMDb lookups
movie_tmdb_cache: dict[str, Info | None] = {}
movie_tmdb_cache: dict[
str,
tuple[Info, str | None, str | None, dict[int, Person]] | None,
] = {}
async def get_movie_tmdb(title: str, year: int | None) -> Info | None:
async def get_movie_tmdb(
title: str,
year: int | None,
) -> tuple[Info, str | None, str | None, dict[int, Person]] | None:
cache_key = f"{title.lower()}:{year}"
if cache_key in movie_tmdb_cache:
return movie_tmdb_cache[cache_key]
@@ -444,16 +509,22 @@ async def _process_movies(
first_item.year,
)
tmdb_info = await get_movie_tmdb(first_item.title, first_item.year)
tmdb_result = await get_movie_tmdb(first_item.title, first_item.year)
if tmdb_info and tmdb_info.tmdb_id:
if tmdb_result and tmdb_result[0].tmdb_id:
tmdb_info, poster_path_ref, backdrop_path_ref, people = tmdb_result
if tmdb_info.tmdb_id not in tmdb_movie_groups:
tmdb_movie_groups[tmdb_info.tmdb_id] = {
"tmdb_info": tmdb_info,
"poster_path_ref": poster_path_ref,
"backdrop_path_ref": backdrop_path_ref,
"people": people,
"items": [],
"torrent_titles": set(),
"year": first_item.year,
}
else:
tmdb_movie_groups[tmdb_info.tmdb_id]["people"].update(people)
tmdb_movie_groups[tmdb_info.tmdb_id]["items"].extend(items)
tmdb_movie_groups[tmdb_info.tmdb_id]["torrent_titles"].add(first_item.title)
else:
@@ -467,15 +538,17 @@ async def _process_movies(
no_tmdb_movie_groups[key]["items"].extend(items)
# Process movies with TMDb info — yield each as ready
for tmdb_id, group_data in tmdb_movie_groups.items():
for group_data in tmdb_movie_groups.values():
tmdb_info = group_data["tmdb_info"]
poster_path_ref = group_data["poster_path_ref"]
backdrop_path_ref = group_data["backdrop_path_ref"]
people = group_data["people"]
items = group_data["items"]
torrent_titles = group_data["torrent_titles"]
year = group_data["year"]
display_title = tmdb_info.title
content_hash = hashlib.md5(f"movie:{tmdb_id}".encode()).hexdigest()[:12]
item_id = content_hash
item_id = build_movie_id(display_title, year)
media_folder = get_media_folder_path(display_title, year, "movie", cover_dir)
# Find/download cover
@@ -487,42 +560,52 @@ async def _process_movies(
cover_path = await find_cover_image(tt, year, "movie", cover_dir)
if cover_path:
break
if not cover_path and tmdb_info.poster_path:
if not cover_path and poster_path_ref:
cover_path = await download_cover_image(
tmdb_info.poster_path, display_title, year, "movie", cover_dir
poster_path_ref,
display_title,
year,
"movie",
cover_dir,
)
tmdb_info = await _cache_cast_profiles(tmdb_info, media_folder, media_root)
tmdb_info, people = await _cache_people_profiles(
tmdb_info,
people,
media_folder,
media_root,
)
torrents = {}
files = {}
for item in items:
relpath = make_relative_path(item.path.as_posix(), media_root)
torrent = await _build_torrent_info(item, media_root)
torrents[relpath] = torrent
torrent = await _build_torrent_info(item, relpath, media_root)
files[relpath] = torrent
sort_by_quality(list(torrents.values()))
sort_by_quality(list(files.values()))
# Queue showreel generation
showreel_paths = []
showreel_source_sets = []
showreel_task = None
if generate_showreels and torrents:
if generate_showreels and files:
# Find the best version for showreel (highest quality)
best_relpath = max(
torrents.keys(),
files.keys(),
key=lambda k: (
RESOLUTION_PRIORITY.get(torrents[k].resolution or "", 0),
torrents[k].size or 0,
RESOLUTION_PRIORITY.get(files[k].resolution or "", 0),
files[k].size or 0,
k,
),
)
best_version = torrents[best_relpath]
if best_version.playable_file and not best_version.playable_file.endswith(
".ifo"
):
best_version = files[best_relpath]
best_playable = _expand_playable_file(
best_relpath, best_version.playable_file
)
if best_playable and not best_playable.endswith(".ifo"):
abs_playable = (
(Path(media_root) / best_version.playable_file).as_posix()
(Path(media_root) / best_playable).as_posix()
if media_root
else best_version.playable_file
else best_playable
)
showreel_source_sets = get_existing_showreel_source_sets(
media_folder, media_root=Path(media_root) if media_root else None
@@ -534,16 +617,19 @@ async def _process_movies(
# Download backdrop
backdrop_path = None
if fetch_covers and tmdb_info.backdrop_path:
if fetch_covers and backdrop_path_ref:
backdrop_path = await download_backdrop_image(
tmdb_info.backdrop_path, display_title, year, "movie", cover_dir
backdrop_path_ref,
display_title,
year,
"movie",
cover_dir,
)
version_timestamps = [v.added_at for v in torrents.values() if v.added_at]
version_timestamps = [v.added_at for v in files.values() if v.added_at]
newest = max(version_timestamps) if version_timestamps else None
movie = Movie(
id=item_id,
title=display_title,
info=tmdb_info,
year=year,
@@ -552,18 +638,16 @@ async def _process_movies(
backdrop_path=make_relative_path(backdrop_path, media_root),
showreel_images=showreel_paths or None,
showreel_source_sets=showreel_source_sets or None,
torrents=torrents,
root_id=root_id,
files=files,
)
yield movie, showreel_task
yield item_id, movie, showreel_task, people
# Process movies without TMDb info
for group_data in no_tmdb_movie_groups.values():
items = group_data["items"]
title = group_data["title"]
year = group_data["year"]
content_hash = hashlib.md5(f"movie:{title}:{year}".encode()).hexdigest()[:12]
item_id = content_hash
item_id = build_movie_id(title, year)
cover_path = (
await find_cover_image(title, year, "movie", cover_dir)
@@ -571,36 +655,39 @@ async def _process_movies(
else None
)
torrents = {}
files = {}
for item in items:
relpath = make_relative_path(item.path.as_posix(), media_root)
torrent = await _build_torrent_info(item, media_root)
torrents[relpath] = torrent
torrent = await _build_torrent_info(item, relpath, media_root)
files[relpath] = torrent
sort_by_quality(list(torrents.values()))
sort_by_quality(list(files.values()))
showreel_paths = []
showreel_source_sets = []
showreel_task = None
if generate_showreels and torrents:
if generate_showreels and files:
# Find the best version for showreel (highest quality)
best_relpath = max(
torrents.keys(),
files.keys(),
key=lambda k: (
RESOLUTION_PRIORITY.get(torrents[k].resolution or "", 0),
torrents[k].size or 0,
RESOLUTION_PRIORITY.get(files[k].resolution or "", 0),
files[k].size or 0,
k,
),
)
best_version = torrents[best_relpath]
if best_version.playable_file and not best_version.playable_file.endswith((
best_version = files[best_relpath]
best_playable = _expand_playable_file(
best_relpath, best_version.playable_file
)
if best_playable and not best_playable.endswith((
".bdmv",
".ifo",
)):
abs_playable = (
(Path(media_root) / best_version.playable_file).as_posix()
(Path(media_root) / best_playable).as_posix()
if media_root
else best_version.playable_file
else best_playable
)
media_folder = get_media_folder_path(title, year, "movie", cover_dir)
showreel_source_sets = get_existing_showreel_source_sets(
@@ -613,21 +700,19 @@ async def _process_movies(
)
showreel_task = (abs_playable, media_folder, title)
version_timestamps = [v.added_at for v in torrents.values() if v.added_at]
version_timestamps = [v.added_at for v in files.values() if v.added_at]
newest = max(version_timestamps) if version_timestamps else None
movie = Movie(
id=item_id,
title=title,
year=year,
newest=newest,
cover_path=make_relative_path(cover_path, media_root),
showreel_images=showreel_paths or None,
showreel_source_sets=showreel_source_sets or None,
torrents=torrents,
root_id=root_id,
files=files,
)
yield movie, showreel_task
yield item_id, movie, showreel_task, {}
async def _process_series(
@@ -637,18 +722,26 @@ async def _process_series(
generate_showreels: bool,
media_root: str | None = None,
root_id: str | None = None,
) -> AsyncIterator[tuple[Series, list[tuple[str, Path, int, int, str]]]]:
) -> AsyncIterator[
tuple[str, Series, list[tuple[str, Path, int, int, str]], dict[int, Person]]
]:
"""Async generator that processes all series.
Yields:
Tuples of ``(Series, episode_reel_tasks)`` as each series is processed.
"""
_ = root_id
# In-memory cache for TMDb lookups
series_tmdb_cache: dict[str, Info | None] = {}
series_tmdb_cache: dict[
str,
tuple[Info, str | None, str | None, dict[int, Person]] | None,
] = {}
season_cache: dict[tuple[int, int], SeasonInfo | None] = {}
async def get_series_tmdb(title: str) -> Info | None:
async def get_series_tmdb(
title: str,
) -> tuple[Info, str | None, str | None, dict[int, Person]] | None:
cache_key = title.lower()
if cache_key in series_tmdb_cache:
return series_tmdb_cache[cache_key]
@@ -694,15 +787,21 @@ async def _process_series(
first_item = items[0]
logger.debug(" [%d/%d] %s", idx, len(series_groups), first_item.title)
tmdb_info = await get_series_tmdb(first_item.title)
tmdb_result = await get_series_tmdb(first_item.title)
if tmdb_info and tmdb_info.tmdb_id:
if tmdb_result and tmdb_result[0].tmdb_id:
tmdb_info, poster_path_ref, backdrop_path_ref, people = tmdb_result
if tmdb_info.tmdb_id not in tmdb_groups:
tmdb_groups[tmdb_info.tmdb_id] = {
"tmdb_info": tmdb_info,
"poster_path_ref": poster_path_ref,
"backdrop_path_ref": backdrop_path_ref,
"people": people,
"items": [],
"torrent_titles": set(),
}
else:
tmdb_groups[tmdb_info.tmdb_id]["people"].update(people)
tmdb_groups[tmdb_info.tmdb_id]["items"].extend(items)
tmdb_groups[tmdb_info.tmdb_id]["torrent_titles"].add(first_item.title)
else:
@@ -714,12 +813,14 @@ async def _process_series(
# Process series with TMDb info — yield each as ready
for series_idx, (tmdb_id, group_data) in enumerate(tmdb_groups.items(), 1):
tmdb_info = group_data["tmdb_info"]
poster_path_ref = group_data["poster_path_ref"]
backdrop_path_ref = group_data["backdrop_path_ref"]
people = group_data["people"]
items = group_data["items"]
torrent_titles = group_data["torrent_titles"]
display_title = tmdb_info.title
content_hash = hashlib.md5(f"series:{tmdb_id}".encode()).hexdigest()[:12]
series_id = content_hash
series_id = build_series_id(display_title)
logger.debug(" [%d/%d] %s", series_idx, len(tmdb_groups), display_title)
@@ -736,17 +837,30 @@ async def _process_series(
cover_path = await find_cover_image(tt, None, "series", cover_dir)
if cover_path:
break
if not cover_path and tmdb_info.poster_path:
if not cover_path and poster_path_ref:
cover_path = await download_cover_image(
tmdb_info.poster_path, display_title, None, "series", cover_dir
poster_path_ref,
display_title,
None,
"series",
cover_dir,
)
tmdb_info = await _cache_cast_profiles(tmdb_info, series_folder, media_root)
tmdb_info, people = await _cache_people_profiles(
tmdb_info,
people,
series_folder,
media_root,
)
# Download backdrop
backdrop_path = None
if fetch_covers and tmdb_info.backdrop_path:
if fetch_covers and backdrop_path_ref:
backdrop_path = await download_backdrop_image(
tmdb_info.backdrop_path, display_title, None, "series", cover_dir
backdrop_path_ref,
display_title,
None,
"series",
cover_dir,
)
# Collect and build episode data
@@ -776,7 +890,6 @@ async def _process_series(
newest = max(item_timestamps) if item_timestamps else None
series = Series(
id=series_id,
title=display_title,
info=tmdb_info,
alternative_titles=different_titles or None,
@@ -784,16 +897,14 @@ async def _process_series(
cover_path=make_relative_path(cover_path, media_root),
backdrop_path=make_relative_path(backdrop_path, media_root),
seasons=seasons_data,
root_id=root_id,
)
yield series, ep_reel_tasks
yield series_id, series, ep_reel_tasks, people
# Process series without TMDb info
for group_data in no_tmdb_groups.values():
items = group_data["items"]
title = group_data["title"]
content_hash = hashlib.md5(f"series:{title}".encode()).hexdigest()[:12]
series_id = content_hash
series_id = build_series_id(title)
cover_path = (
await find_cover_image(title, None, "series", cover_dir)
@@ -825,11 +936,9 @@ async def _process_series(
newest = max(item_timestamps) if item_timestamps else None
series = Series(
id=series_id,
title=title,
newest=newest,
cover_path=make_relative_path(cover_path, media_root),
seasons=seasons_data,
root_id=root_id,
)
yield series, ep_reel_tasks
yield series_id, series, ep_reel_tasks, {}
+28 -9
View File
@@ -418,7 +418,7 @@ class RootScanner:
)
)
)
async for movie, showreel_task in _process_movies(
async for movie_id, movie, showreel_task, people in _process_movies(
categories,
self._output_dir,
fetch_covers=True,
@@ -426,9 +426,21 @@ class RootScanner:
media_root=media_root_str,
root_id=self.root_id,
):
await self._send(Upsert(kind="movie", item=movie))
await self._send(
Upsert(
kind="movie",
id=movie_id,
item=movie,
people=people or None,
)
)
if showreel_task:
await self._showreel_queue.put(("movie", showreel_task, movie))
await self._showreel_queue.put((
"movie",
movie_id,
showreel_task,
movie,
))
logger.info(
"[%d/%d] Movie: %s (showreel queued, queue=%d)",
processed + 1,
@@ -468,7 +480,7 @@ class RootScanner:
)
)
)
async for series, ep_reel_tasks in _process_series(
async for series_id, series, ep_reel_tasks, people in _process_series(
categories,
self._output_dir,
fetch_covers=True,
@@ -476,9 +488,16 @@ class RootScanner:
media_root=media_root_str,
root_id=self.root_id,
):
await self._send(Upsert(kind="series", item=series))
await self._send(
Upsert(
kind="series",
id=series_id,
item=series,
people=people or None,
)
)
for task in ep_reel_tasks:
await self._showreel_queue.put(("episode", task, series))
await self._showreel_queue.put(("episode", series_id, task, series))
if ep_reel_tasks:
logger.info(
"[%d/%d] Series: %s (%d episode reels queued, queue=%d)",
@@ -563,7 +582,7 @@ class RootScanner:
while True:
try:
kind, task_data, item = await self._showreel_queue.get()
kind, item_id, task_data, item = await self._showreel_queue.get()
task_id = f"showreel-{uuid.uuid4().hex[:8]}"
remaining = self._showreel_queue.qsize()
@@ -603,7 +622,7 @@ class RootScanner:
paths = [sources[0] for sources in source_sets if sources]
movie.showreel_images = paths or None
movie.showreel_source_sets = source_sets or None
await self._send(Upsert(kind="movie", item=movie))
await self._send(Upsert(kind="movie", id=item_id, item=movie))
await self._send(
Task(
data=TaskInfo(
@@ -688,7 +707,7 @@ class RootScanner:
)
)
episode.reel_sources = reel_sources or None
await self._send(Upsert(kind="series", item=series))
await self._send(Upsert(kind="series", id=item_id, item=series))
await self._send(
Task(
data=TaskInfo(
+15 -7
View File
@@ -414,10 +414,11 @@ class MediaProbeInfo:
duration: float | None = None
width: int | None = None
height: int | None = None
is_hdr: bool = False
hdr: bool = False
dovi_profile: int | None = None
has_dolby_vision: bool = False
has_dolby_atmos: bool = False
dovi: bool = False
atmos: bool = False
hdr10plus: bool = False
resolution: str | None = None
audio_languages: list[str] | None = None
subtitle_languages: list[str] | None = None
@@ -478,18 +479,25 @@ async def probe_media_info(video_path: str) -> MediaProbeInfo:
info.width, info.height
)
info.is_hdr = (
info.hdr = (
"smpte2084" in lower_text
or "arib-std-b67" in lower_text
or "bt2020" in lower_text
)
info.hdr10plus = (
"hdr10+" in lower_text
or "hdr10plus" in lower_text
or "smpte st 2094" in lower_text
or "smpte-st-2094" in lower_text
or "dynamic hdr" in lower_text
)
dovi_match = _dovi_profile_re.search(text)
if dovi_match:
info.dovi_profile = int(dovi_match.group(1))
elif "dvhe" in lower_text or "dvh1" in lower_text or "dav1" in lower_text:
info.dovi_profile = 7
info.has_dolby_vision = info.dovi_profile is not None
info.dovi = info.dovi_profile is not None
audio_languages: list[str] = []
for line in text.splitlines():
@@ -502,7 +510,7 @@ async def probe_media_info(video_path: str) -> MediaProbeInfo:
if lang and lang not in audio_languages:
audio_languages.append(lang)
if "atmos" in line.lower():
info.has_dolby_atmos = True
info.atmos = True
info.audio_languages = audio_languages or None
subtitle_languages: list[str] = []
@@ -551,7 +559,7 @@ async def is_hdr_video(video_path: str) -> bool:
Returns True if the video has HDR metadata (bt2020, SMPTE ST 2084, etc.)
"""
try:
return (await probe_media_info(video_path)).is_hdr
return (await probe_media_info(video_path)).hdr
except OSError, ValueError, RuntimeError:
return False
+111 -85
View File
@@ -13,9 +13,10 @@ import httpx
from aiopathlib import AsyncPath
from mediahive.models.tmdb import (
CastMember,
CastCredit,
EpisodeInfo,
Info,
Person,
SeasonInfo,
SimilarMedia,
)
@@ -371,7 +372,10 @@ async def _search_movie_with_fallbacks(title: str, year: int | None) -> dict | N
return None
async def fetch_movie_info(title: str, year: int | None = None) -> Info | None:
async def fetch_movie_info(
title: str,
year: int | None = None,
) -> tuple[Info, str | None, str | None, dict[int, Person]] | None:
"""Fetch comprehensive movie info from TMDb."""
data = await _search_movie_with_fallbacks(title, year)
@@ -385,16 +389,19 @@ async def fetch_movie_info(title: str, year: int | None = None) -> Info | None:
details = await fetch_movie_details(movie_id)
if not details:
# Fall back to basic info from search
return Info(
tmdb_id=movie_id,
title=result.get("title"),
original_title=result.get("original_title"),
rating=result.get("vote_average"),
vote_count=result.get("vote_count"),
overview=result.get("overview"),
poster_path=result.get("poster_path"),
backdrop_path=result.get("backdrop_path"),
release_date=result.get("release_date"),
return (
Info(
tmdb_id=movie_id,
title=result.get("title"),
original_title=result.get("original_title"),
rating=result.get("vote_average"),
vote_count=result.get("vote_count"),
overview=result.get("overview"),
release_date=result.get("release_date"),
),
result.get("poster_path"),
result.get("backdrop_path"),
{},
)
# Extract genres
@@ -421,15 +428,22 @@ async def fetch_movie_info(title: str, year: int | None = None) -> Info | None:
# Extract full cast
credits_data = details.get("credits", {})
cast_data = credits_data.get("cast", [])
cast = [
CastMember(
name=c["name"],
character=c.get("character", ""),
profile_path=c.get("profile_path"),
gender=_map_person_gender(c.get("gender")),
cast: list[CastCredit] = []
people: dict[int, Person] = {}
for c in cast_data:
person_id = c.get("id")
cast.append(
CastCredit(
character=c.get("character", "") or None,
id=person_id if isinstance(person_id, int) else None,
)
)
for c in cast_data
]
if isinstance(person_id, int):
people[person_id] = Person(
name=c.get("name") or "",
profile_path=c.get("profile_path"),
gender=_map_person_gender(c.get("gender")),
)
# Extract director from crew
crew = credits_data.get("crew", [])
@@ -438,30 +452,30 @@ async def fetch_movie_info(title: str, year: int | None = None) -> Info | None:
# Extract similar movies (limit to 10)
similar_data = details.get("similar", {}).get("results", [])[:10]
similar = [
SimilarMedia(id=s["id"], title=s["title"], poster_path=s.get("poster_path"))
for s in similar_data
]
similar = [SimilarMedia(id=s["id"], title=s["title"]) for s in similar_data]
return Info(
tmdb_id=movie_id,
title=details.get("title"),
original_title=details.get("original_title"),
alternative_titles=alternative_titles,
rating=details.get("vote_average"),
vote_count=details.get("vote_count"),
overview=details.get("overview"),
genres=genres or None,
release_date=details.get("release_date"),
runtime=details.get("runtime"),
status=details.get("status"),
tagline=details.get("tagline"),
poster_path=details.get("poster_path"),
backdrop_path=details.get("backdrop_path"),
similar=similar or None,
keywords=keywords or None,
cast=cast or None,
director=director,
return (
Info(
tmdb_id=movie_id,
title=details.get("title"),
original_title=details.get("original_title"),
alternative_titles=alternative_titles,
rating=details.get("vote_average"),
vote_count=details.get("vote_count"),
overview=details.get("overview"),
genres=genres or None,
release_date=details.get("release_date"),
runtime=details.get("runtime"),
status=details.get("status"),
tagline=details.get("tagline"),
similar=similar or None,
keywords=keywords or None,
cast=cast or None,
director=director,
),
details.get("poster_path"),
details.get("backdrop_path"),
people,
)
@@ -487,7 +501,9 @@ async def _search_series_with_fallbacks(title: str) -> dict | None:
return None
async def fetch_series_info(title: str) -> Info | None:
async def fetch_series_info(
title: str,
) -> tuple[Info, str | None, str | None, dict[int, Person]] | None:
"""Fetch comprehensive TV series info from TMDb."""
data = await _search_series_with_fallbacks(title)
@@ -501,15 +517,18 @@ async def fetch_series_info(title: str) -> Info | None:
details = await fetch_series_details(series_id)
if not details:
# Fall back to basic info from search
return Info(
tmdb_id=series_id,
title=result.get("name"),
original_title=result.get("original_name"),
rating=result.get("vote_average"),
vote_count=result.get("vote_count"),
overview=result.get("overview"),
poster_path=result.get("poster_path"),
backdrop_path=result.get("backdrop_path"),
return (
Info(
tmdb_id=series_id,
title=result.get("name"),
original_title=result.get("original_name"),
rating=result.get("vote_average"),
vote_count=result.get("vote_count"),
overview=result.get("overview"),
),
result.get("poster_path"),
result.get("backdrop_path"),
{},
)
# Extract genres
@@ -522,15 +541,22 @@ async def fetch_series_info(title: str) -> Info | None:
# Extract full cast
credits_data = details.get("credits", {})
cast_data = credits_data.get("cast", [])
cast = [
CastMember(
name=c["name"],
character=c.get("character", ""),
profile_path=c.get("profile_path"),
gender=_map_person_gender(c.get("gender")),
cast: list[CastCredit] = []
people: dict[int, Person] = {}
for c in cast_data:
person_id = c.get("id")
cast.append(
CastCredit(
character=c.get("character", "") or None,
id=person_id if isinstance(person_id, int) else None,
)
)
for c in cast_data
]
if isinstance(person_id, int):
people[person_id] = Person(
name=c.get("name") or "",
profile_path=c.get("profile_path"),
gender=_map_person_gender(c.get("gender")),
)
# Extract creators
creators = [c["name"] for c in details.get("created_by", [])]
@@ -540,32 +566,32 @@ async def fetch_series_info(title: str) -> Info | None:
# Extract similar series (limit to 10)
similar_data = details.get("similar", {}).get("results", [])[:10]
similar = [
SimilarMedia(id=s["id"], title=s["name"], poster_path=s.get("poster_path"))
for s in similar_data
]
similar = [SimilarMedia(id=s["id"], title=s["name"]) for s in similar_data]
# Get first air date
first_air_date = details.get("first_air_date")
return Info(
tmdb_id=series_id,
title=details.get("name"),
original_title=details.get("original_name"),
rating=details.get("vote_average"),
vote_count=details.get("vote_count"),
overview=details.get("overview"),
genres=genres or None,
release_date=first_air_date,
status=details.get("status"),
tagline=details.get("tagline"),
poster_path=details.get("poster_path"),
backdrop_path=details.get("backdrop_path"),
similar=similar or None,
keywords=keywords or None,
cast=cast or None,
creators=creators or None,
number_of_seasons=details.get("number_of_seasons"),
number_of_episodes=details.get("number_of_episodes"),
networks=networks or None,
return (
Info(
tmdb_id=series_id,
title=details.get("name"),
original_title=details.get("original_name"),
rating=details.get("vote_average"),
vote_count=details.get("vote_count"),
overview=details.get("overview"),
genres=genres or None,
release_date=first_air_date,
status=details.get("status"),
tagline=details.get("tagline"),
similar=similar or None,
keywords=keywords or None,
cast=cast or None,
creators=creators or None,
number_of_seasons=details.get("number_of_seasons"),
number_of_episodes=details.get("number_of_episodes"),
networks=networks or None,
),
details.get("poster_path"),
details.get("backdrop_path"),
people,
)
+24 -4
View File
@@ -1,7 +1,9 @@
"""Utility functions for paths, sizes, and timestamps."""
import os
import re
import time
import unicodedata
from pathlib import Path
from aiopathlib import AsyncPath
@@ -31,6 +33,25 @@ RESOLUTION_PRIORITY = {
}
def build_movie_id(title: str | None, year: int | None) -> str:
"""Build a readable movie ID slug from the title and year."""
normalized_title = unicodedata.normalize("NFKD", title or "")
ascii_title = normalized_title.encode("ascii", "ignore").decode("ascii")
slug = re.sub(r"[^a-z0-9]+", "-", ascii_title.lower()).strip("-")
slug = slug or "movie"
if year:
return f"{slug}-{year}"
return slug
def build_series_id(title: str | None) -> str:
"""Build a readable series ID slug from the title."""
normalized_title = unicodedata.normalize("NFKD", title or "")
ascii_title = normalized_title.encode("ascii", "ignore").decode("ascii")
slug = re.sub(r"[^a-z0-9]+", "-", ascii_title.lower()).strip("-")
return slug or "series"
def classify_resolution_from_dimensions(
width: int | None, height: int | None
) -> str | None:
@@ -239,10 +260,9 @@ def sanitize_filename(name: str) -> str:
def get_media_folder_name(title: str, year: int | None, media_type: str) -> str:
"""Get the folder name for a media item."""
sanitized_title = sanitize_filename(title)
if media_type == "movie" and year:
return f"{sanitized_title} ({year})"
return sanitized_title
if media_type == "movie":
return build_movie_id(title, year)
return build_series_id(title)
def get_media_folder_path(
+192 -92
View File
@@ -18,7 +18,6 @@ from fastapi import WebSocket
from mediahive.models.data import (
IndexSnapshot,
MediaStats,
Movie,
Series,
TaskInfo,
@@ -28,6 +27,7 @@ from mediahive.models.protocol import (
WsInit,
WsInitData,
)
from mediahive.models.tmdb import Person
logger = logging.getLogger("mediahive.index_store")
@@ -51,16 +51,15 @@ class IndexStore:
def __init__(
self,
snapshot_path: Path,
media_root: str | None = None,
root_id: str | None = None,
) -> None:
self.snapshot_path = snapshot_path
self.media_root = media_root
self.root_id = root_id
# The index: keyed by item id
self.movies: dict[str, Movie] = {}
self.series: dict[str, Series] = {}
self.people: dict[int, Person] = {}
self._movie_tmdb_ids: dict[int, str] = {}
self._series_tmdb_ids: dict[int, str] = {}
# Connected WebSocket clients
self._clients: set[WebSocket] = set()
@@ -74,22 +73,15 @@ class IndexStore:
self._snapshot_cache_task: asyncio.Task | None = None
self._cached_snapshot = IndexSnapshot(
generated_at=datetime.now().isoformat(),
media_root=self.media_root,
stats=MediaStats(),
movies=[],
series=[],
movies={},
series={},
people={},
)
# ------------------------------------------------------------------
# Persistence
# ------------------------------------------------------------------
def _maybe_migrate_id(self, item_id: str) -> str:
"""Strip any legacy root_id prefix, leaving only the content hash."""
if ":" in item_id:
return item_id.split(":", 1)[1]
return item_id
async def load_snapshot(self) -> None:
"""Load index from disk snapshot (recovery on startup)."""
ap = AsyncPath(self.snapshot_path)
@@ -99,67 +91,141 @@ class IndexStore:
return
try:
raw = await ap.read_bytes()
loaded_movies, loaded_series = await asyncio.to_thread(
loaded_movies, loaded_series, loaded_people = await asyncio.to_thread(
self._load_snapshot_sync,
raw,
)
self._merge_loaded_snapshot(
loaded_movies,
loaded_series,
loaded_people,
)
self._rebuild_tmdb_indexes()
self._schedule_snapshot_cache_refresh()
except Exception:
logger.exception("Failed to load snapshot from %s", self.snapshot_path)
def _load_snapshot_sync(self, raw: bytes) -> tuple[list[Movie], list[Series]]:
def _load_snapshot_sync(
self,
raw: bytes,
) -> tuple[dict[str, Movie], dict[str, Series], dict[int, Person]]:
"""Parse snapshot bytes in a thread-pool context."""
data = msgspec.json.decode(raw, type=IndexSnapshot)
loaded_movies: list[Movie] = []
loaded_series: list[Series] = []
for m in data.movies:
m.id = self._maybe_migrate_id(m.id)
m.root_id = self.root_id
loaded_movies.append(m)
for s in data.series:
s.id = self._maybe_migrate_id(s.id)
s.root_id = self.root_id
loaded_series.append(s)
payload = msgspec.json.decode(raw)
data = msgspec.convert(payload, type=IndexSnapshot)
loaded_movies = dict(data.movies)
loaded_series = dict(data.series)
loaded_people = dict(data.people)
logger.info(
"Loaded snapshot: %d movies, %d series",
"Loaded snapshot: %d movies, %d series, %d people",
len(loaded_movies),
len(loaded_series),
len(loaded_people),
)
return loaded_movies, loaded_series
return loaded_movies, loaded_series, loaded_people
def _merge_loaded_snapshot(
self,
movies: list[Movie],
series: list[Series],
movies: dict[str, Movie],
series: dict[str, Series],
people: dict[int, Person],
) -> None:
"""Merge loaded snapshot items without overriding newer in-memory updates."""
for movie in movies:
if movie.id not in self.movies:
self.movies[movie.id] = movie
for show in series:
if show.id not in self.series:
self.series[show.id] = show
for item_id, movie in movies.items():
if item_id not in self.movies:
self.movies[item_id] = movie
for item_id, show in series.items():
if item_id not in self.series:
self.series[item_id] = show
for person_id, person in people.items():
self.people[person_id] = person
@staticmethod
def _get_tmdb_id(item: Movie | Series) -> int | None:
if item.info is None:
return None
return item.info.tmdb_id
def _rebuild_tmdb_indexes(self) -> None:
"""Rebuild TMDb id lookup maps from the current in-memory items."""
self._movie_tmdb_ids.clear()
self._series_tmdb_ids.clear()
for item_id, movie in self.movies.items():
tmdb_id = self._get_tmdb_id(movie)
if tmdb_id is not None:
self._movie_tmdb_ids[tmdb_id] = item_id
for item_id, series in self.series.items():
tmdb_id = self._get_tmdb_id(series)
if tmdb_id is not None:
self._series_tmdb_ids[tmdb_id] = item_id
def _dedupe_tmdb_duplicates(self) -> None:
"""Remove duplicate entries that point at the same TMDb item."""
seen_movies: dict[int, str] = {}
for item_id, movie in list(self.movies.items()):
tmdb_id = self._get_tmdb_id(movie)
if tmdb_id is None:
continue
existing_id = seen_movies.get(tmdb_id)
if existing_id is None:
seen_movies[tmdb_id] = item_id
continue
if existing_id != item_id:
self.movies.pop(item_id, None)
seen_series: dict[int, str] = {}
for item_id, series in list(self.series.items()):
tmdb_id = self._get_tmdb_id(series)
if tmdb_id is None:
continue
existing_id = seen_series.get(tmdb_id)
if existing_id is None:
seen_series[tmdb_id] = item_id
continue
if existing_id != item_id:
self.series.pop(item_id, None)
self._rebuild_tmdb_indexes()
def _collapse_movie_tmdb_duplicates(self, tmdb_id: int, keep_id: str) -> None:
"""Remove other movie entries that share a TMDb id."""
for item_id, movie in list(self.movies.items()):
if item_id == keep_id:
continue
if self._get_tmdb_id(movie) == tmdb_id:
self.movies.pop(item_id, None)
self._rebuild_tmdb_indexes()
def _collapse_series_tmdb_duplicates(self, tmdb_id: int, keep_id: str) -> None:
"""Remove other series entries that share a TMDb id."""
for item_id, series in list(self.series.items()):
if item_id == keep_id:
continue
if self._get_tmdb_id(series) == tmdb_id:
self.series.pop(item_id, None)
self._rebuild_tmdb_indexes()
async def _write_snapshot(self) -> None:
"""Write current index to disk (called from debounce task)."""
# Copy values on the event loop thread, then do full snapshot build + disk I/O
# in a worker thread to keep the loop responsive.
movies = list(self.movies.values())
series = list(self.series.values())
await asyncio.to_thread(self._write_snapshot_sync, movies, series)
movies = dict(self.movies)
series = dict(self.series)
people = dict(self.people)
await asyncio.to_thread(self._write_snapshot_sync, movies, series, people)
logger.debug("Snapshot written to %s", self.snapshot_path)
def _write_snapshot_sync(self, movies: list[Movie], series: list[Series]) -> None:
def _write_snapshot_sync(
self,
movies: dict[str, Movie],
series: dict[str, Series],
people: dict[int, Person],
) -> None:
"""Build and write snapshot synchronously in a worker thread."""
snapshot = self._build_snapshot_from_lists(movies, series)
snapshot = self._build_snapshot_from_maps(movies, series, people)
self.snapshot_path.parent.mkdir(parents=True, exist_ok=True)
tmp = self.snapshot_path.with_suffix(".tmp")
tmp.write_bytes(msgspec.json.format(msgspec.json.encode(snapshot), indent=2))
tmp.write_bytes(msgspec.json.encode(snapshot))
# Path.replace is atomic and overwrites on all platforms.
tmp.replace(self.snapshot_path)
@@ -180,12 +246,14 @@ class IndexStore:
async def _refresh_snapshot_cache_once(self) -> None:
"""Rebuild cached snapshot once using copied store values."""
movies = list(self.movies.values())
series = list(self.series.values())
movies = dict(self.movies)
series = dict(self.series)
people = dict(self.people)
self._cached_snapshot = await asyncio.to_thread(
self._build_snapshot_from_lists,
self._build_snapshot_from_maps,
movies,
series,
people,
)
async def _snapshot_cache_writer(self) -> None:
@@ -226,45 +294,91 @@ class IndexStore:
# Mutations
# ------------------------------------------------------------------
def upsert_movie(self, item: Movie) -> bool:
def upsert_movie(
self,
item_id: str,
item: Movie,
people: dict[int, Person] | None = None,
) -> bool:
"""Insert or update a movie. Returns True if it was a real change."""
item.id = self._maybe_migrate_id(item.id)
if not item.root_id and self.root_id:
item.root_id = self.root_id
existing = self.movies.get(item.id)
tmdb_id = self._get_tmdb_id(item)
existing_id = self._movie_tmdb_ids.get(tmdb_id) if tmdb_id is not None else None
if existing_id is not None and existing_id != item_id:
item_id = existing_id
existing = self.movies.get(item_id)
if tmdb_id is not None:
self._movie_tmdb_ids[tmdb_id] = item_id
self._collapse_movie_tmdb_duplicates(tmdb_id, item_id)
if existing is not None:
if msgspec.json.encode(existing) == msgspec.json.encode(item):
if people:
self.people.update(people)
self._schedule_snapshot()
self._broadcast(
Upsert(kind="movie", id=item_id, item=item, people=people)
)
return True
return False
self.movies[item.id] = item
self.movies[item_id] = item
if people:
self.people.update(people)
self._schedule_snapshot()
self._broadcast(Upsert(kind="movie", item=item))
self._broadcast(Upsert(kind="movie", id=item_id, item=item, people=people))
return True
def upsert_series(self, item: Series) -> bool:
def upsert_series(
self,
item_id: str,
item: Series,
people: dict[int, Person] | None = None,
) -> bool:
"""Insert or update a series. Returns True if it was a real change."""
item.id = self._maybe_migrate_id(item.id)
if not item.root_id and self.root_id:
item.root_id = self.root_id
existing = self.series.get(item.id)
tmdb_id = self._get_tmdb_id(item)
existing_id = (
self._series_tmdb_ids.get(tmdb_id) if tmdb_id is not None else None
)
if existing_id is not None and existing_id != item_id:
item_id = existing_id
existing = self.series.get(item_id)
if tmdb_id is not None:
self._series_tmdb_ids[tmdb_id] = item_id
self._collapse_series_tmdb_duplicates(tmdb_id, item_id)
if existing is not None:
if msgspec.json.encode(existing) == msgspec.json.encode(item):
if people:
self.people.update(people)
self._schedule_snapshot()
self._broadcast(
Upsert(kind="series", id=item_id, item=item, people=people)
)
return True
return False
self.series[item.id] = item
self.series[item_id] = item
if people:
self.people.update(people)
self._schedule_snapshot()
self._broadcast(Upsert(kind="series", item=item))
self._broadcast(Upsert(kind="series", id=item_id, item=item, people=people))
return True
def remove_movie(self, item_id: str) -> None:
"""Remove a movie from the index and broadcast."""
item_id = self._maybe_migrate_id(item_id)
self.movies.pop(item_id, None)
for tmdb_id, mapped_id in list(self._movie_tmdb_ids.items()):
if mapped_id == item_id:
self._movie_tmdb_ids.pop(tmdb_id, None)
self._schedule_snapshot()
self._broadcast(Remove(kind="movie", id=item_id))
def remove_series(self, item_id: str) -> None:
"""Remove a series from the index and broadcast."""
item_id = self._maybe_migrate_id(item_id)
self.series.pop(item_id, None)
for tmdb_id, mapped_id in list(self._series_tmdb_ids.items()):
if mapped_id == item_id:
self._series_tmdb_ids.pop(tmdb_id, None)
self._schedule_snapshot()
self._broadcast(Remove(kind="series", id=item_id))
@@ -280,8 +394,9 @@ class IndexStore:
# Send full current state
msg = WsInit(
data=WsInitData(
movies=list(self.movies.values()),
series=list(self.series.values()),
movies=dict(self.movies),
series=dict(self.series),
people=dict(self.people),
)
)
await ws.send_bytes(msgspec.json.encode(msg))
@@ -317,41 +432,26 @@ class IndexStore:
# Read helpers
# ------------------------------------------------------------------
def _build_snapshot_from_lists(
def _build_snapshot_from_maps(
self,
movies: list[Movie],
series: list[Series],
movies: dict[str, Movie],
series: dict[str, Series],
people: dict[int, Person],
) -> IndexSnapshot:
"""Build a sorted IndexSnapshot with computed stats from list copies."""
movies_list = sorted(
movies,
key=lambda x: ((x.title or "").lower(), x.year or 0),
)
series_list = sorted(series, key=lambda x: (x.title or "").lower())
total_movie_versions = sum(len(m.torrents) for m in movies_list)
total_series_episodes = sum(
sum(len(season.episodes) for season in s.seasons) for s in series_list
)
"""Build a keyed IndexSnapshot from map copies."""
return IndexSnapshot(
generated_at=datetime.now().isoformat(),
media_root=self.media_root,
stats=MediaStats(
total_movies=len(movies_list),
total_movie_versions=total_movie_versions,
total_series=len(series_list),
total_series_episodes=total_series_episodes,
),
movies=movies_list,
series=series_list,
movies=movies,
series=series,
people=people,
)
def _build_snapshot(self) -> IndexSnapshot:
"""Build a sorted IndexSnapshot with computed stats."""
return self._build_snapshot_from_lists(
list(self.movies.values()),
list(self.series.values()),
return self._build_snapshot_from_maps(
dict(self.movies),
dict(self.series),
dict(self.people),
)
def get_full_index(self) -> IndexSnapshot:
+13 -29
View File
@@ -7,14 +7,14 @@ from __future__ import annotations
import msgspec
from .tmdb import Info
from .tmdb import Info, Person
# ---------------------------------------------------------------------------
# Index item types (the state stored in IndexStore, sent over WS/API)
# ---------------------------------------------------------------------------
class Torrent(msgspec.Struct):
class Torrent(msgspec.Struct, omit_defaults=True):
"""A torrent file, either for a movie or an episode."""
title: str | None = None
@@ -26,9 +26,10 @@ class Torrent(msgspec.Struct):
audio: str | None = None
audio_languages: list[str] | None = None
subtitle_languages: list[str] | None = None
is_hdr: bool = False
has_dolby_vision: bool = False
has_dolby_atmos: bool = False
hdr: bool = False
dovi: bool = False
atmos: bool = False
hdr10plus: bool = False
encoder: str | None = None
size: int | None = None
added_at: int | None = None
@@ -47,7 +48,7 @@ class Episode(msgspec.Struct):
director: str | None = None
reel_image: str | None = None
reel_sources: list[str] | None = None
torrents: dict[str, Torrent] = {}
files: dict[str, Torrent] = {}
class Season(msgspec.Struct):
@@ -65,7 +66,6 @@ class Season(msgspec.Struct):
class Movie(msgspec.Struct):
"""A movie in the index (one or more versions/releases)."""
id: str
title: str | None = None
info: Info | None = None
year: int | None = None
@@ -74,14 +74,12 @@ class Movie(msgspec.Struct):
backdrop_path: str | None = None
showreel_images: list[str] | None = None
showreel_source_sets: list[list[str]] | None = None
torrents: dict[str, Torrent] = {}
root_id: str | None = None
files: dict[str, Torrent] = {}
class Series(msgspec.Struct):
"""A TV series in the index."""
id: str
title: str | None = None
info: Info | None = None
alternative_titles: list[str] | None = None
@@ -89,37 +87,23 @@ class Series(msgspec.Struct):
cover_path: str | None = None
backdrop_path: str | None = None
seasons: list[Season] = []
root_id: str | None = None
# ---------------------------------------------------------------------------
# Snapshot (disk format for index.json)
# ---------------------------------------------------------------------------
class MediaStats(msgspec.Struct):
"""Aggregate counts for the index snapshot."""
total_movies: int = 0
total_movie_versions: int = 0
total_series: int = 0
total_series_episodes: int = 0
INDEX_SNAPSHOT_VERSION = 1
class IndexSnapshot(msgspec.Struct):
"""On-disk recovery snapshot of the full index."""
version: int = 7
v: int = INDEX_SNAPSHOT_VERSION
generated_at: str = ""
media_root: str | None = None
stats: MediaStats = msgspec.UNSET # type: ignore[assignment]
movies: list[Movie] = []
series: list[Series] = []
def __post_init__(self) -> None:
"""Populate default stats when omitted from decoded payload."""
if self.stats is msgspec.UNSET:
self.stats = MediaStats()
movies: dict[str, Movie] = {}
series: dict[str, Series] = {}
people: dict[int, Person] = {}
class TaskInfo(msgspec.Struct):
+3
View File
@@ -10,13 +10,16 @@ from __future__ import annotations
import msgspec
from .data import Movie, Series, TaskInfo
from .tmdb import Person
class Upsert(msgspec.Struct, tag="upsert"):
"""Single item inserted or updated."""
kind: str # "movie" or "series"
id: str
item: Movie | Series
people: dict[int, Person] | None = None
class Remove(msgspec.Struct, tag="remove"):
+4 -2
View File
@@ -10,6 +10,7 @@ from fastapi.responses import Response
from .data import Movie, Series
from .events import Remove, ScanEvent, Task, Upsert
from .tmdb import Person
# ---------------------------------------------------------------------------
# WebSocket message types
@@ -19,8 +20,9 @@ from .events import Remove, ScanEvent, Task, Upsert
class WsInitData(msgspec.Struct):
"""Payload of the init message."""
movies: list[Movie]
series: list[Series]
movies: dict[str, Movie]
series: dict[str, Series]
people: dict[int, Person]
class WsInit(msgspec.Struct, tag="init"):
+11 -8
View File
@@ -12,21 +12,26 @@ import msgspec
# ---------------------------------------------------------------------------
class CastMember(msgspec.Struct):
"""Actor/crew member."""
class CastCredit(msgspec.Struct, array_like=True):
"""Cast reference embedded in media info (character + person id)."""
character: str | None = None
id: int | None = None
class Person(msgspec.Struct, array_like=True):
"""Deduplicated person payload stored in top-level people map."""
name: str
character: str | None = None
profile_path: str | None = None
gender: str | None = None
class SimilarMedia(msgspec.Struct):
class SimilarMedia(msgspec.Struct, array_like=True):
"""Pointer to a similar movie/series on TMDb."""
id: int
title: str
poster_path: str | None = None
# ---------------------------------------------------------------------------
@@ -76,11 +81,9 @@ class Info(msgspec.Struct):
runtime: int | None = None
status: str | None = None
tagline: str | None = None
poster_path: str | None = None
backdrop_path: str | None = None
similar: list[SimilarMedia] | None = None
keywords: list[str] | None = None
cast: list[CastMember] | None = None
cast: list[CastCredit] | None = None
director: str | None = None
creators: list[str] | None = None
number_of_seasons: int | None = None
+5 -9
View File
@@ -95,9 +95,7 @@ class RootContext:
self.error: str | None = None
snapshot_path = root_path / ".mediahive" / "index.json"
self.store = IndexStore(
snapshot_path, media_root=root_path.as_posix(), root_id=root_id
)
self.store = IndexStore(snapshot_path)
# Scanner is injected later by the supervisor
self.scanner: object | None = None
@@ -170,9 +168,9 @@ class RootContext:
event = await self._events.get()
if isinstance(event, Upsert):
if event.kind == "movie":
self.store.upsert_movie(event.item)
self.store.upsert_movie(event.id, event.item, event.people)
else:
self.store.upsert_series(event.item)
self.store.upsert_series(event.id, event.item, event.people)
elif isinstance(event, Task):
self.store.broadcast_task(event.data)
except asyncio.CancelledError:
@@ -231,9 +229,7 @@ class Supervisor:
continue
movies.extend(ctx.store.movies.values())
series.extend(ctx.store.series.values())
total_movie_versions += sum(
len(m.torrents) for m in ctx.store.movies.values()
)
total_movie_versions += sum(len(m.files) for m in ctx.store.movies.values())
total_series_episodes += sum(
sum(len(season.episodes) for season in s.seasons)
for s in ctx.store.series.values()
@@ -242,7 +238,7 @@ class Supervisor:
from datetime import datetime
return {
"version": 7,
"v": 1,
"generated_at": datetime.now().isoformat(),
"stats": {
"total_movies": len(movies),