refactor index schema and metadata format and directory tree

This commit is contained in:
2026-05-30 01:05:27 +00:00
parent 550f67531a
commit 900fcea638
23 changed files with 879 additions and 458 deletions
+9 -7
View File
@@ -176,7 +176,9 @@ import { ref, computed, onMounted, onUnmounted, watch } from "vue"
import { useRouter, useRoute } from "vue-router"
import type {
Movie,
MovieUi,
Series,
SeriesUi,
MediaItem,
EpisodeWithSeries,
TaskInfo,
@@ -732,7 +734,7 @@ function showDetail(item: MediaItem) {
if (item.type === "episode") {
// For episodes, play directly if possible, otherwise show the series
const epData = item.data as EpisodeWithSeries
const playableFile = Object.values(epData.episode.torrents || {})[0]?.playable_file
const playableFile = Object.values(epData.episode.files || {})[0]?.playable_file
if (playableFile) {
handlePlay(playableFile)
} else {
@@ -800,10 +802,10 @@ function focusDetailEntryTarget(item: MediaItem): boolean {
}
// Convert raw data to MediaItem format
function movieToMediaItem(movie: Movie): MediaItem {
function movieToMediaItem(movie: MovieUi): MediaItem {
// Get resolution from first torrent if available
const torrents = Object.values(movie.torrents || {})
const resolution = torrents.length > 0 ? torrents[0].resolution : null
const files = Object.values(movie.files || {})
const resolution = files.length > 0 ? files[0].resolution : null
return {
id: movie.id,
@@ -819,7 +821,7 @@ function movieToMediaItem(movie: Movie): MediaItem {
}
}
function seriesToMediaItem(series: Series): MediaItem {
function seriesToMediaItem(series: SeriesUi): MediaItem {
// For series, collect reel images from all episodes
const reelImages: string[] = []
const reelSourceSets: string[][] = []
@@ -1249,7 +1251,7 @@ function reloadPage() {
function findRootIdForPath(filePath: string): string | null {
if (!mediaIndex.value) return null
for (const movie of mediaIndex.value.movies) {
for (const torrent of Object.values(movie.torrents || {})) {
for (const torrent of Object.values(movie.files || {})) {
if (torrent.playable_file === filePath) {
return torrent.root_id || movie.root_id
}
@@ -1258,7 +1260,7 @@ function findRootIdForPath(filePath: string): string | null {
for (const series of mediaIndex.value.series) {
for (const season of series.seasons || []) {
for (const episode of season.episodes || []) {
for (const torrent of Object.values(episode.torrents || {})) {
for (const torrent of Object.values(episode.files || {})) {
if (torrent.playable_file === filePath) {
return torrent.root_id || series.root_id
}
+1 -1
View File
@@ -570,7 +570,7 @@ function getRatingClass(item: MediaItem): string {
function getResolution(item: MediaItem): string | null {
if (item.type === "movies") {
const movie = item.data as Movie
return Object.values(movie.torrents || {})[0]?.resolution ?? null
return Object.values(movie.files || {})[0]?.resolution ?? null
}
return null
}
+15 -6
View File
@@ -124,7 +124,7 @@
>
<img
v-if="castMember.profile_path && !castMember.profile_path.startsWith('/')"
:src="getCoverUrl(castMember.profile_path, item.root_id)"
:src="getCastProfileUrl(castMember.profile_path)"
:alt="castMember.name"
class="cast-photo"
/>
@@ -202,7 +202,7 @@
<script setup lang="ts">
import { computed, ref, watch, onMounted, onUnmounted, nextTick } from "vue"
import type { CastMember, MediaItem, Movie, Series, Torrent } from "../types"
import type { CastMember, MediaItem, Movie, MovieUi, Series, Torrent } from "../types"
import {
getCoverUrl,
getVideoPreviewUrl,
@@ -499,7 +499,7 @@ function getShowreelSourceAttributes(path: string): VideoSourceAttributes {
const movieVersions = computed((): Torrent[] => {
if (props.item.type !== "movies") return []
const movie = props.item.data as Movie
return sortTorrentsByPreference(Object.values(movie.torrents || {}))
return sortTorrentsByPreference(Object.values(movie.files || {}))
})
// Page backdrop background
@@ -583,6 +583,15 @@ function getCastPlaceholderUrl(gender?: CastMember["gender"]): string {
return gender === "female" ? castPlaceholderFemaleUrl : castPlaceholderMaleUrl
}
function getCastProfileUrl(profilePath: string | null): string {
if (!profilePath) return ""
if (profilePath.includes("/")) {
return getCoverUrl(profilePath, props.item.root_id)
}
const castPath = `.mediahive/people/${profilePath}`
return getCoverUrl(castPath, props.item.root_id)
}
function formatRuntime(minutes: number): string {
const hours = Math.floor(minutes / 60)
const mins = minutes % 60
@@ -656,7 +665,7 @@ function handlePlayVersion(filePath: string | null) {
function handleVersionContextMenu(event: MouseEvent, version: Torrent) {
event.preventDefault()
event.stopPropagation()
const rootId = version.root_id || ((props.item.data as Movie).root_id ?? props.item.root_id)
const rootId = version.root_id || ((props.item.data as MovieUi).root_id ?? props.item.root_id)
versionActionMenu.value = {
visible: true,
x: event.clientX,
@@ -675,7 +684,7 @@ function handleVersionContextMenu(event: MouseEvent, version: Torrent) {
function handleVersionShortcutKeydown(event: KeyboardEvent, version: Torrent) {
if (!version.playable_file) return
const rootId = version.root_id || ((props.item.data as Movie).root_id ?? props.item.root_id)
const rootId = version.root_id || ((props.item.data as MovieUi).root_id ?? props.item.root_id)
const key = event.key.toLowerCase()
if (key === "e" && (event.metaKey || event.ctrlKey)) {
event.preventDefault()
@@ -718,7 +727,7 @@ function handlePlay(filePath: string | null) {
function handleVersionActivate(version: Torrent, event: MouseEvent | KeyboardEvent) {
if (!version.playable_file) return
const rootId = version.root_id || ((props.item.data as Movie).root_id ?? props.item.root_id)
const rootId = version.root_id || ((props.item.data as MovieUi).root_id ?? props.item.root_id)
if (event.altKey) {
handleOpenFolder(version.playable_file, rootId)
return
@@ -218,7 +218,7 @@ function normalizeQualityBadge(value: string): string {
const hasDolbyVision = computed(() => {
return (
props.torrent.has_dolby_vision === true ||
props.torrent.dovi === true ||
hasAnyTag(
dolbyVisionPattern,
props.torrent.quality,
@@ -231,7 +231,7 @@ const hasDolbyVision = computed(() => {
const hasDolbyAtmos = computed(() => {
return (
props.torrent.has_dolby_atmos === true ||
props.torrent.atmos === true ||
hasAnyTag(
dolbyAtmosPattern,
props.torrent.quality,
@@ -244,7 +244,7 @@ const hasDolbyAtmos = computed(() => {
const hasHdr = computed(() => {
return (
props.torrent.is_hdr === true ||
props.torrent.hdr === true ||
hasAnyTag(
hdrPattern,
props.torrent.quality,
+5 -5
View File
@@ -148,9 +148,9 @@
<div class="context-menu-header">
{{ contextMenu.episode.name || `Episode ${contextMenu.episode.episode_number}` }}
</div>
<div v-if="Object.values(contextMenu.episode.torrents || {}).length > 0">
<div v-if="Object.values(contextMenu.episode.files || {}).length > 0">
<ReleaseVersionCard
v-for="(torrent, index) in sortTorrentsByPreference(Object.values(contextMenu.episode.torrents || {}))"
v-for="(torrent, index) in sortTorrentsByPreference(Object.values(contextMenu.episode.files || {}))"
:key="index"
class="context-menu-version"
:torrent="torrent"
@@ -556,8 +556,8 @@ function handleEpisodeHover(key: string, isEntering: boolean) {
// Backdrop URL - only use backdrop_path, fall back to collage (handled in template)
const backdropUrl = computed(() => {
if (props.series.info?.backdrop_path) {
return getCoverUrl(props.series.info.backdrop_path, props.series.root_id)
if (props.series.backdrop_path) {
return getCoverUrl(props.series.backdrop_path, props.series.root_id)
}
return null
})
@@ -643,7 +643,7 @@ function truncate(text: string, maxLength: number): string {
// Handle play
function handlePlay(episode: Episode) {
const playableFile = Object.values(episode.torrents || {})[0]?.playable_file
const playableFile = Object.values(episode.files || {})[0]?.playable_file
if (playableFile) {
emit("play", playableFile)
}
+136 -31
View File
@@ -1,7 +1,9 @@
import { shallowRef, readonly, onUnmounted } from "vue"
import type {
Movie,
MovieUi,
Series,
SeriesUi,
Episode,
Season,
Torrent,
@@ -13,8 +15,8 @@ import type {
interface RootState {
rootId: string
ws: WebSocket | null
movieMap: Map<string, Movie>
seriesMap: Map<string, Series>
movieMap: Map<string, MovieUi>
seriesMap: Map<string, SeriesUi>
connected: boolean
initialized: boolean
pendingMessages: WsMessage[]
@@ -79,18 +81,33 @@ export function useMediaWebSocket() {
else if (res.includes("720") || res === "hd") score += 60
else if (res.includes("480") || res === "sd") score += 40
else if (res.includes("360")) score += 20
if (t.has_dolby_vision) score += 15
if (t.is_hdr) score += 10
if (t.has_dolby_atmos) score += 5
if (t.dovi) score += 15
if (t.hdr) score += 10
if (t.atmos) score += 5
return score
}
function annotateTorrents(
torrents: { [key: string]: Torrent },
function expandPlayablePath(fileKey: string, playableFile: string | null): string | null {
if (!playableFile) return fileKey
if (playableFile.startsWith("concat:") || playableFile.includes("://")) return playableFile
if (playableFile.startsWith(`${fileKey}/`)) return playableFile
if (playableFile.startsWith("/")) return playableFile.replace(/^\/+/, "")
return `${fileKey}/${playableFile}`
}
function annotateFiles(
files: { [key: string]: Torrent },
rootId: string | null,
): { [key: string]: Torrent } {
return Object.fromEntries(
Object.entries(torrents || {}).map(([k, t]) => [k, { ...t, root_id: t.root_id || rootId }]),
Object.entries(files || {}).map(([k, t]) => [
k,
{
...t,
playable_file: expandPlayablePath(k, t.playable_file || null),
root_id: t.root_id || rootId,
},
]),
)
}
@@ -112,13 +129,95 @@ export function useMediaWebSocket() {
return Object.fromEntries(sorted)
}
function mergeMovies(a: Movie, b: Movie): Movie {
const torrentsA = annotateTorrents(a.torrents, a.root_id)
const torrentsB = annotateTorrents(b.torrents, b.root_id)
function withMovieIdentity(id: string, movie: Movie, rootId: string): MovieUi {
return { ...normalizeMovie(movie), id, root_id: rootId }
}
function withSeriesIdentity(id: string, series: Series, rootId: string): SeriesUi {
return { ...normalizeSeries(series), id, root_id: rootId }
}
function normalizeCastMember(member: unknown): {
name: string
character: string | null
profile_path: string | null
gender: string | null
id: number | null
} {
if (Array.isArray(member)) {
return {
name: typeof member[0] === "string" ? member[0] : "",
character: typeof member[1] === "string" ? member[1] : null,
profile_path: typeof member[2] === "string" ? member[2] : null,
gender: typeof member[3] === "string" ? member[3] : null,
id: typeof member[4] === "number" ? member[4] : null,
}
}
const obj = member as {
name?: unknown
character?: unknown
profile_path?: unknown
gender?: unknown
id?: unknown
} | null
return {
name: typeof obj?.name === "string" ? obj.name : "",
character: typeof obj?.character === "string" ? obj.character : null,
profile_path: typeof obj?.profile_path === "string" ? obj.profile_path : null,
gender: typeof obj?.gender === "string" ? obj.gender : null,
id: typeof obj?.id === "number" ? obj.id : null,
}
}
function normalizeSimilarMember(member: unknown): { id: number; title: string } {
if (Array.isArray(member)) {
return {
id: typeof member[0] === "number" ? member[0] : 0,
title: typeof member[1] === "string" ? member[1] : "",
}
}
const obj = member as { id?: unknown; title?: unknown } | null
return {
id: typeof obj?.id === "number" ? obj.id : 0,
title: typeof obj?.title === "string" ? obj.title : "",
}
}
function normalizeInfo<T extends { cast?: unknown; similar?: unknown }>(info: T | null): T | null {
if (!info) return info
let next: T = info
if (Array.isArray((info as { cast?: unknown }).cast)) {
const cast = ((info as { cast?: unknown[] }).cast || []).map(normalizeCastMember)
next = { ...next, cast } as T
}
if (Array.isArray((info as { similar?: unknown }).similar)) {
const similar = ((info as { similar?: unknown[] }).similar || []).map(normalizeSimilarMember)
next = { ...next, similar } as T
}
return next
}
function normalizeMovie(movie: Movie): Movie {
return {
...movie,
info: normalizeInfo(movie.info),
}
}
function normalizeSeries(series: Series): Series {
return {
...series,
info: normalizeInfo(series.info),
}
}
function mergeMovies(a: MovieUi, b: MovieUi): MovieUi {
const filesA = annotateFiles(a.files, a.root_id)
const filesB = annotateFiles(b.files, b.root_id)
return {
...a,
id: getContentHash(a.id),
torrents: mergeTorrentDicts(torrentsA, torrentsB),
files: mergeTorrentDicts(filesA, filesB),
info: a.info || b.info,
cover_path: a.cover_path || b.cover_path,
backdrop_path: a.backdrop_path || b.backdrop_path,
@@ -135,11 +234,11 @@ export function useMediaWebSocket() {
rootIdA: string | null,
rootIdB: string | null,
): Episode {
const torrentsA = annotateTorrents(a.torrents, rootIdA)
const torrentsB = annotateTorrents(b.torrents, rootIdB)
const filesA = annotateFiles(a.files, rootIdA)
const filesB = annotateFiles(b.files, rootIdB)
return {
...a,
torrents: mergeTorrentDicts(torrentsA, torrentsB),
files: mergeTorrentDicts(filesA, filesB),
reel_image: a.reel_image || b.reel_image,
reel_sources: a.reel_sources?.length ? a.reel_sources : b.reel_sources,
}
@@ -162,7 +261,7 @@ export function useMediaWebSocket() {
} else {
episodeMap.set(ep.episode_number, {
...ep,
torrents: annotateTorrents(ep.torrents, rootIdB),
files: annotateFiles(ep.files, rootIdB),
})
}
}
@@ -173,14 +272,14 @@ export function useMediaWebSocket() {
}
}
function mergeSeries(a: Series, b: Series): Series {
function mergeSeries(a: SeriesUi, b: SeriesUi): SeriesUi {
const seasonMap = new Map<number, Season>()
for (const season of a.seasons || []) {
seasonMap.set(season.season_number, {
...season,
episodes: season.episodes.map((ep) => ({
...ep,
torrents: annotateTorrents(ep.torrents, a.root_id),
files: annotateFiles(ep.files, a.root_id),
})),
})
}
@@ -193,7 +292,7 @@ export function useMediaWebSocket() {
...season,
episodes: season.episodes.map((ep) => ({
...ep,
torrents: annotateTorrents(ep.torrents, b.root_id),
files: annotateFiles(ep.files, b.root_id),
})),
})
}
@@ -208,7 +307,7 @@ export function useMediaWebSocket() {
}
}
function mergeItemsByHash<T extends Movie | Series>(items: T[], mergeFn: (a: T, b: T) => T): T[] {
function mergeItemsByHash<T extends MovieUi | SeriesUi>(items: T[], mergeFn: (a: T, b: T) => T): T[] {
const map = new Map<string, T[]>()
for (const item of items) {
const hash = getContentHash(item.id)
@@ -232,8 +331,8 @@ export function useMediaWebSocket() {
}
function buildIndex(): MediaIndex {
const movies: Movie[] = []
const series: Series[] = []
const movies: MovieUi[] = []
const series: SeriesUi[] = []
for (const state of roots.value.values()) {
movies.push(...state.movieMap.values())
series.push(...state.seriesMap.values())
@@ -241,12 +340,8 @@ export function useMediaWebSocket() {
const mergedMovies = mergeItemsByHash(movies, mergeMovies)
const mergedSeries = mergeItemsByHash(series, mergeSeries)
return {
version: 0,
v: 1,
generated_at: new Date().toISOString(),
stats: {
total_movies: mergedMovies.length,
total_series: mergedSeries.length,
},
movies: mergedMovies,
series: mergedSeries,
}
@@ -283,8 +378,12 @@ export function useMediaWebSocket() {
case "init": {
state.movieMap.clear()
state.seriesMap.clear()
for (const m of msg.data.movies) state.movieMap.set(m.id, m)
for (const s of msg.data.series) state.seriesMap.set(s.id, s)
for (const [id, m] of Object.entries(msg.data.movies || {})) {
state.movieMap.set(id, withMovieIdentity(id, m, state.rootId))
}
for (const [id, s] of Object.entries(msg.data.series || {})) {
state.seriesMap.set(id, withSeriesIdentity(id, s, state.rootId))
}
state.initialized = true
// Replay any deltas that arrived before init completed.
@@ -304,9 +403,15 @@ export function useMediaWebSocket() {
}
case "upsert": {
if (msg.kind === "movie") {
state.movieMap.set(msg.item.id, msg.item as Movie)
state.movieMap.set(
msg.id,
withMovieIdentity(msg.id, msg.item as Movie, state.rootId),
)
} else {
state.seriesMap.set(msg.item.id, msg.item as Series)
state.seriesMap.set(
msg.id,
withSeriesIdentity(msg.id, msg.item as Series, state.rootId),
)
}
updateMergedState()
break
+3 -3
View File
@@ -103,9 +103,9 @@ export function sortTorrentsByPreference(torrents: Torrent[]): Torrent[] {
function detectHdrProfile(t: Torrent): HdrProfile {
const text = [t.title, t.quality, t.codec, t.audio].filter(Boolean).join(" ")
const hasDovi = t.has_dolby_vision || /dolby\s*vision|dovi|\bdv\b/i.test(text)
const hasHdr10Plus = /hdr10\+|hdr10plus/i.test(text)
const hasAnyHdr = t.is_hdr || hasHdr10Plus || hasDovi || /\bhdr\b/i.test(text)
const hasDovi = t.dovi || /dolby\s*vision|dovi|\bdv\b/i.test(text)
const hasHdr10Plus = t.hdr10plus || /hdr10\+|hdr10plus/i.test(text)
const hasAnyHdr = t.hdr || hasHdr10Plus || hasDovi || /\bhdr\b/i.test(text)
if (hasDovi) return "dovi"
if (hasHdr10Plus) return "hdr10plus"
+14 -14
View File
@@ -2,8 +2,8 @@
// This file is loaded as a Web Worker, not imported as a module.
import type {
Movie,
Series,
MovieUi,
SeriesUi,
MatchedPerson,
MatchedEpisode,
SearchMatchInfo,
@@ -15,8 +15,8 @@ import type {
export interface SearchIndexMessage {
type: "index"
movies: Movie[]
series: Series[]
movies: MovieUi[]
series: SeriesUi[]
}
export interface SearchQueryMessage {
@@ -55,8 +55,8 @@ export interface SearchResponseMessage {
// Worker state
// ---------------------------------------------------------------------------
let movies: Movie[] = []
let series: Series[] = []
let movies: MovieUi[] = []
let series: SeriesUi[] = []
// ---------------------------------------------------------------------------
// Normalization helpers (mirrored from App.vue)
@@ -204,9 +204,9 @@ function getBestScore(query: string, ...fields: (string | null | undefined)[]):
return bestScore
}
function getMoviePathScore(movie: Movie, query: string): number {
function getMoviePathScore(movie: MovieUi, query: string): number {
const torrentFields: (string | null | undefined)[] = []
for (const torrent of Object.values(movie.torrents || {})) {
for (const torrent of Object.values(movie.files || {})) {
torrentFields.push(torrent.title, torrent.playable_file)
}
let bestScore = 0
@@ -218,11 +218,11 @@ function getMoviePathScore(movie: Movie, query: string): number {
return bestScore
}
function getSeriesPathScore(series: Series, query: string): number {
function getSeriesPathScore(series: SeriesUi, query: string): number {
const torrentFields: (string | null | undefined)[] = []
for (const season of series.seasons || []) {
for (const episode of season.episodes || []) {
for (const torrent of Object.values(episode.torrents || {})) {
for (const torrent of Object.values(episode.files || {})) {
torrentFields.push(torrent.title, torrent.playable_file)
}
}
@@ -498,9 +498,9 @@ function formatMatchedPeople(people: PersonMatch[]): MatchedPerson[] {
// MediaItem conversion (lightweight, without data payload)
// ---------------------------------------------------------------------------
function movieToSearchResult(movie: Movie): SearchResultItem {
const torrents = Object.values(movie.torrents || {})
const resolution = torrents.length > 0 ? torrents[0].resolution : null
function movieToSearchResult(movie: MovieUi): SearchResultItem {
const files = Object.values(movie.files || {})
const resolution = files.length > 0 ? files[0].resolution : null
return {
id: movie.id,
title: movie.title || "Unknown",
@@ -514,7 +514,7 @@ function movieToSearchResult(movie: Movie): SearchResultItem {
}
}
function seriesToSearchResult(series: Series): SearchResultItem {
function seriesToSearchResult(series: SeriesUi): SearchResultItem {
const reelImages: string[] = []
const reelSourceSets: string[][] = []
for (const season of series.seasons || []) {
+33 -17
View File
@@ -7,12 +7,18 @@ export interface CastMember {
character?: string | null
profile_path: string | null
gender?: CastGender | null
id?: number | null
}
export interface Person {
name: string
profile_path: string | null
gender?: CastGender | null
}
export interface SimilarMedia {
id: number
title: string
poster_path: string | null
}
export interface Info {
@@ -28,8 +34,6 @@ export interface Info {
runtime: number | null
status: string | null
tagline: string | null
poster_path: string | null
backdrop_path: string | null
similar: SimilarMedia[] | null
keywords: string[] | null
cast: CastMember[] | null
@@ -50,9 +54,10 @@ export interface Torrent {
audio: string | null
audio_languages: string[] | null
subtitle_languages: string[] | null
is_hdr: boolean
has_dolby_vision: boolean
has_dolby_atmos: boolean
hdr?: boolean
dovi?: boolean
atmos?: boolean
hdr10plus?: boolean
encoder: string | null
size: number | null
added_at: number | null
@@ -60,7 +65,6 @@ export interface Torrent {
}
export interface Movie {
id: string
title: string | null
info: Info | null
year: number | null
@@ -69,8 +73,7 @@ export interface Movie {
backdrop_path: string | null
showreel_images: string[] | null
showreel_source_sets: string[][] | null
torrents: { [key: string]: Torrent }
root_id: string | null
files: { [key: string]: Torrent }
}
export interface Episode {
@@ -84,7 +87,7 @@ export interface Episode {
director: string | null
reel_image: string | null
reel_sources: string[] | null
torrents: { [key: string]: Torrent }
files: { [key: string]: Torrent }
}
export interface Season {
@@ -98,7 +101,6 @@ export interface Season {
}
export interface Series {
id: string
title: string | null
info: Info | null
alternative_titles: string[] | null
@@ -106,6 +108,15 @@ export interface Series {
cover_path: string | null
backdrop_path: string | null
seasons: Season[]
}
export interface MovieUi extends Movie {
id: string
root_id: string | null
}
export interface SeriesUi extends Series {
id: string
root_id: string | null
}
@@ -117,11 +128,10 @@ export interface MediaStats {
}
export interface MediaIndex {
version: number
v: number
generated_at: string
stats: MediaStats
movies: Movie[]
series: Series[]
movies: MovieUi[]
series: SeriesUi[]
}
export type MediaType = "movies" | "series" | "episode"
@@ -167,7 +177,7 @@ export interface MediaItem {
// Episode with parent series info for standalone display
export interface EpisodeWithSeries {
episode: Episode
series: Series
series: SeriesUi
seasonNumber: number
}
@@ -182,13 +192,19 @@ export interface TaskInfo {
// WebSocket message types (matching server msgspec tagged structs)
export interface WsInitMessage {
type: "init"
data: { movies: Movie[]; series: Series[] }
data: {
movies: Record<string, Movie>
series: Record<string, Series>
people?: Record<string, Person> | Record<number, Person>
}
}
export interface WsUpsertMessage {
type: "upsert"
kind: "movie" | "series"
id: string
item: Movie | Series
people?: Record<string, Person> | Record<number, Person>
}
export interface WsRemoveMessage {