refactor index schema and metadata format and directory tree
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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(".")
|
||||
|
||||
+222
-113
@@ -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:
|
||||
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(
|
||||
member.profile_path,
|
||||
person.profile_path,
|
||||
media_folder,
|
||||
member.name,
|
||||
index,
|
||||
person.name,
|
||||
cast_credit.id,
|
||||
)
|
||||
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,
|
||||
)
|
||||
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, people = await _cache_people_profiles(
|
||||
tmdb_info,
|
||||
people,
|
||||
media_folder,
|
||||
media_root,
|
||||
)
|
||||
tmdb_info = await _cache_cast_profiles(tmdb_info, 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, people = await _cache_people_profiles(
|
||||
tmdb_info,
|
||||
people,
|
||||
series_folder,
|
||||
media_root,
|
||||
)
|
||||
tmdb_info = await _cache_cast_profiles(tmdb_info, 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, {}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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(
|
||||
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"),
|
||||
),
|
||||
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", ""),
|
||||
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,
|
||||
)
|
||||
)
|
||||
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")),
|
||||
)
|
||||
for c in cast_data
|
||||
]
|
||||
|
||||
# Extract director from crew
|
||||
crew = credits_data.get("crew", [])
|
||||
@@ -438,12 +452,10 @@ 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(
|
||||
return (
|
||||
Info(
|
||||
tmdb_id=movie_id,
|
||||
title=details.get("title"),
|
||||
original_title=details.get("original_title"),
|
||||
@@ -456,12 +468,14 @@ async def fetch_movie_info(title: str, year: int | None = None) -> Info | None:
|
||||
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,
|
||||
),
|
||||
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(
|
||||
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"),
|
||||
),
|
||||
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", ""),
|
||||
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,
|
||||
)
|
||||
)
|
||||
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")),
|
||||
)
|
||||
for c in cast_data
|
||||
]
|
||||
|
||||
# Extract creators
|
||||
creators = [c["name"] for c in details.get("created_by", [])]
|
||||
@@ -540,15 +566,13 @@ 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(
|
||||
return (
|
||||
Info(
|
||||
tmdb_id=series_id,
|
||||
title=details.get("name"),
|
||||
original_title=details.get("original_name"),
|
||||
@@ -559,8 +583,6 @@ async def fetch_series_info(title: str) -> Info | 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,
|
||||
@@ -568,4 +590,8 @@ async def fetch_series_info(title: str) -> Info | 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,
|
||||
)
|
||||
|
||||
@@ -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(
|
||||
|
||||
+194
-94
@@ -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):
|
||||
return False
|
||||
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
|
||||
return False
|
||||
self.movies[item_id] = item
|
||||
if people:
|
||||
self.people.update(people)
|
||||
self._schedule_snapshot()
|
||||
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):
|
||||
return False
|
||||
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
|
||||
return False
|
||||
self.series[item_id] = item
|
||||
if people:
|
||||
self.people.update(people)
|
||||
self._schedule_snapshot()
|
||||
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
@@ -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):
|
||||
|
||||
@@ -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"):
|
||||
|
||||
@@ -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"):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user