refactor index schema and metadata format and directory tree
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user