mirror of
https://github.com/Benexl/FastAnime.git
synced 2025-12-12 15:50:01 -08:00
refactor: rename to viu
This commit is contained in:
12
viu/__init__.py
Normal file
12
viu/__init__.py
Normal file
@@ -0,0 +1,12 @@
|
||||
import sys
|
||||
|
||||
if sys.version_info < (3, 10):
|
||||
raise ImportError(
|
||||
"You are using an unsupported version of Python. Only Python versions 3.10 and above are supported by Viu"
|
||||
) # noqa: F541
|
||||
|
||||
|
||||
def Cli():
|
||||
from .cli import run_cli
|
||||
|
||||
run_cli()
|
||||
14
viu/__main__.py
Normal file
14
viu/__main__.py
Normal file
@@ -0,0 +1,14 @@
|
||||
import sys
|
||||
|
||||
if __package__ is None and not getattr(sys, "frozen", False):
|
||||
# direct call of __main__.py
|
||||
import os.path
|
||||
|
||||
path = os.path.realpath(os.path.abspath(__file__))
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(path)))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
from . import Cli
|
||||
|
||||
Cli()
|
||||
7
viu/assets/defaults/ascii-art
Normal file
7
viu/assets/defaults/ascii-art
Normal file
@@ -0,0 +1,7 @@
|
||||
|
||||
██╗░░░██╗██╗██╗░░░██╗
|
||||
██║░░░██║██║██║░░░██║
|
||||
╚██╗░██╔╝██║██║░░░██║
|
||||
░╚████╔╝░██║██║░░░██║
|
||||
░░╚██╔╝░░██║╚██████╔╝
|
||||
░░░╚═╝░░░╚═╝░╚═════╝░
|
||||
23
viu/assets/defaults/fzf-opts
Normal file
23
viu/assets/defaults/fzf-opts
Normal file
@@ -0,0 +1,23 @@
|
||||
--color=fg:#d0d0d0,fg+:#d0d0d0,bg:#121212,bg+:#262626
|
||||
--color=hl:#5f87af,hl+:#5fd7ff,info:#afaf87,marker:#87ff00
|
||||
--color=prompt:#d7005f,spinner:#af5fff,pointer:#af5fff,header:#87afaf
|
||||
--color=border:#262626,label:#aeaeae,query:#d9d9d9
|
||||
--border=rounded
|
||||
--border-label=''
|
||||
--prompt='>'
|
||||
--marker='>'
|
||||
--pointer='◆'
|
||||
--separator='─'
|
||||
--scrollbar='│'
|
||||
--layout=reverse
|
||||
--cycle
|
||||
--info=hidden
|
||||
--height=100%
|
||||
--bind=right:accept,ctrl-/:toggle-preview,ctrl-space:toggle-wrap+toggle-preview-wrap
|
||||
--no-margin
|
||||
+m
|
||||
-i
|
||||
--exact
|
||||
--tabstop=1
|
||||
--preview-window=border-rounded,left,35%,wrap
|
||||
--wrap
|
||||
113
viu/assets/defaults/rofi-themes/confirm.rasi
Normal file
113
viu/assets/defaults/rofi-themes/confirm.rasi
Normal file
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* Rofi Theme: Viu "Tokyo Night" Confirmation
|
||||
* Author: Gemini ft Benexl
|
||||
* Description: A compact and clear modal dialog for Yes/No confirmations that displays a prompt.
|
||||
*/
|
||||
|
||||
/*****----- Configuration -----*****/
|
||||
configuration {
|
||||
font: "JetBrains Mono Nerd Font 12";
|
||||
}
|
||||
|
||||
/*****----- Global Properties -----*****/
|
||||
* {
|
||||
/* Tokyo Night Color Palette */
|
||||
bg-col: #1a1b26;
|
||||
bg-alt: #24283b;
|
||||
fg-col: #c0caf5;
|
||||
|
||||
blue: #7aa2f7;
|
||||
green: #9ece6a; /* For 'Yes' */
|
||||
red: #f7768e; /* For 'No' */
|
||||
|
||||
background-color: transparent;
|
||||
text-color: @fg-col;
|
||||
}
|
||||
|
||||
/*****----- Main Window -----*****/
|
||||
window {
|
||||
transparency: "real";
|
||||
location: center;
|
||||
anchor: center;
|
||||
fullscreen: false;
|
||||
width: 350px;
|
||||
|
||||
border: 2px;
|
||||
border-color: @blue;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
background-color: @bg-col;
|
||||
}
|
||||
|
||||
/*****----- Main Box -----*****/
|
||||
mainbox {
|
||||
children: [ inputbar, message, listview ];
|
||||
spacing: 15px;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
/*****----- Inputbar (Displays the -p 'prompt') -----*****/
|
||||
inputbar {
|
||||
background-color: transparent;
|
||||
text-color: @blue;
|
||||
children: [ prompt ];
|
||||
}
|
||||
|
||||
prompt {
|
||||
font: "JetBrains Mono Nerd Font Bold 14";
|
||||
horizontal-align: 0.5; /* Center the title */
|
||||
background-color: transparent;
|
||||
text-color: inherit;
|
||||
}
|
||||
|
||||
|
||||
/*****----- Message (Displays the -mesg 'Are you Sure?') -----*****/
|
||||
message {
|
||||
padding: 10px;
|
||||
margin: 5px 0px;
|
||||
border-radius: 8px;
|
||||
background-color: @bg-alt;
|
||||
text-color: @fg-col;
|
||||
}
|
||||
|
||||
textbox {
|
||||
font: "JetBrains Mono Nerd Font 12";
|
||||
horizontal-align: 0.5;
|
||||
background-color: transparent;
|
||||
text-color: inherit;
|
||||
}
|
||||
|
||||
/*****----- Listview (The Buttons) -----*****/
|
||||
listview {
|
||||
columns: 2;
|
||||
lines: 1;
|
||||
spacing: 15px;
|
||||
layout: vertical;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
/*****----- Elements (Yes/No Buttons) -----*****/
|
||||
element {
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
background-color: @bg-alt;
|
||||
text-color: @fg-col;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
element-text {
|
||||
font: "JetBrains Mono Nerd Font Bold 12";
|
||||
horizontal-align: 0.5;
|
||||
background-color: transparent;
|
||||
text-color: inherit;
|
||||
}
|
||||
|
||||
element normal.normal {
|
||||
background-color: @bg-alt;
|
||||
text-color: @fg-col;
|
||||
}
|
||||
|
||||
element selected.normal {
|
||||
background-color: @blue;
|
||||
text-color: @bg-col;
|
||||
}
|
||||
86
viu/assets/defaults/rofi-themes/input.rasi
Normal file
86
viu/assets/defaults/rofi-themes/input.rasi
Normal file
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Rofi Theme: Viu "Tokyo Night" Input
|
||||
* Author: Gemini ft Benexl
|
||||
* Description: A compact, modern modal dialog for text input that correctly displays the prompt.
|
||||
*/
|
||||
|
||||
/*****----- Configuration -----*****/
|
||||
configuration {
|
||||
font: "JetBrains Mono Nerd Font 14";
|
||||
}
|
||||
|
||||
/*****----- Global Properties -----*****/
|
||||
* {
|
||||
/* Tokyo Night Color Palette */
|
||||
bg-col: #1a1b26ff;
|
||||
bg-alt: #24283bff;
|
||||
fg-col: #c0caf5ff;
|
||||
fg-alt: #a9b1d6ff;
|
||||
accent: #bb9af7ff;
|
||||
blue: #7aa2f7ff;
|
||||
|
||||
background-color: transparent;
|
||||
text-color: @fg-col;
|
||||
}
|
||||
|
||||
/*****----- Main Window -----*****/
|
||||
window {
|
||||
transparency: "real";
|
||||
location: center;
|
||||
anchor: center;
|
||||
fullscreen: false;
|
||||
width: 500px;
|
||||
|
||||
border: 2px;
|
||||
border-color: @blue;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
background-color: @bg-col;
|
||||
}
|
||||
|
||||
/*****----- Main Box -----*****/
|
||||
mainbox {
|
||||
children: [ message, inputbar ];
|
||||
spacing: 20px;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
/*****----- Message (The Main Question, uses -mesg) -----*****/
|
||||
message {
|
||||
padding: 10px;
|
||||
border-radius: 8px;
|
||||
background-color: @bg-alt;
|
||||
text-color: @fg-col;
|
||||
}
|
||||
|
||||
textbox {
|
||||
font: "JetBrains Mono Nerd Font Bold 14";
|
||||
horizontal-align: 0.5; /* Center the prompt text */
|
||||
background-color: transparent;
|
||||
text-color: inherit;
|
||||
}
|
||||
|
||||
/*****----- Inputbar (Contains the title and entry field) -----*****/
|
||||
inputbar {
|
||||
padding: 8px 12px;
|
||||
border: 1px;
|
||||
border-radius: 6px;
|
||||
border-color: @accent;
|
||||
background-color: @bg-alt;
|
||||
spacing: 10px;
|
||||
children: [ prompt, entry ];
|
||||
}
|
||||
|
||||
/* This is the title from the -p flag */
|
||||
prompt {
|
||||
background-color: transparent;
|
||||
text-color: @accent;
|
||||
}
|
||||
|
||||
/* This is where the user types */
|
||||
entry {
|
||||
background-color: transparent;
|
||||
text-color: @fg-col;
|
||||
placeholder: "Type here...";
|
||||
placeholder-color: @fg-alt;
|
||||
}
|
||||
104
viu/assets/defaults/rofi-themes/main.rasi
Normal file
104
viu/assets/defaults/rofi-themes/main.rasi
Normal file
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Rofi Theme: Viu "Tokyo Night" Main
|
||||
* Author: Gemini ft Benexl
|
||||
* Description: A sharp, modern, and ultra-compact theme with a Tokyo Night palette.
|
||||
*/
|
||||
|
||||
/*****----- Configuration -----*****/
|
||||
configuration {
|
||||
font: "JetBrains Mono Nerd Font 14";
|
||||
show-icons: false;
|
||||
location: 0; /* 0 = center */
|
||||
width: 50;
|
||||
yoffset: -50;
|
||||
lines: 3;
|
||||
}
|
||||
|
||||
/*****----- Global Properties -----*****/
|
||||
* {
|
||||
/* Tokyo Night Color Palette */
|
||||
bg-col: #1a1b26ff; /* Main Background */
|
||||
bg-alt: #24283bff; /* Lighter Background for elements */
|
||||
fg-col: #c0caf5ff; /* Main Foreground */
|
||||
fg-alt: #a9b1d6ff; /* Dimmer Foreground for placeholders */
|
||||
accent: #bb9af7ff; /* Magenta/Purple for accents */
|
||||
selected: #7aa2f7ff; /* Blue for selection highlight */
|
||||
|
||||
background-color: transparent;
|
||||
text-color: @fg-col;
|
||||
}
|
||||
|
||||
/*****----- Main Window -----*****/
|
||||
window {
|
||||
transparency: "real";
|
||||
background-color: @bg-col;
|
||||
border: 2px;
|
||||
border-color: @selected; /* Using blue for the main border */
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
/*****----- Main Box -----*****/
|
||||
mainbox {
|
||||
children: [ inputbar, listview ];
|
||||
spacing: 10px;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
/*****----- Inputbar -----*****/
|
||||
inputbar {
|
||||
background-color: @bg-alt;
|
||||
border: 1px;
|
||||
border-color: @accent; /* Using magenta for the input border */
|
||||
border-radius: 6px;
|
||||
padding: 6px 12px;
|
||||
spacing: 12px;
|
||||
children: [ prompt, entry ];
|
||||
}
|
||||
|
||||
prompt {
|
||||
background-color: transparent;
|
||||
text-color: @accent;
|
||||
}
|
||||
|
||||
entry {
|
||||
background-color: transparent;
|
||||
text-color: @fg-col;
|
||||
placeholder: "Search...";
|
||||
placeholder-color: @fg-alt;
|
||||
}
|
||||
|
||||
/*****----- List of items -----*****/
|
||||
listview {
|
||||
scrollbar: false;
|
||||
spacing: 4px;
|
||||
padding: 4px 0px;
|
||||
layout: vertical;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
/*****----- Elements -----*****/
|
||||
element {
|
||||
padding: 6px 12px;
|
||||
border-radius: 6px;
|
||||
spacing: 15px;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
element-text {
|
||||
vertical-align: 0.5;
|
||||
background-color: transparent;
|
||||
text-color: inherit;
|
||||
}
|
||||
|
||||
/* Default state of elements */
|
||||
element normal.normal {
|
||||
background-color: transparent;
|
||||
text-color: @fg-col;
|
||||
}
|
||||
|
||||
/* Selected entry in the list */
|
||||
element selected.normal {
|
||||
background-color: @selected; /* Blue highlight */
|
||||
text-color: @bg-col; /* Dark text for high contrast */
|
||||
}
|
||||
109
viu/assets/defaults/rofi-themes/preview.rasi
Normal file
109
viu/assets/defaults/rofi-themes/preview.rasi
Normal file
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Rofi Theme: Viu "Tokyo Night" Horizontal Strip
|
||||
* Author: Gemini ft Benexl
|
||||
* Description: A fullscreen, horizontal, icon-centric theme for previews.
|
||||
*/
|
||||
|
||||
/*****----- Configuration -----*****/
|
||||
configuration {
|
||||
font: "JetBrains Mono Nerd Font 12";
|
||||
show-icons: true;
|
||||
}
|
||||
|
||||
/*****----- Global Properties -----*****/
|
||||
* {
|
||||
/* Tokyo Night Color Palette */
|
||||
bg-col: #1a1b26;
|
||||
bg-alt: #24283b; /* Slightly lighter for elements */
|
||||
fg-col: #c0caf5;
|
||||
fg-alt: #a9b1d6;
|
||||
|
||||
blue: #7aa2f7;
|
||||
cyan: #7dcfff;
|
||||
magenta: #bb9af7;
|
||||
|
||||
background-color: transparent;
|
||||
text-color: @fg-col;
|
||||
}
|
||||
|
||||
/*****----- Main Window -----*****/
|
||||
window {
|
||||
transparency: "real";
|
||||
background-color: @bg-col;
|
||||
fullscreen: true;
|
||||
padding: 2%;
|
||||
}
|
||||
|
||||
/*****----- Main Box -----*****/
|
||||
mainbox {
|
||||
children: [ inputbar, listview ];
|
||||
spacing: 3%;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
/*****----- Inputbar -----*****/
|
||||
inputbar {
|
||||
spacing: 15px;
|
||||
padding: 12px 16px;
|
||||
border-radius: 10px;
|
||||
background-color: @bg-alt;
|
||||
text-color: @fg-col;
|
||||
margin: 0% 20%; /* Center the input bar */
|
||||
children: [ prompt, entry ];
|
||||
}
|
||||
|
||||
prompt {
|
||||
text-color: @magenta;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
entry {
|
||||
background-color: transparent;
|
||||
placeholder: "Select an option...";
|
||||
placeholder-color: @fg-alt;
|
||||
}
|
||||
|
||||
/*****----- List of items -----*****/
|
||||
listview {
|
||||
layout: horizontal;
|
||||
columns: 5;
|
||||
spacing: 20px;
|
||||
fixed-height: true;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
/*****----- Elements -----*****/
|
||||
element {
|
||||
orientation: vertical;
|
||||
padding: 30px 20px;
|
||||
border-radius: 12px;
|
||||
spacing: 20px;
|
||||
background-color: @bg-alt;
|
||||
cursor: pointer;
|
||||
width: 200px; /* Width of each element */
|
||||
height: 50px; /* Height of each element */
|
||||
}
|
||||
|
||||
element-icon {
|
||||
size: 33%;
|
||||
horizontal-align: 0.5;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
element-text {
|
||||
horizontal-align: 0.5;
|
||||
background-color: transparent;
|
||||
text-color: inherit;
|
||||
}
|
||||
|
||||
/* Default state of elements */
|
||||
element normal.normal {
|
||||
background-color: @bg-alt;
|
||||
text-color: @fg-col;
|
||||
}
|
||||
|
||||
/* Selected entry in the list */
|
||||
element selected.normal {
|
||||
background-color: @blue;
|
||||
text-color: @bg-col; /* Invert text color for contrast */
|
||||
}
|
||||
16
viu/assets/defaults/viu-worker.template.service
Normal file
16
viu/assets/defaults/viu-worker.template.service
Normal file
@@ -0,0 +1,16 @@
|
||||
# values in {NAME} syntax are provided by python using .replace()
|
||||
#
|
||||
[Unit]
|
||||
Description=Viu Background Worker
|
||||
After=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
# Ensure you have the full path to your viu executable
|
||||
# Use `which viu` to find it
|
||||
ExecStart={EXECUTABLE} worker --log
|
||||
Restart=always
|
||||
RestartSec=30
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
7
viu/assets/graphql/allanime/queries/anime.gql
Normal file
7
viu/assets/graphql/allanime/queries/anime.gql
Normal file
@@ -0,0 +1,7 @@
|
||||
query ($showId: String!) {
|
||||
show(_id: $showId) {
|
||||
_id
|
||||
name
|
||||
availableEpisodesDetail
|
||||
}
|
||||
}
|
||||
15
viu/assets/graphql/allanime/queries/episodes.gql
Normal file
15
viu/assets/graphql/allanime/queries/episodes.gql
Normal file
@@ -0,0 +1,15 @@
|
||||
query (
|
||||
$showId: String!
|
||||
$translationType: VaildTranslationTypeEnumType!
|
||||
$episodeString: String!
|
||||
) {
|
||||
episode(
|
||||
showId: $showId
|
||||
translationType: $translationType
|
||||
episodeString: $episodeString
|
||||
) {
|
||||
episodeString
|
||||
sourceUrls
|
||||
notes
|
||||
}
|
||||
}
|
||||
25
viu/assets/graphql/allanime/queries/search.gql
Normal file
25
viu/assets/graphql/allanime/queries/search.gql
Normal file
@@ -0,0 +1,25 @@
|
||||
query (
|
||||
$search: SearchInput
|
||||
$limit: Int
|
||||
$page: Int
|
||||
$translationType: VaildTranslationTypeEnumType
|
||||
$countryOrigin: VaildCountryOriginEnumType
|
||||
) {
|
||||
shows(
|
||||
search: $search
|
||||
limit: $limit
|
||||
page: $page
|
||||
translationType: $translationType
|
||||
countryOrigin: $countryOrigin
|
||||
) {
|
||||
pageInfo {
|
||||
total
|
||||
}
|
||||
edges {
|
||||
_id
|
||||
name
|
||||
availableEpisodes
|
||||
__typename
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
mutation ($id: Int) {
|
||||
DeleteMediaListEntry(id: $id) {
|
||||
deleted
|
||||
}
|
||||
}
|
||||
5
viu/assets/graphql/anilist/mutations/mark-read.gql
Normal file
5
viu/assets/graphql/anilist/mutations/mark-read.gql
Normal file
@@ -0,0 +1,5 @@
|
||||
mutation {
|
||||
UpdateUser {
|
||||
unreadNotificationCount
|
||||
}
|
||||
}
|
||||
32
viu/assets/graphql/anilist/mutations/media-list.gql
Normal file
32
viu/assets/graphql/anilist/mutations/media-list.gql
Normal file
@@ -0,0 +1,32 @@
|
||||
mutation (
|
||||
$mediaId: Int
|
||||
$scoreRaw: Int
|
||||
$repeat: Int
|
||||
$progress: Int
|
||||
$status: MediaListStatus
|
||||
) {
|
||||
SaveMediaListEntry(
|
||||
mediaId: $mediaId
|
||||
scoreRaw: $scoreRaw
|
||||
progress: $progress
|
||||
repeat: $repeat
|
||||
status: $status
|
||||
) {
|
||||
id
|
||||
status
|
||||
mediaId
|
||||
score
|
||||
progress
|
||||
repeat
|
||||
startedAt {
|
||||
year
|
||||
month
|
||||
day
|
||||
}
|
||||
completedAt {
|
||||
year
|
||||
month
|
||||
day
|
||||
}
|
||||
}
|
||||
}
|
||||
11
viu/assets/graphql/anilist/queries/logged-in-user.gql
Normal file
11
viu/assets/graphql/anilist/queries/logged-in-user.gql
Normal file
@@ -0,0 +1,11 @@
|
||||
query {
|
||||
Viewer {
|
||||
id
|
||||
name
|
||||
bannerImage
|
||||
avatar {
|
||||
large
|
||||
medium
|
||||
}
|
||||
}
|
||||
}
|
||||
13
viu/assets/graphql/anilist/queries/media-airing-schedule.gql
Normal file
13
viu/assets/graphql/anilist/queries/media-airing-schedule.gql
Normal file
@@ -0,0 +1,13 @@
|
||||
query ($id: Int, $type: MediaType) {
|
||||
Page {
|
||||
media(id: $id, sort: POPULARITY_DESC, type: $type) {
|
||||
airingSchedule(notYetAired: true) {
|
||||
nodes {
|
||||
airingAt
|
||||
timeUntilAiring
|
||||
episode
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
31
viu/assets/graphql/anilist/queries/media-characters.gql
Normal file
31
viu/assets/graphql/anilist/queries/media-characters.gql
Normal file
@@ -0,0 +1,31 @@
|
||||
query ($id: Int, $type: MediaType) {
|
||||
Page {
|
||||
media(id: $id, type: $type) {
|
||||
characters {
|
||||
nodes {
|
||||
name {
|
||||
first
|
||||
middle
|
||||
last
|
||||
full
|
||||
native
|
||||
}
|
||||
image {
|
||||
medium
|
||||
large
|
||||
}
|
||||
description
|
||||
gender
|
||||
dateOfBirth {
|
||||
year
|
||||
month
|
||||
day
|
||||
}
|
||||
age
|
||||
bloodType
|
||||
favourites
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
5
viu/assets/graphql/anilist/queries/media-list-item.gql
Normal file
5
viu/assets/graphql/anilist/queries/media-list-item.gql
Normal file
@@ -0,0 +1,5 @@
|
||||
query ($mediaId: Int) {
|
||||
MediaList(mediaId: $mediaId) {
|
||||
id
|
||||
}
|
||||
}
|
||||
94
viu/assets/graphql/anilist/queries/media-list.gql
Normal file
94
viu/assets/graphql/anilist/queries/media-list.gql
Normal file
@@ -0,0 +1,94 @@
|
||||
query (
|
||||
$userId: Int
|
||||
$status: MediaListStatus
|
||||
$type: MediaType
|
||||
$page: Int
|
||||
$perPage: Int
|
||||
$sort: [MediaListSort]
|
||||
) {
|
||||
Page(perPage: $perPage, page: $page) {
|
||||
pageInfo {
|
||||
total
|
||||
currentPage
|
||||
hasNextPage
|
||||
}
|
||||
mediaList(userId: $userId, status: $status, type: $type, sort: $sort) {
|
||||
mediaId
|
||||
media {
|
||||
id
|
||||
idMal
|
||||
format
|
||||
title {
|
||||
romaji
|
||||
english
|
||||
}
|
||||
coverImage {
|
||||
medium
|
||||
large
|
||||
}
|
||||
trailer {
|
||||
site
|
||||
id
|
||||
}
|
||||
popularity
|
||||
streamingEpisodes {
|
||||
title
|
||||
thumbnail
|
||||
}
|
||||
favourites
|
||||
averageScore
|
||||
episodes
|
||||
genres
|
||||
synonyms
|
||||
studios {
|
||||
nodes {
|
||||
name
|
||||
favourites
|
||||
isAnimationStudio
|
||||
}
|
||||
}
|
||||
tags {
|
||||
name
|
||||
}
|
||||
startDate {
|
||||
year
|
||||
month
|
||||
day
|
||||
}
|
||||
endDate {
|
||||
year
|
||||
month
|
||||
day
|
||||
}
|
||||
status
|
||||
description
|
||||
mediaListEntry {
|
||||
status
|
||||
id
|
||||
progress
|
||||
}
|
||||
nextAiringEpisode {
|
||||
timeUntilAiring
|
||||
airingAt
|
||||
episode
|
||||
}
|
||||
}
|
||||
status
|
||||
progress
|
||||
score
|
||||
repeat
|
||||
notes
|
||||
startedAt {
|
||||
year
|
||||
month
|
||||
day
|
||||
}
|
||||
completedAt {
|
||||
year
|
||||
month
|
||||
day
|
||||
}
|
||||
createdAt
|
||||
}
|
||||
}
|
||||
}
|
||||
60
viu/assets/graphql/anilist/queries/media-recommendations.gql
Normal file
60
viu/assets/graphql/anilist/queries/media-recommendations.gql
Normal file
@@ -0,0 +1,60 @@
|
||||
query ($id: Int, $page: Int, $per_page: Int) {
|
||||
Page(perPage: $per_page, page: $page) {
|
||||
recommendations(mediaRecommendationId: $id) {
|
||||
media {
|
||||
id
|
||||
idMal
|
||||
format
|
||||
mediaListEntry {
|
||||
status
|
||||
id
|
||||
progress
|
||||
}
|
||||
title {
|
||||
english
|
||||
romaji
|
||||
native
|
||||
}
|
||||
coverImage {
|
||||
medium
|
||||
large
|
||||
}
|
||||
description
|
||||
episodes
|
||||
duration
|
||||
trailer {
|
||||
site
|
||||
id
|
||||
}
|
||||
genres
|
||||
synonyms
|
||||
averageScore
|
||||
popularity
|
||||
streamingEpisodes {
|
||||
title
|
||||
thumbnail
|
||||
}
|
||||
favourites
|
||||
tags {
|
||||
name
|
||||
}
|
||||
startDate {
|
||||
year
|
||||
month
|
||||
day
|
||||
}
|
||||
endDate {
|
||||
year
|
||||
month
|
||||
day
|
||||
}
|
||||
status
|
||||
nextAiringEpisode {
|
||||
timeUntilAiring
|
||||
airingAt
|
||||
episode
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
61
viu/assets/graphql/anilist/queries/media-relations.gql
Normal file
61
viu/assets/graphql/anilist/queries/media-relations.gql
Normal file
@@ -0,0 +1,61 @@
|
||||
query ($id: Int, $format_not_in: [MediaFormat]) {
|
||||
Media(id: $id, format_not_in: $format_not_in) {
|
||||
relations {
|
||||
nodes {
|
||||
id
|
||||
idMal
|
||||
type
|
||||
format
|
||||
title {
|
||||
english
|
||||
romaji
|
||||
native
|
||||
}
|
||||
coverImage {
|
||||
medium
|
||||
large
|
||||
}
|
||||
mediaListEntry {
|
||||
status
|
||||
id
|
||||
progress
|
||||
}
|
||||
description
|
||||
episodes
|
||||
duration
|
||||
trailer {
|
||||
site
|
||||
id
|
||||
}
|
||||
genres
|
||||
synonyms
|
||||
averageScore
|
||||
popularity
|
||||
streamingEpisodes {
|
||||
title
|
||||
thumbnail
|
||||
}
|
||||
favourites
|
||||
tags {
|
||||
name
|
||||
}
|
||||
startDate {
|
||||
year
|
||||
month
|
||||
day
|
||||
}
|
||||
endDate {
|
||||
year
|
||||
month
|
||||
day
|
||||
}
|
||||
status
|
||||
nextAiringEpisode {
|
||||
timeUntilAiring
|
||||
airingAt
|
||||
episode
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
28
viu/assets/graphql/anilist/queries/notifications.gql
Normal file
28
viu/assets/graphql/anilist/queries/notifications.gql
Normal file
@@ -0,0 +1,28 @@
|
||||
query {
|
||||
Page(perPage: 5) {
|
||||
pageInfo {
|
||||
total
|
||||
}
|
||||
notifications(resetNotificationCount: true, type: AIRING) {
|
||||
... on AiringNotification {
|
||||
id
|
||||
type
|
||||
episode
|
||||
contexts
|
||||
createdAt
|
||||
media {
|
||||
id
|
||||
idMal
|
||||
title {
|
||||
romaji
|
||||
english
|
||||
}
|
||||
coverImage {
|
||||
medium
|
||||
large
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
18
viu/assets/graphql/anilist/queries/reviews.gql
Normal file
18
viu/assets/graphql/anilist/queries/reviews.gql
Normal file
@@ -0,0 +1,18 @@
|
||||
query ($id: Int) {
|
||||
Page {
|
||||
pageInfo {
|
||||
total
|
||||
}
|
||||
reviews(mediaId: $id) {
|
||||
summary
|
||||
user {
|
||||
name
|
||||
avatar {
|
||||
large
|
||||
medium
|
||||
}
|
||||
}
|
||||
body
|
||||
}
|
||||
}
|
||||
}
|
||||
121
viu/assets/graphql/anilist/queries/search.gql
Normal file
121
viu/assets/graphql/anilist/queries/search.gql
Normal file
@@ -0,0 +1,121 @@
|
||||
query (
|
||||
$query: String
|
||||
$per_page: Int
|
||||
$page: Int
|
||||
$sort: [MediaSort]
|
||||
$id_in: [Int]
|
||||
$genre_in: [String]
|
||||
$genre_not_in: [String]
|
||||
$tag_in: [String]
|
||||
$tag_not_in: [String]
|
||||
$status_in: [MediaStatus]
|
||||
$status: MediaStatus
|
||||
$status_not_in: [MediaStatus]
|
||||
$popularity_greater: Int
|
||||
$popularity_lesser: Int
|
||||
$averageScore_greater: Int
|
||||
$averageScore_lesser: Int
|
||||
$seasonYear: Int
|
||||
$startDate_greater: FuzzyDateInt
|
||||
$startDate_lesser: FuzzyDateInt
|
||||
$startDate: FuzzyDateInt
|
||||
$endDate_greater: FuzzyDateInt
|
||||
$endDate_lesser: FuzzyDateInt
|
||||
$format_in: [MediaFormat]
|
||||
$type: MediaType
|
||||
$season: MediaSeason
|
||||
$on_list: Boolean
|
||||
) {
|
||||
Page(perPage: $per_page, page: $page) {
|
||||
pageInfo {
|
||||
total
|
||||
currentPage
|
||||
hasNextPage
|
||||
}
|
||||
media(
|
||||
search: $query
|
||||
id_in: $id_in
|
||||
genre_in: $genre_in
|
||||
genre_not_in: $genre_not_in
|
||||
tag_in: $tag_in
|
||||
tag_not_in: $tag_not_in
|
||||
status_in: $status_in
|
||||
status: $status
|
||||
startDate: $startDate
|
||||
status_not_in: $status_not_in
|
||||
popularity_greater: $popularity_greater
|
||||
popularity_lesser: $popularity_lesser
|
||||
averageScore_greater: $averageScore_greater
|
||||
averageScore_lesser: $averageScore_lesser
|
||||
startDate_greater: $startDate_greater
|
||||
startDate_lesser: $startDate_lesser
|
||||
endDate_greater: $endDate_greater
|
||||
endDate_lesser: $endDate_lesser
|
||||
format_in: $format_in
|
||||
sort: $sort
|
||||
season: $season
|
||||
seasonYear: $seasonYear
|
||||
type: $type
|
||||
onList: $on_list
|
||||
) {
|
||||
id
|
||||
idMal
|
||||
format
|
||||
title {
|
||||
romaji
|
||||
english
|
||||
}
|
||||
coverImage {
|
||||
medium
|
||||
large
|
||||
}
|
||||
trailer {
|
||||
site
|
||||
id
|
||||
}
|
||||
mediaListEntry {
|
||||
status
|
||||
id
|
||||
progress
|
||||
}
|
||||
popularity
|
||||
streamingEpisodes {
|
||||
title
|
||||
thumbnail
|
||||
}
|
||||
favourites
|
||||
averageScore
|
||||
duration
|
||||
episodes
|
||||
genres
|
||||
synonyms
|
||||
studios {
|
||||
nodes {
|
||||
name
|
||||
favourites
|
||||
isAnimationStudio
|
||||
}
|
||||
}
|
||||
tags {
|
||||
name
|
||||
}
|
||||
startDate {
|
||||
year
|
||||
month
|
||||
day
|
||||
}
|
||||
endDate {
|
||||
year
|
||||
month
|
||||
day
|
||||
}
|
||||
status
|
||||
description
|
||||
nextAiringEpisode {
|
||||
timeUntilAiring
|
||||
airingAt
|
||||
episode
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
62
viu/assets/graphql/anilist/queries/user-info.gql
Normal file
62
viu/assets/graphql/anilist/queries/user-info.gql
Normal file
@@ -0,0 +1,62 @@
|
||||
query ($userId: Int) {
|
||||
User(id: $userId) {
|
||||
name
|
||||
about
|
||||
avatar {
|
||||
large
|
||||
medium
|
||||
}
|
||||
bannerImage
|
||||
statistics {
|
||||
anime {
|
||||
count
|
||||
minutesWatched
|
||||
episodesWatched
|
||||
genres {
|
||||
count
|
||||
meanScore
|
||||
genre
|
||||
}
|
||||
tags {
|
||||
tag {
|
||||
id
|
||||
}
|
||||
count
|
||||
meanScore
|
||||
}
|
||||
}
|
||||
manga {
|
||||
count
|
||||
meanScore
|
||||
chaptersRead
|
||||
volumesRead
|
||||
tags {
|
||||
count
|
||||
meanScore
|
||||
}
|
||||
genres {
|
||||
count
|
||||
meanScore
|
||||
}
|
||||
}
|
||||
}
|
||||
favourites {
|
||||
anime {
|
||||
nodes {
|
||||
title {
|
||||
romaji
|
||||
english
|
||||
}
|
||||
}
|
||||
}
|
||||
manga {
|
||||
nodes {
|
||||
title {
|
||||
romaji
|
||||
english
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
viu/assets/icons/logo.ico
Normal file
BIN
viu/assets/icons/logo.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.7 KiB |
BIN
viu/assets/icons/logo.png
Normal file
BIN
viu/assets/icons/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 276 KiB |
17
viu/assets/normalizer.json
Normal file
17
viu/assets/normalizer.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"allanime": {
|
||||
"1P": "one piece",
|
||||
"Magia Record: Mahou Shoujo Madoka☆Magica Gaiden (TV)": "Mahou Shoujo Madoka☆Magica",
|
||||
"Dungeon ni Deai o Motomeru no wa Machigatte Iru Darouka": "Dungeon ni Deai wo Motomeru no wa Machigatteiru Darou ka",
|
||||
"Hazurewaku no \"Joutai Ijou Skill\" de Saikyou ni Natta Ore ga Subete wo Juurin suru made": "Hazure Waku no [Joutai Ijou Skill] de Saikyou ni Natta Ore ga Subete wo Juurin Suru made",
|
||||
"Re:Zero kara Hajimeru Isekai Seikatsu Season 3": "Re:Zero kara Hajimeru Isekai Seikatsu 3rd Season"
|
||||
},
|
||||
"hianime": {
|
||||
"My Star": "Oshi no Ko"
|
||||
},
|
||||
"animepahe": {
|
||||
"Azumanga Daiou The Animation": "Azumanga Daioh",
|
||||
"Mairimashita! Iruma-kun 2nd Season": "Mairimashita! Iruma-kun 2",
|
||||
"Mairimashita! Iruma-kun 3rd Season": "Mairimashita! Iruma-kun 3"
|
||||
}
|
||||
}
|
||||
22
viu/assets/scripts/fzf/airing-schedule-info.template.sh
Normal file
22
viu/assets/scripts/fzf/airing-schedule-info.template.sh
Normal file
@@ -0,0 +1,22 @@
|
||||
#!/bin/sh
|
||||
#
|
||||
# Viu Airing Schedule Info Script Template
|
||||
# This script formats and displays airing schedule details in the FZF preview pane.
|
||||
# Python injects the actual data values into the placeholders.
|
||||
|
||||
draw_rule
|
||||
|
||||
print_kv "Anime Title" "{ANIME_TITLE}"
|
||||
|
||||
draw_rule
|
||||
|
||||
print_kv "Total Episodes" "{TOTAL_EPISODES}"
|
||||
print_kv "Upcoming Episodes" "{UPCOMING_EPISODES}"
|
||||
|
||||
draw_rule
|
||||
|
||||
echo "{C_KEY}Next Episodes:{RESET}"
|
||||
echo
|
||||
echo "{SCHEDULE_TABLE}" | fold -s -w "$WIDTH"
|
||||
|
||||
draw_rule
|
||||
75
viu/assets/scripts/fzf/airing-schedule-preview.template.sh
Normal file
75
viu/assets/scripts/fzf/airing-schedule-preview.template.sh
Normal file
@@ -0,0 +1,75 @@
|
||||
#!/bin/sh
|
||||
#
|
||||
# FZF Airing Schedule Preview Script Template
|
||||
#
|
||||
# This script is a template. The placeholders in curly braces, like {NAME}
|
||||
# are dynamically filled by python using .replace()
|
||||
|
||||
WIDTH=${FZF_PREVIEW_COLUMNS:-80} # Set a fallback width of 80
|
||||
IMAGE_RENDERER="{IMAGE_RENDERER}"
|
||||
|
||||
generate_sha256() {
|
||||
local input
|
||||
|
||||
# Check if input is passed as an argument or piped
|
||||
if [ -n "$1" ]; then
|
||||
input="$1"
|
||||
else
|
||||
input=$(cat)
|
||||
fi
|
||||
|
||||
if command -v sha256sum &>/dev/null; then
|
||||
echo -n "$input" | sha256sum | awk '{print $1}'
|
||||
elif command -v shasum &>/dev/null; then
|
||||
echo -n "$input" | shasum -a 256 | awk '{print $1}'
|
||||
elif command -v sha256 &>/dev/null; then
|
||||
echo -n "$input" | sha256 | awk '{print $1}'
|
||||
elif command -v openssl &>/dev/null; then
|
||||
echo -n "$input" | openssl dgst -sha256 | awk '{print $2}'
|
||||
else
|
||||
echo -n "$input" | base64 | tr '/+' '_-' | tr -d '\n'
|
||||
fi
|
||||
}
|
||||
|
||||
|
||||
print_kv() {
|
||||
local key="$1"
|
||||
local value="$2"
|
||||
local key_len=${#key}
|
||||
local value_len=${#value}
|
||||
local multiplier="${3:-1}"
|
||||
|
||||
# Correctly calculate padding by accounting for the key, the ": ", and the value.
|
||||
local padding_len=$((WIDTH - key_len - 2 - value_len * multiplier))
|
||||
|
||||
# If the text is too long to fit, just add a single space for separation.
|
||||
if [ "$padding_len" -lt 1 ]; then
|
||||
padding_len=1
|
||||
value=$(echo $value| fold -s -w "$((WIDTH - key_len - 3))")
|
||||
printf "{C_KEY}%s:{RESET}%*s%s\\n" "$key" "$padding_len" "" " $value"
|
||||
else
|
||||
printf "{C_KEY}%s:{RESET}%*s%s\\n" "$key" "$padding_len" "" " $value"
|
||||
fi
|
||||
}
|
||||
|
||||
|
||||
draw_rule(){
|
||||
ll=2
|
||||
while [ $ll -le $FZF_PREVIEW_COLUMNS ];do
|
||||
echo -n -e "{C_RULE}─{RESET}"
|
||||
((ll++))
|
||||
done
|
||||
echo
|
||||
}
|
||||
|
||||
title={}
|
||||
hash=$(generate_sha256 "$title")
|
||||
|
||||
if [ "{PREVIEW_MODE}" = "full" ] || [ "{PREVIEW_MODE}" = "text" ]; then
|
||||
info_file="{INFO_CACHE_DIR}{PATH_SEP}$hash"
|
||||
if [ -f "$info_file" ]; then
|
||||
source "$info_file"
|
||||
else
|
||||
echo "📅 Loading airing schedule..."
|
||||
fi
|
||||
fi
|
||||
41
viu/assets/scripts/fzf/character-info.template.sh
Normal file
41
viu/assets/scripts/fzf/character-info.template.sh
Normal file
@@ -0,0 +1,41 @@
|
||||
#!/bin/sh
|
||||
#
|
||||
# Viu Character Info Script Template
|
||||
# This script formats and displays character details in the FZF preview pane.
|
||||
# Python injects the actual data values into the placeholders.
|
||||
|
||||
draw_rule
|
||||
|
||||
print_kv "Character Name" "{CHARACTER_NAME}"
|
||||
|
||||
if [ -n "{CHARACTER_NATIVE_NAME}" ] && [ "{CHARACTER_NATIVE_NAME}" != "N/A" ]; then
|
||||
print_kv "Native Name" "{CHARACTER_NATIVE_NAME}"
|
||||
fi
|
||||
|
||||
draw_rule
|
||||
|
||||
if [ -n "{CHARACTER_GENDER}" ] && [ "{CHARACTER_GENDER}" != "Unknown" ]; then
|
||||
print_kv "Gender" "{CHARACTER_GENDER}"
|
||||
fi
|
||||
|
||||
if [ -n "{CHARACTER_AGE}" ] && [ "{CHARACTER_AGE}" != "Unknown" ]; then
|
||||
print_kv "Age" "{CHARACTER_AGE}"
|
||||
fi
|
||||
|
||||
if [ -n "{CHARACTER_BLOOD_TYPE}" ] && [ "{CHARACTER_BLOOD_TYPE}" != "N/A" ]; then
|
||||
print_kv "Blood Type" "{CHARACTER_BLOOD_TYPE}"
|
||||
fi
|
||||
|
||||
if [ -n "{CHARACTER_BIRTHDAY}" ] && [ "{CHARACTER_BIRTHDAY}" != "N/A" ]; then
|
||||
print_kv "Birthday" "{CHARACTER_BIRTHDAY}"
|
||||
fi
|
||||
|
||||
if [ -n "{CHARACTER_FAVOURITES}" ] && [ "{CHARACTER_FAVOURITES}" != "0" ]; then
|
||||
print_kv "Favorites" "{CHARACTER_FAVOURITES}"
|
||||
fi
|
||||
|
||||
draw_rule
|
||||
|
||||
echo "{CHARACTER_DESCRIPTION}" | fold -s -w "$WIDTH"
|
||||
|
||||
draw_rule
|
||||
130
viu/assets/scripts/fzf/character-preview.template.sh
Normal file
130
viu/assets/scripts/fzf/character-preview.template.sh
Normal file
@@ -0,0 +1,130 @@
|
||||
#!/bin/sh
|
||||
#
|
||||
# FZF Character Preview Script Template
|
||||
#
|
||||
# This script is a template. The placeholders in curly braces, like {NAME}
|
||||
# are dynamically filled by python using .replace()
|
||||
|
||||
WIDTH=${FZF_PREVIEW_COLUMNS:-80} # Set a fallback width of 80
|
||||
IMAGE_RENDERER="{IMAGE_RENDERER}"
|
||||
|
||||
generate_sha256() {
|
||||
local input
|
||||
|
||||
# Check if input is passed as an argument or piped
|
||||
if [ -n "$1" ]; then
|
||||
input="$1"
|
||||
else
|
||||
input=$(cat)
|
||||
fi
|
||||
|
||||
if command -v sha256sum &>/dev/null; then
|
||||
echo -n "$input" | sha256sum | awk '{print $1}'
|
||||
elif command -v shasum &>/dev/null; then
|
||||
echo -n "$input" | shasum -a 256 | awk '{print $1}'
|
||||
elif command -v sha256 &>/dev/null; then
|
||||
echo -n "$input" | sha256 | awk '{print $1}'
|
||||
elif command -v openssl &>/dev/null; then
|
||||
echo -n "$input" | openssl dgst -sha256 | awk '{print $2}'
|
||||
else
|
||||
echo -n "$input" | base64 | tr '/+' '_-' | tr -d '\n'
|
||||
fi
|
||||
}
|
||||
|
||||
fzf_preview() {
|
||||
file=$1
|
||||
|
||||
dim=${FZF_PREVIEW_COLUMNS}x${FZF_PREVIEW_LINES}
|
||||
if [ "$dim" = x ]; then
|
||||
dim=$(stty size </dev/tty | awk "{print \$2 \"x\" \$1}")
|
||||
fi
|
||||
if ! [ "$IMAGE_RENDERER" = "icat" ] && [ -z "$KITTY_WINDOW_ID" ] && [ "$((FZF_PREVIEW_TOP + FZF_PREVIEW_LINES))" -eq "$(stty size </dev/tty | awk "{print \$1}")" ]; then
|
||||
dim=${FZF_PREVIEW_COLUMNS}x$((FZF_PREVIEW_LINES - 1))
|
||||
fi
|
||||
|
||||
if [ "$IMAGE_RENDERER" = "icat" ] && [ -z "$GHOSTTY_BIN_DIR" ]; then
|
||||
if command -v kitten >/dev/null 2>&1; then
|
||||
kitten icat --clear --transfer-mode=memory --unicode-placeholder --stdin=no --place="$dim@0x0" "$file" | sed "\$d" | sed "$(printf "\$s/\$/\033[m/")"
|
||||
elif command -v icat >/dev/null 2>&1; then
|
||||
icat --clear --transfer-mode=memory --unicode-placeholder --stdin=no --place="$dim@0x0" "$file" | sed "\$d" | sed "$(printf "\$s/\$/\033[m/")"
|
||||
else
|
||||
kitty icat --clear --transfer-mode=memory --unicode-placeholder --stdin=no --place="$dim@0x0" "$file" | sed "\$d" | sed "$(printf "\$s/\$/\033[m/")"
|
||||
fi
|
||||
|
||||
elif [ -n "$GHOSTTY_BIN_DIR" ]; then
|
||||
if command -v kitten >/dev/null 2>&1; then
|
||||
kitten icat --clear --transfer-mode=memory --unicode-placeholder --stdin=no --place="$dim@0x0" "$file" | sed "\$d" | sed "$(printf "\$s/\$/\033[m/")"
|
||||
elif command -v icat >/dev/null 2>&1; then
|
||||
icat --clear --transfer-mode=memory --unicode-placeholder --stdin=no --place="$dim@0x0" "$file" | sed "\$d" | sed "$(printf "\$s/\$/\033[m/")"
|
||||
else
|
||||
chafa -s "$dim" "$file"
|
||||
fi
|
||||
elif command -v chafa >/dev/null 2>&1; then
|
||||
case "$PLATFORM" in
|
||||
android) chafa -s "$dim" "$file" ;;
|
||||
windows) chafa -f sixel -s "$dim" "$file" ;;
|
||||
*) chafa -s "$dim" "$file" ;;
|
||||
esac
|
||||
echo
|
||||
|
||||
elif command -v imgcat >/dev/null; then
|
||||
imgcat -W "${dim%%x*}" -H "${dim##*x}" "$file"
|
||||
|
||||
else
|
||||
echo please install a terminal image viewer
|
||||
echo either icat for kitty terminal and wezterm or imgcat or chafa
|
||||
fi
|
||||
}
|
||||
print_kv() {
|
||||
local key="$1"
|
||||
local value="$2"
|
||||
local key_len=${#key}
|
||||
local value_len=${#value}
|
||||
local multiplier="${3:-1}"
|
||||
|
||||
# Correctly calculate padding by accounting for the key, the ": ", and the value.
|
||||
local padding_len=$((WIDTH - key_len - 2 - value_len * multiplier))
|
||||
|
||||
# If the text is too long to fit, just add a single space for separation.
|
||||
if [ "$padding_len" -lt 1 ]; then
|
||||
padding_len=1
|
||||
value=$(echo $value| fold -s -w "$((WIDTH - key_len - 3))")
|
||||
printf "{C_KEY}%s:{RESET}%*s%s\\n" "$key" "$padding_len" "" " $value"
|
||||
else
|
||||
printf "{C_KEY}%s:{RESET}%*s%s\\n" "$key" "$padding_len" "" " $value"
|
||||
fi
|
||||
}
|
||||
|
||||
|
||||
draw_rule(){
|
||||
ll=2
|
||||
while [ $ll -le $FZF_PREVIEW_COLUMNS ];do
|
||||
echo -n -e "{C_RULE}─{RESET}"
|
||||
((ll++))
|
||||
done
|
||||
echo
|
||||
}
|
||||
|
||||
title={}
|
||||
hash=$(generate_sha256 "$title")
|
||||
|
||||
|
||||
# FIXME: Disabled since they cover the text perhaps its aspect ratio related or image format not sure
|
||||
# if [ "{PREVIEW_MODE}" = "full" ] || [ "{PREVIEW_MODE}" = "image" ]; then
|
||||
# image_file="{IMAGE_CACHE_DIR}{PATH_SEP}$hash.png"
|
||||
# if [ -f "$image_file" ]; then
|
||||
# fzf_preview "$image_file"
|
||||
# echo # Add a newline for spacing
|
||||
# fi
|
||||
# fi
|
||||
|
||||
if [ "{PREVIEW_MODE}" = "full" ] || [ "{PREVIEW_MODE}" = "text" ]; then
|
||||
info_file="{INFO_CACHE_DIR}{PATH_SEP}$hash"
|
||||
if [ -f "$info_file" ]; then
|
||||
source "$info_file"
|
||||
else
|
||||
echo "👤 Loading character details..."
|
||||
fi
|
||||
fi
|
||||
|
||||
|
||||
315
viu/assets/scripts/fzf/dynamic-preview.template.sh
Normal file
315
viu/assets/scripts/fzf/dynamic-preview.template.sh
Normal file
@@ -0,0 +1,315 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# FZF Dynamic Preview Script Template
|
||||
#
|
||||
# This script handles previews for dynamic search results by parsing the JSON
|
||||
# search results file and extracting info for the selected item.
|
||||
# The placeholders in curly braces are dynamically filled by Python using .replace()
|
||||
|
||||
WIDTH=${FZF_PREVIEW_COLUMNS:-80}
|
||||
IMAGE_RENDERER="{IMAGE_RENDERER}"
|
||||
SEARCH_RESULTS_FILE="{SEARCH_RESULTS_FILE}"
|
||||
IMAGE_CACHE_PATH="{IMAGE_CACHE_PATH}"
|
||||
INFO_CACHE_PATH="{INFO_CACHE_PATH}"
|
||||
PATH_SEP="{PATH_SEP}"
|
||||
|
||||
# Color codes injected by Python
|
||||
C_TITLE="{C_TITLE}"
|
||||
C_KEY="{C_KEY}"
|
||||
C_VALUE="{C_VALUE}"
|
||||
C_RULE="{C_RULE}"
|
||||
RESET="{RESET}"
|
||||
|
||||
# Selected item from fzf
|
||||
SELECTED_ITEM={}
|
||||
|
||||
generate_sha256() {
|
||||
local input="$1"
|
||||
if command -v sha256sum &>/dev/null; then
|
||||
echo -n "$input" | sha256sum | awk '{print $1}'
|
||||
elif command -v shasum &>/dev/null; then
|
||||
echo -n "$input" | shasum -a 256 | awk '{print $1}'
|
||||
elif command -v sha256 &>/dev/null; then
|
||||
echo -n "$input" | sha256 | awk '{print $1}'
|
||||
elif command -v openssl &>/dev/null; then
|
||||
echo -n "$input" | openssl dgst -sha256 | awk '{print $2}'
|
||||
else
|
||||
echo -n "$input" | base64 | tr '/+' '_-' | tr -d '\n'
|
||||
fi
|
||||
}
|
||||
|
||||
fzf_preview() {
|
||||
file=$1
|
||||
dim=${FZF_PREVIEW_COLUMNS}x${FZF_PREVIEW_LINES}
|
||||
if [ "$dim" = x ]; then
|
||||
dim=$(stty size </dev/tty | awk "{print \$2 \"x\" \$1}")
|
||||
fi
|
||||
if ! [ "$IMAGE_RENDERER" = "icat" ] && [ -z "$KITTY_WINDOW_ID" ] && [ "$((FZF_PREVIEW_TOP + FZF_PREVIEW_LINES))" -eq "$(stty size </dev/tty | awk "{print \$1}")" ]; then
|
||||
dim=${FZF_PREVIEW_COLUMNS}x$((FZF_PREVIEW_LINES - 1))
|
||||
fi
|
||||
|
||||
if [ "$IMAGE_RENDERER" = "icat" ] && [ -z "$GHOSTTY_BIN_DIR" ]; then
|
||||
if command -v kitten >/dev/null 2>&1; then
|
||||
kitten icat --clear --transfer-mode=memory --unicode-placeholder --stdin=no --place="$dim@0x0" "$file" | sed "\$d" | sed "$(printf "\$s/\$/\033[m/")"
|
||||
elif command -v icat >/dev/null 2>&1; then
|
||||
icat --clear --transfer-mode=memory --unicode-placeholder --stdin=no --place="$dim@0x0" "$file" | sed "\$d" | sed "$(printf "\$s/\$/\033[m/")"
|
||||
else
|
||||
kitty icat --clear --transfer-mode=memory --unicode-placeholder --stdin=no --place="$dim@0x0" "$file" | sed "\$d" | sed "$(printf "\$s/\$/\033[m/")"
|
||||
fi
|
||||
elif [ -n "$GHOSTTY_BIN_DIR" ]; then
|
||||
if command -v kitten >/dev/null 2>&1; then
|
||||
kitten icat --clear --transfer-mode=memory --unicode-placeholder --stdin=no --place="$dim@0x0" "$file" | sed "\$d" | sed "$(printf "\$s/\$/\033[m/")"
|
||||
elif command -v icat >/dev/null 2>&1; then
|
||||
icat --clear --transfer-mode=memory --unicode-placeholder --stdin=no --place="$dim@0x0" "$file" | sed "\$d" | sed "$(printf "\$s/\$/\033[m/")"
|
||||
else
|
||||
chafa -s "$dim" "$file"
|
||||
fi
|
||||
elif command -v chafa >/dev/null 2>&1; then
|
||||
case "$PLATFORM" in
|
||||
android) chafa -s "$dim" "$file" ;;
|
||||
windows) chafa -f sixel -s "$dim" "$file" ;;
|
||||
*) chafa -s "$dim" "$file" ;;
|
||||
esac
|
||||
echo
|
||||
elif command -v imgcat >/dev/null; then
|
||||
imgcat -W "${dim%%x*}" -H "${dim##*x}" "$file"
|
||||
else
|
||||
echo please install a terminal image viewer
|
||||
echo either icat for kitty terminal and wezterm or imgcat or chafa
|
||||
fi
|
||||
}
|
||||
|
||||
print_kv() {
|
||||
local key="$1"
|
||||
local value="$2"
|
||||
local key_len=${#key}
|
||||
local value_len=${#value}
|
||||
local multiplier="${3:-1}"
|
||||
|
||||
local padding_len=$((WIDTH - key_len - 2 - value_len * multiplier))
|
||||
|
||||
if [ "$padding_len" -lt 1 ]; then
|
||||
padding_len=1
|
||||
value=$(echo $value| fold -s -w "$((WIDTH - key_len - 3))")
|
||||
printf "{C_KEY}%s:{RESET}%*s%s\\n" "$key" "$padding_len" "" " $value"
|
||||
else
|
||||
printf "{C_KEY}%s:{RESET}%*s%s\\n" "$key" "$padding_len" "" " $value"
|
||||
fi
|
||||
}
|
||||
|
||||
draw_rule() {
|
||||
ll=2
|
||||
while [ $ll -le $FZF_PREVIEW_COLUMNS ];do
|
||||
echo -n -e "{C_RULE}─{RESET}"
|
||||
((ll++))
|
||||
done
|
||||
echo
|
||||
}
|
||||
|
||||
clean_html() {
|
||||
echo "$1" | sed 's/<[^>]*>//g' | sed 's/</</g' | sed 's/>/>/g' | sed 's/&/\&/g' | sed 's/"/"/g' | sed "s/'/'/g"
|
||||
}
|
||||
|
||||
format_date() {
|
||||
local date_obj="$1"
|
||||
if [ "$date_obj" = "null" ] || [ -z "$date_obj" ]; then
|
||||
echo "N/A"
|
||||
return
|
||||
fi
|
||||
|
||||
# Extract year, month, day from the date object
|
||||
if command -v jq >/dev/null 2>&1; then
|
||||
year=$(echo "$date_obj" | jq -r '.year // "N/A"' 2>/dev/null || echo "N/A")
|
||||
month=$(echo "$date_obj" | jq -r '.month // ""' 2>/dev/null || echo "")
|
||||
day=$(echo "$date_obj" | jq -r '.day // ""' 2>/dev/null || echo "")
|
||||
else
|
||||
year=$(echo "$date_obj" | python3 -c "import json, sys; data=json.load(sys.stdin); print(data.get('year', 'N/A'))" 2>/dev/null || echo "N/A")
|
||||
month=$(echo "$date_obj" | python3 -c "import json, sys; data=json.load(sys.stdin); print(data.get('month', ''))" 2>/dev/null || echo "")
|
||||
day=$(echo "$date_obj" | python3 -c "import json, sys; data=json.load(sys.stdin); print(data.get('day', ''))" 2>/dev/null || echo "")
|
||||
fi
|
||||
|
||||
if [ "$year" = "N/A" ] || [ "$year" = "null" ]; then
|
||||
echo "N/A"
|
||||
elif [ -n "$month" ] && [ "$month" != "null" ] && [ -n "$day" ] && [ "$day" != "null" ]; then
|
||||
echo "$day/$month/$year"
|
||||
elif [ -n "$month" ] && [ "$month" != "null" ]; then
|
||||
echo "$month/$year"
|
||||
else
|
||||
echo "$year"
|
||||
fi
|
||||
}
|
||||
|
||||
# If no selection or search results file doesn't exist, show placeholder
|
||||
if [ -z "$SELECTED_ITEM" ] || [ ! -f "$SEARCH_RESULTS_FILE" ]; then
|
||||
echo "${C_TITLE}Dynamic Search Preview${RESET}"
|
||||
draw_rule
|
||||
echo "Type to search for anime..."
|
||||
echo "Results will appear here as you type."
|
||||
echo
|
||||
echo "DEBUG:"
|
||||
echo "SELECTED_ITEM='$SELECTED_ITEM'"
|
||||
echo "SEARCH_RESULTS_FILE='$SEARCH_RESULTS_FILE'"
|
||||
if [ -f "$SEARCH_RESULTS_FILE" ]; then
|
||||
echo "Search results file exists"
|
||||
else
|
||||
echo "Search results file missing"
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
# Parse the search results JSON and find the matching item
|
||||
if command -v jq >/dev/null 2>&1; then
|
||||
MEDIA_DATA=$(cat "$SEARCH_RESULTS_FILE" | jq --arg anime_title "$SELECTED_ITEM" '
|
||||
.data.Page.media[]? |
|
||||
select((.title.english // .title.romaji // .title.native // "Unknown") == $anime_title )
|
||||
' )
|
||||
else
|
||||
# Fallback to Python for JSON parsing
|
||||
MEDIA_DATA=$(cat "$SEARCH_RESULTS_FILE" | python3 -c "
|
||||
import json
|
||||
import sys
|
||||
|
||||
try:
|
||||
data = json.load(sys.stdin)
|
||||
selected_item = '''$SELECTED_ITEM'''
|
||||
|
||||
if 'data' not in data or 'Page' not in data['data'] or 'media' not in data['data']['Page']:
|
||||
sys.exit(1)
|
||||
|
||||
media_list = data['data']['Page']['media']
|
||||
|
||||
for media in media_list:
|
||||
title = media.get('title', {})
|
||||
english_title = title.get('english') or title.get('romaji') or title.get('native', 'Unknown')
|
||||
year = media.get('startDate', {}).get('year', 'Unknown') if media.get('startDate') else 'Unknown'
|
||||
status = media.get('status', 'Unknown')
|
||||
genres = ', '.join(media.get('genres', [])[:3]) or 'Unknown'
|
||||
display_format = f'{english_title} ({year}) [{status}] - {genres}'
|
||||
# Debug output for matching
|
||||
print(f"DEBUG: selected_item='{selected_item.strip()}' display_format='{display_format.strip()}'", file=sys.stderr)
|
||||
if selected_item.strip() == display_format.strip():
|
||||
json.dump(media, sys.stdout, indent=2)
|
||||
sys.exit(0)
|
||||
print(f"DEBUG: No match found for selected_item='{selected_item.strip()}'", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
print(f'Error: {e}', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
" 2>/dev/null)
|
||||
fi
|
||||
|
||||
# If we couldn't find the media data, show error
|
||||
if [ $? -ne 0 ] || [ -z "$MEDIA_DATA" ]; then
|
||||
echo "${C_TITLE}Preview Error${RESET}"
|
||||
draw_rule
|
||||
echo "Could not load preview data for:"
|
||||
echo "$SELECTED_ITEM"
|
||||
echo
|
||||
echo "DEBUG INFO:"
|
||||
echo "Search results file: $SEARCH_RESULTS_FILE"
|
||||
if [ -f "$SEARCH_RESULTS_FILE" ]; then
|
||||
echo "File exists, size: $(wc -c < "$SEARCH_RESULTS_FILE") bytes"
|
||||
echo "First few lines of search results:"
|
||||
head -3 "$SEARCH_RESULTS_FILE" 2>/dev/null || echo "Cannot read file"
|
||||
else
|
||||
echo "Search results file does not exist"
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Extract information from the media data
|
||||
if command -v jq >/dev/null 2>&1; then
|
||||
# Use jq for faster extraction
|
||||
TITLE=$(echo "$MEDIA_DATA" | jq -r '.title.english // .title.romaji // .title.native // "Unknown"' 2>/dev/null || echo "Unknown")
|
||||
STATUS=$(echo "$MEDIA_DATA" | jq -r '.status // "Unknown"' 2>/dev/null || echo "Unknown")
|
||||
FORMAT=$(echo "$MEDIA_DATA" | jq -r '.format // "Unknown"' 2>/dev/null || echo "Unknown")
|
||||
EPISODES=$(echo "$MEDIA_DATA" | jq -r '.episodes // "Unknown"' 2>/dev/null || echo "Unknown")
|
||||
DURATION=$(echo "$MEDIA_DATA" | jq -r 'if .duration then "\(.duration) min" else "Unknown" end' 2>/dev/null || echo "Unknown")
|
||||
SCORE=$(echo "$MEDIA_DATA" | jq -r 'if .averageScore then "\(.averageScore)/100" else "N/A" end' 2>/dev/null || echo "N/A")
|
||||
FAVOURITES=$(echo "$MEDIA_DATA" | jq -r '.favourites // 0' 2>/dev/null | sed ':a;s/\B[0-9]\{3\}\>/,&/;ta' || echo "0")
|
||||
POPULARITY=$(echo "$MEDIA_DATA" | jq -r '.popularity // 0' 2>/dev/null | sed ':a;s/\B[0-9]\{3\}\>/,&/;ta' || echo "0")
|
||||
GENRES=$(echo "$MEDIA_DATA" | jq -r '(.genres[:5] // []) | join(", ") | if . == "" then "Unknown" else . end' 2>/dev/null || echo "Unknown")
|
||||
DESCRIPTION=$(echo "$MEDIA_DATA" | jq -r '.description // "No description available."' 2>/dev/null || echo "No description available.")
|
||||
|
||||
# Get start and end dates as JSON objects
|
||||
START_DATE_OBJ=$(echo "$MEDIA_DATA" | jq -c '.startDate' 2>/dev/null || echo "null")
|
||||
END_DATE_OBJ=$(echo "$MEDIA_DATA" | jq -c '.endDate' 2>/dev/null || echo "null")
|
||||
|
||||
# Get cover image URL
|
||||
COVER_IMAGE=$(echo "$MEDIA_DATA" | jq -r '.coverImage.large // ""' 2>/dev/null || echo "")
|
||||
else
|
||||
# Fallback to Python for extraction
|
||||
TITLE=$(echo "$MEDIA_DATA" | python3 -c "import json, sys; data=json.load(sys.stdin); title=data.get('title',{}); print(title.get('english') or title.get('romaji') or title.get('native', 'Unknown'))" 2>/dev/null || echo "Unknown")
|
||||
STATUS=$(echo "$MEDIA_DATA" | python3 -c "import json, sys; data=json.load(sys.stdin); print(data.get('status', 'Unknown'))" 2>/dev/null || echo "Unknown")
|
||||
FORMAT=$(echo "$MEDIA_DATA" | python3 -c "import json, sys; data=json.load(sys.stdin); print(data.get('format', 'Unknown'))" 2>/dev/null || echo "Unknown")
|
||||
EPISODES=$(echo "$MEDIA_DATA" | python3 -c "import json, sys; data=json.load(sys.stdin); print(data.get('episodes', 'Unknown'))" 2>/dev/null || echo "Unknown")
|
||||
DURATION=$(echo "$MEDIA_DATA" | python3 -c "import json, sys; data=json.load(sys.stdin); duration=data.get('duration'); print(f'{duration} min' if duration else 'Unknown')" 2>/dev/null || echo "Unknown")
|
||||
SCORE=$(echo "$MEDIA_DATA" | python3 -c "import json, sys; data=json.load(sys.stdin); score=data.get('averageScore'); print(f'{score}/100' if score else 'N/A')" 2>/dev/null || echo "N/A")
|
||||
FAVOURITES=$(echo "$MEDIA_DATA" | python3 -c "import json, sys; data=json.load(sys.stdin); print(f\"{data.get('favourites', 0):,}\")" 2>/dev/null || echo "0")
|
||||
POPULARITY=$(echo "$MEDIA_DATA" | python3 -c "import json, sys; data=json.load(sys.stdin); print(f\"{data.get('popularity', 0):,}\")" 2>/dev/null || echo "0")
|
||||
GENRES=$(echo "$MEDIA_DATA" | python3 -c "import json, sys; data=json.load(sys.stdin); print(', '.join(data.get('genres', [])[:5]))" 2>/dev/null || echo "Unknown")
|
||||
DESCRIPTION=$(echo "$MEDIA_DATA" | python3 -c "import json, sys; data=json.load(sys.stdin); print(data.get('description', 'No description available.'))" 2>/dev/null || echo "No description available.")
|
||||
|
||||
# Get start and end dates
|
||||
START_DATE_OBJ=$(echo "$MEDIA_DATA" | python3 -c "import json, sys; data=json.load(sys.stdin); json.dump(data.get('startDate'), sys.stdout)" 2>/dev/null || echo "null")
|
||||
END_DATE_OBJ=$(echo "$MEDIA_DATA" | python3 -c "import json, sys; data=json.load(sys.stdin); json.dump(data.get('endDate'), sys.stdout)" 2>/dev/null || echo "null")
|
||||
|
||||
# Get cover image URL
|
||||
COVER_IMAGE=$(echo "$MEDIA_DATA" | python3 -c "import json, sys; data=json.load(sys.stdin); cover=data.get('coverImage',{}); print(cover.get('large', ''))" 2>/dev/null || echo "")
|
||||
fi
|
||||
|
||||
# Format the dates
|
||||
START_DATE=$(format_date "$START_DATE_OBJ")
|
||||
END_DATE=$(format_date "$END_DATE_OBJ")
|
||||
|
||||
# Generate cache hash for this item (using selected item like regular preview)
|
||||
CACHE_HASH=$(generate_sha256 "$SELECTED_ITEM")
|
||||
|
||||
# Try to show image if available
|
||||
if [ "{PREVIEW_MODE}" = "full" ] || [ "{PREVIEW_MODE}" = "image" ]; then
|
||||
image_file="{IMAGE_CACHE_PATH}{PATH_SEP}${CACHE_HASH}.png"
|
||||
|
||||
# If image not cached and we have a URL, try to download it quickly
|
||||
if [ ! -f "$image_file" ] && [ -n "$COVER_IMAGE" ]; then
|
||||
if command -v curl >/dev/null 2>&1; then
|
||||
# Quick download with timeout
|
||||
curl -s -m 3 -L "$COVER_IMAGE" -o "$image_file" 2>/dev/null || rm -f "$image_file" 2>/dev/null
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -f "$image_file" ]; then
|
||||
fzf_preview "$image_file"
|
||||
else
|
||||
echo "🖼️ Loading image..."
|
||||
fi
|
||||
echo
|
||||
fi
|
||||
|
||||
# Display text info if configured
|
||||
if [ "{PREVIEW_MODE}" = "full" ] || [ "{PREVIEW_MODE}" = "text" ]; then
|
||||
draw_rule
|
||||
print_kv "Title" "$TITLE"
|
||||
draw_rule
|
||||
|
||||
print_kv "Score" "$SCORE"
|
||||
print_kv "Favourites" "$FAVOURITES"
|
||||
print_kv "Popularity" "$POPULARITY"
|
||||
print_kv "Status" "$STATUS"
|
||||
|
||||
draw_rule
|
||||
|
||||
print_kv "Episodes" "$EPISODES"
|
||||
print_kv "Duration" "$DURATION"
|
||||
print_kv "Format" "$FORMAT"
|
||||
|
||||
draw_rule
|
||||
|
||||
print_kv "Genres" "$GENRES"
|
||||
print_kv "Start Date" "$START_DATE"
|
||||
print_kv "End Date" "$END_DATE"
|
||||
|
||||
draw_rule
|
||||
|
||||
# Clean and display description
|
||||
CLEAN_DESCRIPTION=$(clean_html "$DESCRIPTION")
|
||||
echo "$CLEAN_DESCRIPTION" | fold -s -w "$WIDTH"
|
||||
fi
|
||||
31
viu/assets/scripts/fzf/episode-info.template.sh
Executable file
31
viu/assets/scripts/fzf/episode-info.template.sh
Executable file
@@ -0,0 +1,31 @@
|
||||
#!/bin/sh
|
||||
#
|
||||
# Episode Preview Info Script Template
|
||||
# This script formats and displays episode information in the FZF preview pane.
|
||||
# Some values are injected by python those with '{name}' syntax using .replace()
|
||||
|
||||
draw_rule
|
||||
|
||||
echo "{TITLE}" | fold -s -w "$WIDTH"
|
||||
|
||||
draw_rule
|
||||
|
||||
print_kv "Duration" "{DURATION}"
|
||||
print_kv "Status" "{STATUS}"
|
||||
|
||||
draw_rule
|
||||
|
||||
print_kv "Total Episodes" "{EPISODES}"
|
||||
print_kv "Next Episode" "{NEXT_EPISODE}"
|
||||
|
||||
draw_rule
|
||||
|
||||
print_kv "Progress" "{USER_PROGRESS}"
|
||||
print_kv "List Status" "{USER_STATUS}"
|
||||
|
||||
draw_rule
|
||||
|
||||
print_kv "Start Date" "{START_DATE}"
|
||||
print_kv "End Date" "{END_DATE}"
|
||||
|
||||
draw_rule
|
||||
54
viu/assets/scripts/fzf/info.template.sh
Normal file
54
viu/assets/scripts/fzf/info.template.sh
Normal file
@@ -0,0 +1,54 @@
|
||||
#!/bin/sh
|
||||
#
|
||||
# Viu Preview Info Script Template
|
||||
# This script formats and displays the textual information in the FZF preview pane.
|
||||
# Some values are injected by python those with '{name}' syntax using .replace()
|
||||
|
||||
draw_rule
|
||||
|
||||
print_kv "Title" "{TITLE}"
|
||||
|
||||
draw_rule
|
||||
|
||||
# Emojis take up double the space
|
||||
score_multiplier=1
|
||||
if ! [ "{SCORE}" = "N/A" ]; then
|
||||
score_multiplier=2
|
||||
fi
|
||||
print_kv "Score" "{SCORE}" $score_multiplier
|
||||
|
||||
print_kv "Favourites" "{FAVOURITES}"
|
||||
print_kv "Popularity" "{POPULARITY}"
|
||||
print_kv "Status" "{STATUS}"
|
||||
|
||||
draw_rule
|
||||
|
||||
print_kv "Episodes" "{EPISODES}"
|
||||
print_kv "Next Episode" "{NEXT_EPISODE}"
|
||||
print_kv "Duration" "{DURATION}"
|
||||
|
||||
draw_rule
|
||||
|
||||
print_kv "Genres" "{GENRES}"
|
||||
print_kv "Format" "{FORMAT}"
|
||||
|
||||
draw_rule
|
||||
|
||||
print_kv "List Status" "{USER_STATUS}"
|
||||
print_kv "Progress" "{USER_PROGRESS}"
|
||||
|
||||
draw_rule
|
||||
|
||||
print_kv "Start Date" "{START_DATE}"
|
||||
print_kv "End Date" "{END_DATE}"
|
||||
|
||||
draw_rule
|
||||
|
||||
print_kv "Studios" "{STUDIOS}"
|
||||
print_kv "Synonymns" "{SYNONYMNS}"
|
||||
print_kv "Tags" "{TAGS}"
|
||||
|
||||
draw_rule
|
||||
|
||||
# Synopsis
|
||||
echo "{SYNOPSIS}" | fold -s -w "$WIDTH"
|
||||
147
viu/assets/scripts/fzf/preview.template.sh
Executable file
147
viu/assets/scripts/fzf/preview.template.sh
Executable file
@@ -0,0 +1,147 @@
|
||||
#!/bin/sh
|
||||
#
|
||||
# FZF Preview Script Template
|
||||
#
|
||||
# This script is a template. The placeholders in curly braces, like {NAME}
|
||||
# are dynamically filled by python using .replace()
|
||||
|
||||
WIDTH=${FZF_PREVIEW_COLUMNS:-80} # Set a fallback width of 80
|
||||
IMAGE_RENDERER="{IMAGE_RENDERER}"
|
||||
|
||||
generate_sha256() {
|
||||
local input
|
||||
|
||||
# Check if input is passed as an argument or piped
|
||||
if [ -n "$1" ]; then
|
||||
input="$1"
|
||||
else
|
||||
input=$(cat)
|
||||
fi
|
||||
|
||||
if command -v sha256sum &>/dev/null; then
|
||||
echo -n "$input" | sha256sum | awk '{print $1}'
|
||||
elif command -v shasum &>/dev/null; then
|
||||
echo -n "$input" | shasum -a 256 | awk '{print $1}'
|
||||
elif command -v sha256 &>/dev/null; then
|
||||
echo -n "$input" | sha256 | awk '{print $1}'
|
||||
elif command -v openssl &>/dev/null; then
|
||||
echo -n "$input" | openssl dgst -sha256 | awk '{print $2}'
|
||||
else
|
||||
echo -n "$input" | base64 | tr '/+' '_-' | tr -d '\n'
|
||||
fi
|
||||
}
|
||||
|
||||
fzf_preview() {
|
||||
file=$1
|
||||
|
||||
dim=${FZF_PREVIEW_COLUMNS}x${FZF_PREVIEW_LINES}
|
||||
if [ "$dim" = x ]; then
|
||||
dim=$(stty size </dev/tty | awk "{print \$2 \"x\" \$1}")
|
||||
fi
|
||||
if ! [ "$IMAGE_RENDERER" = "icat" ] && [ -z "$KITTY_WINDOW_ID" ] && [ "$((FZF_PREVIEW_TOP + FZF_PREVIEW_LINES))" -eq "$(stty size </dev/tty | awk "{print \$1}")" ]; then
|
||||
dim=${FZF_PREVIEW_COLUMNS}x$((FZF_PREVIEW_LINES - 1))
|
||||
fi
|
||||
|
||||
if [ "$IMAGE_RENDERER" = "icat" ] && [ -z "$GHOSTTY_BIN_DIR" ]; then
|
||||
if command -v kitten >/dev/null 2>&1; then
|
||||
kitten icat --clear --transfer-mode=memory --unicode-placeholder{SCALE_UP} --stdin=no --place="$dim@0x0" "$file" | sed "\$d" | sed "$(printf "\$s/\$/\033[m/")"
|
||||
elif command -v icat >/dev/null 2>&1; then
|
||||
icat --clear --transfer-mode=memory --unicode-placeholder{SCALE_UP} --stdin=no --place="$dim@0x0" "$file" | sed "\$d" | sed "$(printf "\$s/\$/\033[m/")"
|
||||
else
|
||||
kitty icat --clear --transfer-mode=memory --unicode-placeholder{SCALE_UP} --stdin=no --place="$dim@0x0" "$file" | sed "\$d" | sed "$(printf "\$s/\$/\033[m/")"
|
||||
fi
|
||||
|
||||
elif [ -n "$GHOSTTY_BIN_DIR" ]; then
|
||||
dim=$((FZF_PREVIEW_COLUMNS - 1))x${FZF_PREVIEW_LINES}
|
||||
if command -v kitten >/dev/null 2>&1; then
|
||||
kitten icat --clear --transfer-mode=memory --unicode-placeholder{SCALE_UP} --stdin=no --place="$dim@0x0" "$file" | sed "\$d" | sed "$(printf "\$s/\$/\033[m/")"
|
||||
elif command -v icat >/dev/null 2>&1; then
|
||||
icat --clear --transfer-mode=memory --unicode-placeholder{SCALE_UP} --stdin=no --place="$dim@0x0" "$file" | sed "\$d" | sed "$(printf "\$s/\$/\033[m/")"
|
||||
else
|
||||
chafa -s "$dim" "$file"
|
||||
fi
|
||||
elif command -v chafa >/dev/null 2>&1; then
|
||||
case "$PLATFORM" in
|
||||
android) chafa -s "$dim" "$file" ;;
|
||||
windows) chafa -f sixel -s "$dim" "$file" ;;
|
||||
*) chafa -s "$dim" "$file" ;;
|
||||
esac
|
||||
echo
|
||||
|
||||
elif command -v imgcat >/dev/null; then
|
||||
imgcat -W "${dim%%x*}" -H "${dim##*x}" "$file"
|
||||
|
||||
else
|
||||
echo please install a terminal image viewer
|
||||
echo either icat for kitty terminal and wezterm or imgcat or chafa
|
||||
fi
|
||||
}
|
||||
|
||||
|
||||
# --- Helper function for printing a key-value pair, aligning the value to the right ---
|
||||
print_kv() {
|
||||
local key="$1"
|
||||
local value="$2"
|
||||
local key_len=${#key}
|
||||
local value_len=${#value}
|
||||
local multiplier="${3:-1}"
|
||||
|
||||
# Correctly calculate padding by accounting for the key, the ": ", and the value.
|
||||
local padding_len=$((WIDTH - key_len - 2 - value_len * multiplier))
|
||||
|
||||
# If the text is too long to fit, just add a single space for separation.
|
||||
if [ "$padding_len" -lt 1 ]; then
|
||||
padding_len=1
|
||||
value=$(echo "$value"| fold -s -w "$((WIDTH - key_len - 3))")
|
||||
printf "{C_KEY}%s:{RESET}%*s%s\\n" "$key" "$padding_len" "" " $value"
|
||||
else
|
||||
printf "{C_KEY}%s:{RESET}%*s%s\\n" "$key" "$padding_len" "" " $value"
|
||||
fi
|
||||
}
|
||||
|
||||
# --- Draw a rule across the screen ---
|
||||
# TODO: figure out why this method does not work in fzf
|
||||
draw_rule() {
|
||||
local rule
|
||||
# Generate the line of '─' characters, removing the trailing newline `tr` adds.
|
||||
rule=$(printf '%*s' "$WIDTH" | tr ' ' '─' | tr -d '\n')
|
||||
# Print the rule with colors and a single, clean newline.
|
||||
printf "{C_RULE}%s{RESET}\\n" "$rule"
|
||||
}
|
||||
|
||||
|
||||
draw_rule(){
|
||||
ll=2
|
||||
while [ $ll -le $FZF_PREVIEW_COLUMNS ];do
|
||||
echo -n -e "{C_RULE}─{RESET}"
|
||||
((ll++))
|
||||
done
|
||||
echo
|
||||
}
|
||||
|
||||
# Generate the same cache key that the Python worker uses
|
||||
# {PREFIX} is used only on episode previews to make sure they are unique
|
||||
title={}
|
||||
hash=$(generate_sha256 "{PREFIX}$title")
|
||||
|
||||
#
|
||||
# --- Display image if configured and the cached file exists ---
|
||||
#
|
||||
if [ "{PREVIEW_MODE}" = "full" ] || [ "{PREVIEW_MODE}" = "image" ]; then
|
||||
image_file="{IMAGE_CACHE_PATH}{PATH_SEP}$hash.png"
|
||||
if [ -f "$image_file" ]; then
|
||||
fzf_preview "$image_file"
|
||||
else
|
||||
echo "🖼️ Loading image..."
|
||||
fi
|
||||
echo # Add a newline for spacing
|
||||
fi
|
||||
# Display text info if configured and the cached file exists
|
||||
if [ "{PREVIEW_MODE}" = "full" ] || [ "{PREVIEW_MODE}" = "text" ]; then
|
||||
info_file="{INFO_CACHE_PATH}{PATH_SEP}$hash"
|
||||
if [ -f "$info_file" ]; then
|
||||
source "$info_file"
|
||||
else
|
||||
echo "📝 Loading details..."
|
||||
fi
|
||||
fi
|
||||
19
viu/assets/scripts/fzf/review-info.template.sh
Normal file
19
viu/assets/scripts/fzf/review-info.template.sh
Normal file
@@ -0,0 +1,19 @@
|
||||
#!/bin/sh
|
||||
#
|
||||
# Viu Review Info Script Template
|
||||
# This script formats and displays review details in the FZF preview pane.
|
||||
# Python injects the actual data values into the placeholders.
|
||||
|
||||
draw_rule
|
||||
|
||||
print_kv "Review By" "{REVIEWER_NAME}"
|
||||
|
||||
draw_rule
|
||||
|
||||
print_kv "Summary" "{REVIEW_SUMMARY}"
|
||||
|
||||
draw_rule
|
||||
|
||||
echo "{REVIEW_BODY}" | fold -s -w "$WIDTH"
|
||||
|
||||
draw_rule
|
||||
75
viu/assets/scripts/fzf/review-preview.template.sh
Normal file
75
viu/assets/scripts/fzf/review-preview.template.sh
Normal file
@@ -0,0 +1,75 @@
|
||||
#!/bin/sh
|
||||
#
|
||||
# FZF Preview Script Template
|
||||
#
|
||||
# This script is a template. The placeholders in curly braces, like {NAME}
|
||||
# are dynamically filled by python using .replace()
|
||||
|
||||
WIDTH=${FZF_PREVIEW_COLUMNS:-80} # Set a fallback width of 80
|
||||
IMAGE_RENDERER="{IMAGE_RENDERER}"
|
||||
|
||||
generate_sha256() {
|
||||
local input
|
||||
|
||||
# Check if input is passed as an argument or piped
|
||||
if [ -n "$1" ]; then
|
||||
input="$1"
|
||||
else
|
||||
input=$(cat)
|
||||
fi
|
||||
|
||||
if command -v sha256sum &>/dev/null; then
|
||||
echo -n "$input" | sha256sum | awk '{print $1}'
|
||||
elif command -v shasum &>/dev/null; then
|
||||
echo -n "$input" | shasum -a 256 | awk '{print $1}'
|
||||
elif command -v sha256 &>/dev/null; then
|
||||
echo -n "$input" | sha256 | awk '{print $1}'
|
||||
elif command -v openssl &>/dev/null; then
|
||||
echo -n "$input" | openssl dgst -sha256 | awk '{print $2}'
|
||||
else
|
||||
echo -n "$input" | base64 | tr '/+' '_-' | tr -d '\n'
|
||||
fi
|
||||
}
|
||||
|
||||
|
||||
print_kv() {
|
||||
local key="$1"
|
||||
local value="$2"
|
||||
local key_len=${#key}
|
||||
local value_len=${#value}
|
||||
local multiplier="${3:-1}"
|
||||
|
||||
# Correctly calculate padding by accounting for the key, the ": ", and the value.
|
||||
local padding_len=$((WIDTH - key_len - 2 - value_len * multiplier))
|
||||
|
||||
# If the text is too long to fit, just add a single space for separation.
|
||||
if [ "$padding_len" -lt 1 ]; then
|
||||
padding_len=1
|
||||
value=$(echo $value| fold -s -w "$((WIDTH - key_len - 3))")
|
||||
printf "{C_KEY}%s:{RESET}%*s%s\\n" "$key" "$padding_len" "" " $value"
|
||||
else
|
||||
printf "{C_KEY}%s:{RESET}%*s%s\\n" "$key" "$padding_len" "" " $value"
|
||||
fi
|
||||
}
|
||||
|
||||
|
||||
draw_rule(){
|
||||
ll=2
|
||||
while [ $ll -le $FZF_PREVIEW_COLUMNS ];do
|
||||
echo -n -e "{C_RULE}─{RESET}"
|
||||
((ll++))
|
||||
done
|
||||
echo
|
||||
}
|
||||
|
||||
title={}
|
||||
hash=$(generate_sha256 "$title")
|
||||
|
||||
if [ "{PREVIEW_MODE}" = "full" ] || [ "{PREVIEW_MODE}" = "text" ]; then
|
||||
info_file="{INFO_CACHE_DIR}{PATH_SEP}$hash"
|
||||
if [ -f "$info_file" ]; then
|
||||
source "$info_file"
|
||||
else
|
||||
echo "📝 Loading details..."
|
||||
fi
|
||||
fi
|
||||
118
viu/assets/scripts/fzf/search.template.sh
Executable file
118
viu/assets/scripts/fzf/search.template.sh
Executable file
@@ -0,0 +1,118 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# FZF Dynamic Search Script Template
|
||||
#
|
||||
# This script is a template for dynamic search functionality in fzf.
|
||||
# The placeholders in curly braces, like {QUERY} are dynamically filled by Python using .replace()
|
||||
|
||||
# Configuration variables (injected by Python)
|
||||
GRAPHQL_ENDPOINT="{GRAPHQL_ENDPOINT}"
|
||||
CACHE_DIR="{CACHE_DIR}"
|
||||
SEARCH_RESULTS_FILE="{SEARCH_RESULTS_FILE}"
|
||||
AUTH_HEADER="{AUTH_HEADER}"
|
||||
|
||||
# Get the current query from fzf
|
||||
QUERY="{{q}}"
|
||||
|
||||
# If query is empty, exit with empty results
|
||||
if [ -z "$QUERY" ]; then
|
||||
echo ""
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Create GraphQL variables
|
||||
VARIABLES=$(cat <<EOF
|
||||
{
|
||||
"query": "$QUERY",
|
||||
"type": "ANIME",
|
||||
"per_page": 50,
|
||||
"genre_not_in": ["Hentai"]
|
||||
}
|
||||
EOF
|
||||
)
|
||||
|
||||
# The GraphQL query is injected here as a properly escaped string
|
||||
GRAPHQL_QUERY='{GRAPHQL_QUERY}'
|
||||
|
||||
# Create the GraphQL request payload
|
||||
PAYLOAD=$(cat <<EOF
|
||||
{
|
||||
"query": $GRAPHQL_QUERY,
|
||||
"variables": $VARIABLES
|
||||
}
|
||||
EOF
|
||||
)
|
||||
|
||||
# Make the GraphQL request and save raw results
|
||||
if [ -n "$AUTH_HEADER" ]; then
|
||||
RESPONSE=$(curl -s -X POST \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: $AUTH_HEADER" \
|
||||
-d "$PAYLOAD" \
|
||||
"$GRAPHQL_ENDPOINT")
|
||||
else
|
||||
RESPONSE=$(curl -s -X POST \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$PAYLOAD" \
|
||||
"$GRAPHQL_ENDPOINT")
|
||||
fi
|
||||
|
||||
# Check if the request was successful
|
||||
if [ $? -ne 0 ] || [ -z "$RESPONSE" ]; then
|
||||
echo "❌ Search failed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Save the raw response for later processing
|
||||
echo "$RESPONSE" > "$SEARCH_RESULTS_FILE"
|
||||
|
||||
# Parse and display results
|
||||
if command -v jq >/dev/null 2>&1; then
|
||||
# Use jq for faster and more reliable JSON parsing
|
||||
echo "$RESPONSE" | jq -r '
|
||||
if .errors then
|
||||
"❌ Search error: " + (.errors | tostring)
|
||||
elif (.data.Page.media // []) | length == 0 then
|
||||
"❌ No results found"
|
||||
else
|
||||
.data.Page.media[] | (.title.english // .title.romaji // .title.native // "Unknown")
|
||||
end
|
||||
' 2>/dev/null || echo "❌ Parse error"
|
||||
else
|
||||
# Fallback to Python for JSON parsing
|
||||
echo "$RESPONSE" | python3 -c "
|
||||
import json
|
||||
import sys
|
||||
|
||||
try:
|
||||
data = json.load(sys.stdin)
|
||||
|
||||
if 'errors' in data:
|
||||
print('❌ Search error: ' + str(data['errors']))
|
||||
sys.exit(1)
|
||||
|
||||
if 'data' not in data or 'Page' not in data['data'] or 'media' not in data['data']['Page']:
|
||||
print('❌ No results found')
|
||||
sys.exit(0)
|
||||
|
||||
media_list = data['data']['Page']['media']
|
||||
|
||||
if not media_list:
|
||||
print('❌ No results found')
|
||||
sys.exit(0)
|
||||
|
||||
for media in media_list:
|
||||
title = media.get('title', {})
|
||||
english_title = title.get('english') or title.get('romaji') or title.get('native', 'Unknown')
|
||||
year = media.get('startDate', {}).get('year', 'Unknown') if media.get('startDate') else 'Unknown'
|
||||
status = media.get('status', 'Unknown')
|
||||
genres = ', '.join(media.get('genres', [])[:3]) or 'Unknown'
|
||||
|
||||
# Format: Title (Year) [Status] - Genres
|
||||
print(f'{english_title} ({year}) [{status}] - {genres}')
|
||||
|
||||
except Exception as e:
|
||||
print(f'❌ Parse error: {str(e)}')
|
||||
sys.exit(1)
|
||||
"
|
||||
fi
|
||||
3
viu/cli/__init__.py
Normal file
3
viu/cli/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .cli import cli as run_cli
|
||||
|
||||
__all__ = ["run_cli"]
|
||||
108
viu/cli/cli.py
Normal file
108
viu/cli/cli.py
Normal file
@@ -0,0 +1,108 @@
|
||||
import logging
|
||||
import sys
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import click
|
||||
from click.core import ParameterSource
|
||||
|
||||
from ..core.config import AppConfig
|
||||
from ..core.constants import PROJECT_NAME, USER_CONFIG, __version__
|
||||
from .config import ConfigLoader
|
||||
from .options import options_from_model
|
||||
from .utils.exception import setup_exceptions_handler
|
||||
from .utils.lazyloader import LazyGroup
|
||||
from .utils.logging import setup_logging
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import TypedDict
|
||||
|
||||
from typing_extensions import Unpack
|
||||
|
||||
class Options(TypedDict):
|
||||
no_config: bool | None
|
||||
trace: bool | None
|
||||
dev: bool | None
|
||||
log: bool | None
|
||||
rich_traceback: bool | None
|
||||
rich_traceback_theme: str
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
commands = {
|
||||
"config": "config.config",
|
||||
"search": "search.search",
|
||||
"anilist": "anilist.anilist",
|
||||
"download": "download.download",
|
||||
"update": "update.update",
|
||||
"registry": "registry.registry",
|
||||
"worker": "worker.worker",
|
||||
"queue": "queue.queue",
|
||||
}
|
||||
|
||||
|
||||
@click.group(
|
||||
cls=LazyGroup,
|
||||
root="viu.cli.commands",
|
||||
lazy_subcommands=commands,
|
||||
context_settings=dict(auto_envvar_prefix=PROJECT_NAME),
|
||||
)
|
||||
@click.version_option(__version__, "--version")
|
||||
@click.option("--no-config", is_flag=True, help="Don't load the user config file.")
|
||||
@click.option(
|
||||
"--trace", is_flag=True, help="Controls Whether to display tracebacks or not"
|
||||
)
|
||||
@click.option("--dev", is_flag=True, help="Controls Whether the app is in dev mode")
|
||||
@click.option("--log", is_flag=True, help="Controls Whether to log")
|
||||
@click.option(
|
||||
"--rich-traceback",
|
||||
is_flag=True,
|
||||
help="Controls Whether to display a rich traceback",
|
||||
)
|
||||
@click.option(
|
||||
"--rich-traceback-theme",
|
||||
default="github-dark",
|
||||
help="Controls Whether to display a rich traceback",
|
||||
)
|
||||
@options_from_model(AppConfig)
|
||||
@click.pass_context
|
||||
def cli(ctx: click.Context, **options: "Unpack[Options]"):
|
||||
"""
|
||||
The main entry point for the Viu CLI.
|
||||
"""
|
||||
setup_logging(options["log"])
|
||||
setup_exceptions_handler(
|
||||
options["trace"],
|
||||
options["dev"],
|
||||
options["rich_traceback"],
|
||||
options["rich_traceback_theme"],
|
||||
)
|
||||
|
||||
logger.info(f"Current Command: {' '.join(sys.argv)}")
|
||||
cli_overrides = {}
|
||||
param_lookup = {p.name: p for p in ctx.command.params}
|
||||
|
||||
for param_name, param_value in ctx.params.items():
|
||||
source = ctx.get_parameter_source(param_name)
|
||||
if source in (ParameterSource.ENVIRONMENT, ParameterSource.COMMANDLINE):
|
||||
parameter = param_lookup.get(param_name)
|
||||
|
||||
if (
|
||||
parameter
|
||||
and hasattr(parameter, "model_name")
|
||||
and hasattr(parameter, "field_name")
|
||||
):
|
||||
model_name = getattr(parameter, "model_name")
|
||||
field_name = getattr(parameter, "field_name")
|
||||
|
||||
if model_name not in cli_overrides:
|
||||
cli_overrides[model_name] = {}
|
||||
cli_overrides[model_name][field_name] = param_value
|
||||
|
||||
loader = ConfigLoader(config_path=USER_CONFIG)
|
||||
config = (
|
||||
AppConfig.model_validate(cli_overrides)
|
||||
if options["no_config"]
|
||||
else loader.load(cli_overrides)
|
||||
)
|
||||
ctx.obj = config
|
||||
0
viu/cli/commands/__init__.py
Normal file
0
viu/cli/commands/__init__.py
Normal file
3
viu/cli/commands/anilist/__init__.py
Normal file
3
viu/cli/commands/anilist/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .cmd import anilist
|
||||
|
||||
__all__ = ["anilist"]
|
||||
43
viu/cli/commands/anilist/cmd.py
Normal file
43
viu/cli/commands/anilist/cmd.py
Normal file
@@ -0,0 +1,43 @@
|
||||
import click
|
||||
|
||||
from ...utils.lazyloader import LazyGroup
|
||||
from . import examples
|
||||
|
||||
commands = {
|
||||
# "trending": "trending.trending",
|
||||
# "recent": "recent.recent",
|
||||
"search": "search.search",
|
||||
"download": "download.download",
|
||||
"downloads": "downloads.downloads",
|
||||
"auth": "auth.auth",
|
||||
"stats": "stats.stats",
|
||||
"notifications": "notifications.notifications",
|
||||
}
|
||||
|
||||
|
||||
@click.group(
|
||||
cls=LazyGroup,
|
||||
name="anilist",
|
||||
root="viu.cli.commands.anilist.commands",
|
||||
invoke_without_command=True,
|
||||
help="A beautiful interface that gives you access to a commplete streaming experience",
|
||||
short_help="Access all streaming options",
|
||||
lazy_subcommands=commands,
|
||||
epilog=examples.main,
|
||||
)
|
||||
@click.option(
|
||||
"--resume", is_flag=True, help="Resume from the last session (Not yet implemented)."
|
||||
)
|
||||
@click.pass_context
|
||||
def anilist(ctx: click.Context, resume: bool):
|
||||
"""
|
||||
The entry point for the 'anilist' command. If no subcommand is invoked,
|
||||
it launches the interactive TUI mode.
|
||||
"""
|
||||
from ...interactive.session import session
|
||||
|
||||
config = ctx.obj
|
||||
|
||||
if ctx.invoked_subcommand is None:
|
||||
session.load_menus_from_folder("media")
|
||||
session.run(config, resume=resume)
|
||||
0
viu/cli/commands/anilist/commands/__init__.py
Normal file
0
viu/cli/commands/anilist/commands/__init__.py
Normal file
70
viu/cli/commands/anilist/commands/auth.py
Normal file
70
viu/cli/commands/anilist/commands/auth.py
Normal file
@@ -0,0 +1,70 @@
|
||||
import click
|
||||
import webbrowser
|
||||
|
||||
from .....core.config.model import AppConfig
|
||||
|
||||
|
||||
@click.command(help="Login to your AniList account to enable progress tracking.")
|
||||
@click.option("--status", "-s", is_flag=True, help="Check current login status.")
|
||||
@click.option("--logout", "-l", is_flag=True, help="Log out and erase credentials.")
|
||||
@click.pass_obj
|
||||
def auth(config: AppConfig, status: bool, logout: bool):
|
||||
"""Handles user authentication and credential management."""
|
||||
from .....core.constants import ANILIST_AUTH
|
||||
from .....libs.media_api.api import create_api_client
|
||||
from .....libs.selectors.selector import create_selector
|
||||
from ....service.auth import AuthService
|
||||
from ....service.feedback import FeedbackService
|
||||
|
||||
auth_service = AuthService("anilist")
|
||||
feedback = FeedbackService(config)
|
||||
selector = create_selector(config)
|
||||
feedback.clear_console()
|
||||
|
||||
if status:
|
||||
user_data = auth_service.get_auth()
|
||||
if user_data:
|
||||
feedback.info(f"Logged in as: {user_data.user_profile}")
|
||||
else:
|
||||
feedback.error("Not logged in.")
|
||||
return
|
||||
|
||||
if logout:
|
||||
if selector.confirm("Are you sure you want to log out and erase your token?"):
|
||||
auth_service.clear_user_profile()
|
||||
feedback.info("You have been logged out.")
|
||||
return
|
||||
|
||||
if auth_profile := auth_service.get_auth():
|
||||
if not selector.confirm(
|
||||
f"You are already logged in as {auth_profile.user_profile.name}.Would you like to relogin"
|
||||
):
|
||||
return
|
||||
api_client = create_api_client("anilist", config)
|
||||
|
||||
open_success = webbrowser.open(ANILIST_AUTH, new=2)
|
||||
if open_success:
|
||||
feedback.info("Your browser has been opened to obtain an AniList token.")
|
||||
feedback.info(f"or you can visit the site manually [magenta][link={ANILIST_AUTH}]here[/link][/magenta].")
|
||||
else:
|
||||
feedback.warning(
|
||||
f"Failed to open the browser. Please visit the site manually [magenta][link={ANILIST_AUTH}]here[/link][/magenta]."
|
||||
)
|
||||
feedback.info(
|
||||
"After authorizing, copy the token from the address bar and paste it below."
|
||||
)
|
||||
|
||||
token = selector.ask("Enter your AniList Access Token")
|
||||
if not token:
|
||||
feedback.error("Login cancelled.")
|
||||
return
|
||||
|
||||
# Use the API client to validate the token and get profile info
|
||||
profile = api_client.authenticate(token.strip())
|
||||
|
||||
if profile:
|
||||
# If successful, use the manager to save the credentials
|
||||
auth_service.save_user_profile(profile, token)
|
||||
feedback.info(f"Successfully logged in as {profile.name}! ✨")
|
||||
else:
|
||||
feedback.error("Login failed. The token may be invalid or expired.")
|
||||
265
viu/cli/commands/anilist/commands/download.py
Normal file
265
viu/cli/commands/anilist/commands/download.py
Normal file
@@ -0,0 +1,265 @@
|
||||
from typing import TYPE_CHECKING, Dict, List
|
||||
|
||||
import click
|
||||
from viu.cli.utils.completion import anime_titles_shell_complete
|
||||
from viu.core.config import AppConfig
|
||||
from viu.core.exceptions import ViuError
|
||||
from viu.libs.media_api.types import (
|
||||
MediaFormat,
|
||||
MediaGenre,
|
||||
MediaItem,
|
||||
MediaSeason,
|
||||
MediaSort,
|
||||
MediaStatus,
|
||||
MediaTag,
|
||||
MediaType,
|
||||
MediaYear,
|
||||
)
|
||||
|
||||
from .. import examples
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import TypedDict
|
||||
|
||||
from typing_extensions import Unpack
|
||||
|
||||
class DownloadOptions(TypedDict, total=False):
|
||||
title: str | None
|
||||
episode_range: str | None
|
||||
page: int
|
||||
per_page: int | None
|
||||
season: str | None
|
||||
status: tuple[str, ...]
|
||||
status_not: tuple[str, ...]
|
||||
sort: str | None
|
||||
genres: tuple[str, ...]
|
||||
genres_not: tuple[str, ...]
|
||||
tags: tuple[str, ...]
|
||||
tags_not: tuple[str, ...]
|
||||
media_format: tuple[str, ...]
|
||||
media_type: str | None
|
||||
year: str | None
|
||||
popularity_greater: int | None
|
||||
popularity_lesser: int | None
|
||||
score_greater: int | None
|
||||
score_lesser: int | None
|
||||
start_date_greater: int | None
|
||||
start_date_lesser: int | None
|
||||
end_date_greater: int | None
|
||||
end_date_lesser: int | None
|
||||
on_list: bool | None
|
||||
yes: bool
|
||||
|
||||
|
||||
@click.command(
|
||||
help="Search for anime on AniList and download episodes.",
|
||||
short_help="Search and download anime.",
|
||||
epilog=examples.download,
|
||||
)
|
||||
# --- Re-using all search options ---
|
||||
@click.option("--title", "-t", shell_complete=anime_titles_shell_complete)
|
||||
@click.option("--page", "-p", type=click.IntRange(min=1), default=1)
|
||||
@click.option("--per-page", type=click.IntRange(min=1, max=50))
|
||||
@click.option("--season", type=click.Choice([s.value for s in MediaSeason]))
|
||||
@click.option(
|
||||
"--status", "-S", multiple=True, type=click.Choice([s.value for s in MediaStatus])
|
||||
)
|
||||
@click.option(
|
||||
"--status-not", multiple=True, type=click.Choice([s.value for s in MediaStatus])
|
||||
)
|
||||
@click.option("--sort", "-s", type=click.Choice([s.value for s in MediaSort]))
|
||||
@click.option(
|
||||
"--genres", "-g", multiple=True, type=click.Choice([g.value for g in MediaGenre])
|
||||
)
|
||||
@click.option(
|
||||
"--genres-not", multiple=True, type=click.Choice([g.value for g in MediaGenre])
|
||||
)
|
||||
@click.option(
|
||||
"--tags", "-T", multiple=True, type=click.Choice([t.value for t in MediaTag])
|
||||
)
|
||||
@click.option(
|
||||
"--tags-not", multiple=True, type=click.Choice([t.value for t in MediaTag])
|
||||
)
|
||||
@click.option(
|
||||
"--media-format",
|
||||
"-f",
|
||||
multiple=True,
|
||||
type=click.Choice([f.value for f in MediaFormat]),
|
||||
)
|
||||
@click.option("--media-type", type=click.Choice([t.value for t in MediaType]))
|
||||
@click.option("--year", "-y", type=click.Choice([y.value for y in MediaYear]))
|
||||
@click.option("--popularity-greater", type=click.IntRange(min=0))
|
||||
@click.option("--popularity-lesser", type=click.IntRange(min=0))
|
||||
@click.option("--score-greater", type=click.IntRange(min=0, max=100))
|
||||
@click.option("--score-lesser", type=click.IntRange(min=0, max=100))
|
||||
@click.option("--start-date-greater", type=int)
|
||||
@click.option("--start-date-lesser", type=int)
|
||||
@click.option("--end-date-greater", type=int)
|
||||
@click.option("--end-date-lesser", type=int)
|
||||
@click.option("--on-list/--not-on-list", "-L/-no-L", type=bool, default=None)
|
||||
# --- Download specific options ---
|
||||
@click.option(
|
||||
"--episode-range",
|
||||
"-r",
|
||||
help="Range of episodes to download (e.g., '1-10', '5', '8:12'). Required.",
|
||||
required=True,
|
||||
)
|
||||
@click.option(
|
||||
"--yes",
|
||||
"-Y",
|
||||
is_flag=True,
|
||||
help="Automatically download from all found anime without prompting for selection.",
|
||||
)
|
||||
@click.pass_obj
|
||||
def download(config: AppConfig, **options: "Unpack[DownloadOptions]"):
|
||||
from viu.cli.service.download.service import DownloadService
|
||||
from viu.cli.service.feedback import FeedbackService
|
||||
from viu.cli.service.registry import MediaRegistryService
|
||||
from viu.cli.service.watch_history import WatchHistoryService
|
||||
from viu.cli.utils.parser import parse_episode_range
|
||||
from viu.libs.media_api.api import create_api_client
|
||||
from viu.libs.media_api.params import MediaSearchParams
|
||||
from viu.libs.provider.anime.provider import create_provider
|
||||
from viu.libs.selectors import create_selector
|
||||
from rich.progress import Progress
|
||||
|
||||
feedback = FeedbackService(config)
|
||||
selector = create_selector(config)
|
||||
media_api = create_api_client(config.general.media_api, config)
|
||||
provider = create_provider(config.general.provider)
|
||||
registry = MediaRegistryService(config.general.media_api, config.media_registry)
|
||||
watch_history = WatchHistoryService(config, registry, media_api)
|
||||
download_service = DownloadService(config, registry, media_api, provider)
|
||||
|
||||
try:
|
||||
sort_val = options.get("sort")
|
||||
status_val = options.get("status")
|
||||
status_not_val = options.get("status_not")
|
||||
genres_val = options.get("genres")
|
||||
genres_not_val = options.get("genres_not")
|
||||
tags_val = options.get("tags")
|
||||
tags_not_val = options.get("tags_not")
|
||||
media_format_val = options.get("media_format")
|
||||
media_type_val = options.get("media_type")
|
||||
season_val = options.get("season")
|
||||
year_val = options.get("year")
|
||||
|
||||
search_params = MediaSearchParams(
|
||||
query=options.get("title"),
|
||||
page=options.get("page", 1),
|
||||
per_page=options.get("per_page"),
|
||||
sort=MediaSort(sort_val) if sort_val else None,
|
||||
status_in=[MediaStatus(s) for s in status_val] if status_val else None,
|
||||
status_not_in=[MediaStatus(s) for s in status_not_val]
|
||||
if status_not_val
|
||||
else None,
|
||||
genre_in=[MediaGenre(g) for g in genres_val] if genres_val else None,
|
||||
genre_not_in=[MediaGenre(g) for g in genres_not_val]
|
||||
if genres_not_val
|
||||
else None,
|
||||
tag_in=[MediaTag(t) for t in tags_val] if tags_val else None,
|
||||
tag_not_in=[MediaTag(t) for t in tags_not_val] if tags_not_val else None,
|
||||
format_in=[MediaFormat(f) for f in media_format_val]
|
||||
if media_format_val
|
||||
else None,
|
||||
type=MediaType(media_type_val) if media_type_val else None,
|
||||
season=MediaSeason(season_val) if season_val else None,
|
||||
seasonYear=int(year_val) if year_val else None,
|
||||
popularity_greater=options.get("popularity_greater"),
|
||||
popularity_lesser=options.get("popularity_lesser"),
|
||||
averageScore_greater=options.get("score_greater"),
|
||||
averageScore_lesser=options.get("score_lesser"),
|
||||
startDate_greater=options.get("start_date_greater"),
|
||||
startDate_lesser=options.get("start_date_lesser"),
|
||||
endDate_greater=options.get("end_date_greater"),
|
||||
endDate_lesser=options.get("end_date_lesser"),
|
||||
on_list=options.get("on_list"),
|
||||
)
|
||||
|
||||
with Progress() as progress:
|
||||
progress.add_task("Searching AniList...", total=None)
|
||||
search_result = media_api.search_media(search_params)
|
||||
|
||||
if not search_result or not search_result.media:
|
||||
raise ViuError("No anime found matching your search criteria.")
|
||||
|
||||
anime_to_download: List[MediaItem]
|
||||
if options.get("yes"):
|
||||
anime_to_download = search_result.media
|
||||
else:
|
||||
choice_map: Dict[str, MediaItem] = {
|
||||
(item.title.english or item.title.romaji or f"ID: {item.id}"): item
|
||||
for item in search_result.media
|
||||
}
|
||||
preview_command = None
|
||||
if config.general.preview != "none":
|
||||
from ....utils.preview import create_preview_context
|
||||
|
||||
with create_preview_context() as preview_ctx:
|
||||
preview_command = preview_ctx.get_anime_preview(
|
||||
list(choice_map.values()),
|
||||
list(choice_map.keys()),
|
||||
config,
|
||||
)
|
||||
selected_titles = selector.choose_multiple(
|
||||
"Select anime to download",
|
||||
list(choice_map.keys()),
|
||||
preview=preview_command,
|
||||
)
|
||||
else:
|
||||
selected_titles = selector.choose_multiple(
|
||||
"Select anime to download",
|
||||
list(choice_map.keys()),
|
||||
)
|
||||
if not selected_titles:
|
||||
feedback.warning("No anime selected. Aborting download.")
|
||||
return
|
||||
anime_to_download = [choice_map[title] for title in selected_titles]
|
||||
|
||||
total_downloaded = 0
|
||||
episode_range_str = options.get("episode_range")
|
||||
if not episode_range_str:
|
||||
raise ViuError("--episode-range is required.")
|
||||
|
||||
for media_item in anime_to_download:
|
||||
watch_history.add_media_to_list_if_not_present(media_item)
|
||||
|
||||
available_episodes = [str(i + 1) for i in range(media_item.episodes or 0)]
|
||||
if not available_episodes:
|
||||
feedback.warning(
|
||||
f"No episode information for '{media_item.title.english}', skipping."
|
||||
)
|
||||
continue
|
||||
|
||||
try:
|
||||
episodes_to_download = list(
|
||||
parse_episode_range(episode_range_str, available_episodes)
|
||||
)
|
||||
if not episodes_to_download:
|
||||
feedback.warning(
|
||||
f"Episode range '{episode_range_str}' resulted in no episodes for '{media_item.title.english}'."
|
||||
)
|
||||
continue
|
||||
|
||||
feedback.info(
|
||||
f"Preparing to download {len(episodes_to_download)} episodes for '{media_item.title.english}'."
|
||||
)
|
||||
download_service.download_episodes_sync(
|
||||
media_item, episodes_to_download
|
||||
)
|
||||
total_downloaded += len(episodes_to_download)
|
||||
|
||||
except (ValueError, IndexError) as e:
|
||||
feedback.error(
|
||||
f"Invalid episode range for '{media_item.title.english}': {e}"
|
||||
)
|
||||
continue
|
||||
|
||||
feedback.success(
|
||||
f"Finished. Successfully downloaded a total of {total_downloaded} episodes."
|
||||
)
|
||||
|
||||
except ViuError as e:
|
||||
feedback.error("Download command failed", str(e))
|
||||
except Exception as e:
|
||||
feedback.error("An unexpected error occurred", str(e))
|
||||
211
viu/cli/commands/anilist/commands/downloads.py
Normal file
211
viu/cli/commands/anilist/commands/downloads.py
Normal file
@@ -0,0 +1,211 @@
|
||||
import json
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import click
|
||||
|
||||
from .....core.config import AppConfig
|
||||
from .....libs.media_api.params import MediaSearchParams
|
||||
from .....libs.media_api.types import (
|
||||
MediaFormat,
|
||||
MediaGenre,
|
||||
MediaSort,
|
||||
UserMediaListStatus,
|
||||
)
|
||||
from ....service.feedback import FeedbackService
|
||||
from ....service.registry.service import MediaRegistryService
|
||||
|
||||
|
||||
@click.command(help="Search through the local media registry")
|
||||
@click.argument("query", required=False)
|
||||
@click.option(
|
||||
"--status",
|
||||
type=click.Choice(
|
||||
[s.value for s in UserMediaListStatus],
|
||||
case_sensitive=False,
|
||||
),
|
||||
help="Filter by watch status",
|
||||
)
|
||||
@click.option(
|
||||
"--genre", multiple=True, help="Filter by genre (can be used multiple times)"
|
||||
)
|
||||
@click.option(
|
||||
"--format",
|
||||
type=click.Choice(
|
||||
[
|
||||
f.value
|
||||
for f in MediaFormat
|
||||
if f not in [MediaFormat.MANGA, MediaFormat.NOVEL, MediaFormat.ONE_SHOT]
|
||||
],
|
||||
case_sensitive=False,
|
||||
),
|
||||
help="Filter by format",
|
||||
)
|
||||
@click.option("--year", type=int, help="Filter by release year")
|
||||
@click.option("--min-score", type=float, help="Minimum average score (0.0 - 10.0)")
|
||||
@click.option("--max-score", type=float, help="Maximum average score (0.0 - 10.0)")
|
||||
@click.option(
|
||||
"--sort",
|
||||
type=click.Choice(
|
||||
["title", "score", "popularity", "year", "episodes", "updated"],
|
||||
case_sensitive=False,
|
||||
),
|
||||
default="title",
|
||||
help="Sort results by field",
|
||||
)
|
||||
@click.option("--limit", type=int, default=20, help="Maximum number of results to show")
|
||||
@click.option(
|
||||
"--json", "output_json", is_flag=True, help="Output results in JSON format"
|
||||
)
|
||||
@click.option(
|
||||
"--api",
|
||||
default="anilist",
|
||||
type=click.Choice(["anilist"], case_sensitive=False),
|
||||
help="Media API registry to search",
|
||||
)
|
||||
@click.pass_obj
|
||||
def downloads(
|
||||
config: AppConfig,
|
||||
query: str | None,
|
||||
status: str | None,
|
||||
genre: tuple[str, ...],
|
||||
format: str | None,
|
||||
year: int | None,
|
||||
min_score: float | None,
|
||||
max_score: float | None,
|
||||
sort: str,
|
||||
limit: int,
|
||||
output_json: bool,
|
||||
api: str,
|
||||
):
|
||||
"""
|
||||
Search through your local media registry.
|
||||
|
||||
You can search by title and filter by various criteria like status,
|
||||
genre, format, year, and score range.
|
||||
"""
|
||||
feedback = FeedbackService(config)
|
||||
if not has_user_input(click.get_current_context()):
|
||||
from ....interactive.session import session
|
||||
from ....interactive.state import MediaApiState, MenuName, State
|
||||
|
||||
# Create initial state with search results
|
||||
initial_state = [State(menu_name=MenuName.DOWNLOADS)]
|
||||
|
||||
session.load_menus_from_folder("media")
|
||||
session.run(config, history=initial_state)
|
||||
|
||||
registry_service = MediaRegistryService(api, config.media_registry)
|
||||
|
||||
search_params = _build_search_params(
|
||||
query, status, genre, format, year, min_score, max_score, sort, limit
|
||||
)
|
||||
|
||||
with feedback.progress("Searching local registry..."):
|
||||
result = registry_service.search_for_media(search_params)
|
||||
|
||||
if not result or not result.media:
|
||||
feedback.info("No Results", "No media found matching your criteria")
|
||||
return
|
||||
|
||||
if output_json:
|
||||
print(json.dumps(result.model_dump(mode="json"), indent=2))
|
||||
return
|
||||
|
||||
from ....interactive.session import session
|
||||
from ....interactive.state import MediaApiState, MenuName, State
|
||||
|
||||
feedback.info(
|
||||
f"Found {len(result.media)} anime matching your search. Launching interactive mode..."
|
||||
)
|
||||
|
||||
# Create initial state with search results
|
||||
initial_state = [
|
||||
State(menu_name=MenuName.DOWNLOADS),
|
||||
State(
|
||||
menu_name=MenuName.RESULTS,
|
||||
media_api=MediaApiState(
|
||||
search_result={
|
||||
media_item.id: media_item for media_item in result.media
|
||||
},
|
||||
search_params=search_params,
|
||||
page_info=result.page_info,
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
session.load_menus_from_folder("media")
|
||||
session.run(config, history=initial_state)
|
||||
|
||||
|
||||
def _build_search_params(
|
||||
query: str | None,
|
||||
status: str | None,
|
||||
genre: tuple[str, ...],
|
||||
format_str: str | None,
|
||||
year: int | None,
|
||||
min_score: float | None,
|
||||
max_score: float | None,
|
||||
sort: str,
|
||||
limit: int,
|
||||
) -> MediaSearchParams:
|
||||
"""Build MediaSearchParams from command options for local filtering."""
|
||||
sort_map = {
|
||||
"title": MediaSort.TITLE_ROMAJI,
|
||||
"score": MediaSort.SCORE_DESC,
|
||||
"popularity": MediaSort.POPULARITY_DESC,
|
||||
"year": MediaSort.START_DATE_DESC,
|
||||
"episodes": MediaSort.EPISODES_DESC,
|
||||
"updated": MediaSort.UPDATED_AT_DESC,
|
||||
}
|
||||
|
||||
# Safely convert strings to enums
|
||||
format_enum = next(
|
||||
(f for f in MediaFormat if f.value.lower() == (format_str or "").lower()), None
|
||||
)
|
||||
genre_enums = [
|
||||
g for g_str in genre for g in MediaGenre if g.value.lower() == g_str.lower()
|
||||
]
|
||||
|
||||
# Note: Local search handles status separately as it's part of the index, not MediaItem
|
||||
|
||||
return MediaSearchParams(
|
||||
query=query,
|
||||
per_page=limit,
|
||||
sort=[sort_map.get(sort.lower(), MediaSort.TITLE_ROMAJI)],
|
||||
averageScore_greater=int(min_score * 10) if min_score is not None else None,
|
||||
averageScore_lesser=int(max_score * 10) if max_score is not None else None,
|
||||
genre_in=genre_enums or None,
|
||||
format_in=[format_enum] if format_enum else None,
|
||||
seasonYear=year,
|
||||
)
|
||||
|
||||
|
||||
def has_user_input(ctx: click.Context) -> bool:
|
||||
"""
|
||||
Checks if any command-line options or arguments were provided by the user
|
||||
by comparing the given values to their default values.
|
||||
|
||||
This handles all parameter types including flags, multiple options,
|
||||
and arguments with no default.
|
||||
"""
|
||||
import sys
|
||||
|
||||
if len(sys.argv) > 3:
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
for param in ctx.command.params:
|
||||
# Get the value for the parameter from the context.
|
||||
# This will be the user-provided value or the default.
|
||||
value = ctx.params.get(param.name)
|
||||
|
||||
# We need to explicitly check if a value was provided by the user.
|
||||
# The simplest way to do this is to compare it to its default.
|
||||
if value != param.default:
|
||||
# If the value is different from the default, the user
|
||||
# must have provided it.
|
||||
return True
|
||||
|
||||
# If the loop completes without finding any non-default values,
|
||||
# then no user input was given.
|
||||
return False
|
||||
56
viu/cli/commands/anilist/commands/notifications.py
Normal file
56
viu/cli/commands/anilist/commands/notifications.py
Normal file
@@ -0,0 +1,56 @@
|
||||
import click
|
||||
from viu.core.config import AppConfig
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
|
||||
|
||||
@click.command(help="Check for new AniList notifications (e.g., for airing episodes).")
|
||||
@click.pass_obj
|
||||
def notifications(config: AppConfig):
|
||||
"""
|
||||
Displays unread notifications from AniList.
|
||||
Running this command will also mark the notifications as read on the AniList website.
|
||||
"""
|
||||
from viu.cli.service.feedback import FeedbackService
|
||||
from viu.libs.media_api.api import create_api_client
|
||||
|
||||
from ....service.auth import AuthService
|
||||
|
||||
feedback = FeedbackService(config)
|
||||
console = Console()
|
||||
auth = AuthService(config.general.media_api)
|
||||
api_client = create_api_client(config.general.media_api, config)
|
||||
if profile := auth.get_auth():
|
||||
api_client.authenticate(profile.token)
|
||||
|
||||
if not api_client.is_authenticated():
|
||||
feedback.error(
|
||||
"Authentication Required", "Please log in with 'viu anilist auth'."
|
||||
)
|
||||
return
|
||||
|
||||
with feedback.progress("Fetching notifications..."):
|
||||
notifs = api_client.get_notifications()
|
||||
|
||||
if not notifs:
|
||||
feedback.success("All caught up!", "You have no new notifications.")
|
||||
return
|
||||
|
||||
table = Table(
|
||||
title="🔔 AniList Notifications", show_header=True, header_style="bold magenta"
|
||||
)
|
||||
table.add_column("Date", style="dim", width=12)
|
||||
table.add_column("Anime Title", style="cyan")
|
||||
table.add_column("Details", style="green")
|
||||
|
||||
for notif in sorted(notifs, key=lambda n: n.created_at, reverse=True):
|
||||
title = notif.media.title.english or notif.media.title.romaji or "Unknown"
|
||||
date_str = notif.created_at.strftime("%Y-%m-%d")
|
||||
details = f"Episode {notif.episode} has aired!"
|
||||
|
||||
table.add_row(date_str, title, details)
|
||||
|
||||
console.print(table)
|
||||
feedback.info(
|
||||
"Notifications have been marked as read on AniList.",
|
||||
)
|
||||
334
viu/cli/commands/anilist/commands/search.py
Normal file
334
viu/cli/commands/anilist/commands/search.py
Normal file
@@ -0,0 +1,334 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import click
|
||||
|
||||
from .....core.config import AppConfig
|
||||
from .....core.exceptions import ViuError
|
||||
from .....libs.media_api.types import (
|
||||
MediaFormat,
|
||||
MediaGenre,
|
||||
MediaSeason,
|
||||
MediaSort,
|
||||
MediaStatus,
|
||||
MediaTag,
|
||||
MediaType,
|
||||
MediaYear,
|
||||
)
|
||||
from ....utils.completion import anime_titles_shell_complete
|
||||
from .. import examples
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import TypedDict
|
||||
|
||||
from typing_extensions import Unpack
|
||||
|
||||
class SearchOptions(TypedDict, total=False):
|
||||
title: str | None
|
||||
dump_json: bool
|
||||
page: int
|
||||
per_page: int | None
|
||||
season: str | None
|
||||
status: tuple[str, ...]
|
||||
status_not: tuple[str, ...]
|
||||
sort: str | None
|
||||
genres: tuple[str, ...]
|
||||
genres_not: tuple[str, ...]
|
||||
tags: tuple[str, ...]
|
||||
tags_not: tuple[str, ...]
|
||||
media_format: tuple[str, ...]
|
||||
media_type: str | None
|
||||
year: str | None
|
||||
popularity_greater: int | None
|
||||
popularity_lesser: int | None
|
||||
score_greater: int | None
|
||||
score_lesser: int | None
|
||||
start_date_greater: int | None
|
||||
start_date_lesser: int | None
|
||||
end_date_greater: int | None
|
||||
end_date_lesser: int | None
|
||||
on_list: bool | None
|
||||
|
||||
|
||||
@click.command(
|
||||
help="Search for anime using anilists api and get top ~50 results",
|
||||
short_help="Search for anime",
|
||||
epilog=examples.search,
|
||||
)
|
||||
@click.option("--title", "-t", shell_complete=anime_titles_shell_complete)
|
||||
@click.option(
|
||||
"--dump-json",
|
||||
"-d",
|
||||
is_flag=True,
|
||||
help="Only print out the results dont open anilist menu",
|
||||
)
|
||||
@click.option(
|
||||
"--page",
|
||||
"-p",
|
||||
type=click.IntRange(min=1),
|
||||
default=1,
|
||||
help="Page number for pagination",
|
||||
)
|
||||
@click.option(
|
||||
"--per-page",
|
||||
type=click.IntRange(min=1, max=50),
|
||||
help="Number of results per page (max 50)",
|
||||
)
|
||||
@click.option(
|
||||
"--season",
|
||||
help="The season the media was released",
|
||||
type=click.Choice([season.value for season in MediaSeason]),
|
||||
)
|
||||
@click.option(
|
||||
"--status",
|
||||
"-S",
|
||||
help="The media status of the anime",
|
||||
multiple=True,
|
||||
type=click.Choice([status.value for status in MediaStatus]),
|
||||
)
|
||||
@click.option(
|
||||
"--status-not",
|
||||
help="Exclude media with these statuses",
|
||||
multiple=True,
|
||||
type=click.Choice([status.value for status in MediaStatus]),
|
||||
)
|
||||
@click.option(
|
||||
"--sort",
|
||||
"-s",
|
||||
help="What to sort the search results on",
|
||||
type=click.Choice([sort.value for sort in MediaSort]),
|
||||
)
|
||||
@click.option(
|
||||
"--genres",
|
||||
"-g",
|
||||
multiple=True,
|
||||
help="the genres to filter by",
|
||||
type=click.Choice([genre.value for genre in MediaGenre]),
|
||||
)
|
||||
@click.option(
|
||||
"--genres-not",
|
||||
multiple=True,
|
||||
help="Exclude these genres",
|
||||
type=click.Choice([genre.value for genre in MediaGenre]),
|
||||
)
|
||||
@click.option(
|
||||
"--tags",
|
||||
"-T",
|
||||
multiple=True,
|
||||
help="the tags to filter by",
|
||||
type=click.Choice([tag.value for tag in MediaTag]),
|
||||
)
|
||||
@click.option(
|
||||
"--tags-not",
|
||||
multiple=True,
|
||||
help="Exclude these tags",
|
||||
type=click.Choice([tag.value for tag in MediaTag]),
|
||||
)
|
||||
@click.option(
|
||||
"--media-format",
|
||||
"-f",
|
||||
multiple=True,
|
||||
help="Media format",
|
||||
type=click.Choice([format.value for format in MediaFormat]),
|
||||
)
|
||||
@click.option(
|
||||
"--media-type",
|
||||
help="Media type (ANIME or MANGA)",
|
||||
type=click.Choice([media_type.value for media_type in MediaType]),
|
||||
)
|
||||
@click.option(
|
||||
"--year",
|
||||
"-y",
|
||||
type=click.Choice([year.value for year in MediaYear]),
|
||||
help="the year the media was released",
|
||||
)
|
||||
@click.option(
|
||||
"--popularity-greater",
|
||||
type=click.IntRange(min=0),
|
||||
help="Minimum popularity score",
|
||||
)
|
||||
@click.option(
|
||||
"--popularity-lesser",
|
||||
type=click.IntRange(min=0),
|
||||
help="Maximum popularity score",
|
||||
)
|
||||
@click.option(
|
||||
"--score-greater",
|
||||
type=click.IntRange(min=0, max=100),
|
||||
help="Minimum average score (0-100)",
|
||||
)
|
||||
@click.option(
|
||||
"--score-lesser",
|
||||
type=click.IntRange(min=0, max=100),
|
||||
help="Maximum average score (0-100)",
|
||||
)
|
||||
@click.option(
|
||||
"--start-date-greater",
|
||||
type=click.IntRange(min=10000101, max=99991231),
|
||||
help="Minimum start date (YYYYMMDD format, e.g., 20240101)",
|
||||
)
|
||||
@click.option(
|
||||
"--start-date-lesser",
|
||||
type=click.IntRange(min=10000101, max=99991231),
|
||||
help="Maximum start date (YYYYMMDD format, e.g., 20241231)",
|
||||
)
|
||||
@click.option(
|
||||
"--end-date-greater",
|
||||
type=click.IntRange(min=10000101, max=99991231),
|
||||
help="Minimum end date (YYYYMMDD format, e.g., 20240101)",
|
||||
)
|
||||
@click.option(
|
||||
"--end-date-lesser",
|
||||
type=click.IntRange(min=10000101, max=99991231),
|
||||
help="Maximum end date (YYYYMMDD format, e.g., 20241231)",
|
||||
)
|
||||
@click.option(
|
||||
"--on-list/--not-on-list",
|
||||
"-L/-no-L",
|
||||
help="Whether the anime should be in your list or not",
|
||||
type=bool,
|
||||
)
|
||||
@click.pass_obj
|
||||
def search(config: AppConfig, **options: "Unpack[SearchOptions]"):
|
||||
import json
|
||||
|
||||
from rich.progress import Progress
|
||||
|
||||
from .....libs.media_api.api import create_api_client
|
||||
from .....libs.media_api.params import MediaSearchParams
|
||||
from ....service.feedback import FeedbackService
|
||||
|
||||
feedback = FeedbackService(config)
|
||||
|
||||
try:
|
||||
# Create API client
|
||||
api_client = create_api_client(config.general.media_api, config)
|
||||
|
||||
# Extract options
|
||||
title = options.get("title")
|
||||
dump_json = options.get("dump_json", False)
|
||||
page = options.get("page", 1)
|
||||
per_page = options.get("per_page") or config.anilist.per_page or 50
|
||||
season = options.get("season")
|
||||
status = options.get("status", ())
|
||||
status_not = options.get("status_not", ())
|
||||
sort = options.get("sort")
|
||||
genres = options.get("genres", ())
|
||||
genres_not = options.get("genres_not", ())
|
||||
tags = options.get("tags", ())
|
||||
tags_not = options.get("tags_not", ())
|
||||
media_format = options.get("media_format", ())
|
||||
media_type = options.get("media_type")
|
||||
year = options.get("year")
|
||||
popularity_greater = options.get("popularity_greater")
|
||||
popularity_lesser = options.get("popularity_lesser")
|
||||
score_greater = options.get("score_greater")
|
||||
score_lesser = options.get("score_lesser")
|
||||
start_date_greater = options.get("start_date_greater")
|
||||
start_date_lesser = options.get("start_date_lesser")
|
||||
end_date_greater = options.get("end_date_greater")
|
||||
end_date_lesser = options.get("end_date_lesser")
|
||||
on_list = options.get("on_list")
|
||||
|
||||
# Validate logical relationships
|
||||
if (
|
||||
score_greater is not None
|
||||
and score_lesser is not None
|
||||
and score_greater > score_lesser
|
||||
):
|
||||
raise ViuError("Minimum score cannot be higher than maximum score")
|
||||
|
||||
if (
|
||||
popularity_greater is not None
|
||||
and popularity_lesser is not None
|
||||
and popularity_greater > popularity_lesser
|
||||
):
|
||||
raise ViuError(
|
||||
"Minimum popularity cannot be higher than maximum popularity"
|
||||
)
|
||||
|
||||
if (
|
||||
start_date_greater is not None
|
||||
and start_date_lesser is not None
|
||||
and start_date_greater > start_date_lesser
|
||||
):
|
||||
raise ViuError(
|
||||
"Start date greater cannot be later than start date lesser"
|
||||
)
|
||||
|
||||
if (
|
||||
end_date_greater is not None
|
||||
and end_date_lesser is not None
|
||||
and end_date_greater > end_date_lesser
|
||||
):
|
||||
raise ViuError(
|
||||
"End date greater cannot be later than end date lesser"
|
||||
)
|
||||
|
||||
# Build search parameters
|
||||
search_params = MediaSearchParams(
|
||||
query=title,
|
||||
page=page,
|
||||
per_page=per_page,
|
||||
sort=MediaSort(sort) if sort else None,
|
||||
status_in=[MediaStatus(s) for s in status] if status else None,
|
||||
status_not_in=[MediaStatus(s) for s in status_not] if status_not else None,
|
||||
genre_in=[MediaGenre(g) for g in genres] if genres else None,
|
||||
genre_not_in=[MediaGenre(g) for g in genres_not] if genres_not else None,
|
||||
tag_in=[MediaTag(t) for t in tags] if tags else None,
|
||||
tag_not_in=[MediaTag(t) for t in tags_not] if tags_not else None,
|
||||
format_in=[MediaFormat(f) for f in media_format] if media_format else None,
|
||||
type=MediaType(media_type) if media_type else None,
|
||||
season=MediaSeason(season) if season else None,
|
||||
seasonYear=int(year) if year else None,
|
||||
popularity_greater=popularity_greater,
|
||||
popularity_lesser=popularity_lesser,
|
||||
averageScore_greater=score_greater,
|
||||
averageScore_lesser=score_lesser,
|
||||
startDate_greater=start_date_greater,
|
||||
startDate_lesser=start_date_lesser,
|
||||
endDate_greater=end_date_greater,
|
||||
endDate_lesser=end_date_lesser,
|
||||
on_list=on_list,
|
||||
)
|
||||
|
||||
# Search for anime
|
||||
with Progress() as progress:
|
||||
progress.add_task("Searching anime...", total=None)
|
||||
search_result = api_client.search_media(search_params)
|
||||
|
||||
if not search_result or not search_result.media:
|
||||
raise ViuError("No anime found matching your search criteria")
|
||||
|
||||
if dump_json:
|
||||
# Use Pydantic's built-in serialization
|
||||
print(json.dumps(search_result.model_dump(mode="json")))
|
||||
else:
|
||||
# Launch interactive session for browsing results
|
||||
from ....interactive.session import session
|
||||
from ....interactive.state import MediaApiState, MenuName, State
|
||||
|
||||
feedback.info(
|
||||
f"Found {len(search_result.media)} anime matching your search. Launching interactive mode..."
|
||||
)
|
||||
|
||||
# Create initial state with search results
|
||||
initial_state = State(
|
||||
menu_name=MenuName.RESULTS,
|
||||
media_api=MediaApiState(
|
||||
search_result={
|
||||
media_item.id: media_item for media_item in search_result.media
|
||||
},
|
||||
search_params=search_params,
|
||||
page_info=search_result.page_info,
|
||||
),
|
||||
)
|
||||
|
||||
session.load_menus_from_folder("media")
|
||||
session.run(config, history=[initial_state])
|
||||
|
||||
except ViuError as e:
|
||||
feedback.error("Search failed", str(e))
|
||||
raise click.Abort()
|
||||
except Exception as e:
|
||||
feedback.error("Unexpected error occurred", str(e))
|
||||
raise click.Abort()
|
||||
90
viu/cli/commands/anilist/commands/stats.py
Normal file
90
viu/cli/commands/anilist/commands/stats.py
Normal file
@@ -0,0 +1,90 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import click
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from viu.core.config import AppConfig
|
||||
|
||||
|
||||
@click.command(help="Print out your anilist stats")
|
||||
@click.pass_obj
|
||||
def stats(config: "AppConfig"):
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
from rich.console import Console
|
||||
from rich.markdown import Markdown
|
||||
from rich.panel import Panel
|
||||
|
||||
from .....libs.media_api.api import create_api_client
|
||||
from ....service.auth import AuthService
|
||||
from ....service.feedback import FeedbackService
|
||||
|
||||
console = Console()
|
||||
|
||||
feedback = FeedbackService(config)
|
||||
auth = AuthService(config.general.media_api)
|
||||
|
||||
media_api_client = create_api_client(config.general.media_api, config)
|
||||
|
||||
try:
|
||||
# Check authentication
|
||||
|
||||
if profile := auth.get_auth():
|
||||
if not media_api_client.authenticate(profile.token):
|
||||
feedback.error(
|
||||
"Authentication Required",
|
||||
f"You must be logged in to {config.general.media_api} to sync your media list.",
|
||||
)
|
||||
feedback.info(
|
||||
"Run this command to authenticate:",
|
||||
f"viu {config.general.media_api} auth",
|
||||
)
|
||||
raise click.Abort()
|
||||
|
||||
# Check if kitten is available for image display
|
||||
KITTEN_EXECUTABLE = shutil.which("kitten")
|
||||
if not KITTEN_EXECUTABLE:
|
||||
feedback.warning(
|
||||
"Kitten not found - profile image will not be displayed"
|
||||
)
|
||||
else:
|
||||
# Display profile image using kitten icat
|
||||
if profile.user_profile.avatar_url:
|
||||
console.clear()
|
||||
image_x = int(console.size.width * 0.1)
|
||||
image_y = int(console.size.height * 0.1)
|
||||
img_w = console.size.width // 3
|
||||
img_h = console.size.height // 3
|
||||
|
||||
image_process = subprocess.run(
|
||||
[
|
||||
KITTEN_EXECUTABLE,
|
||||
"icat",
|
||||
"--clear",
|
||||
"--place",
|
||||
f"{img_w}x{img_h}@{image_x}x{image_y}",
|
||||
profile.user_profile.avatar_url,
|
||||
],
|
||||
check=False,
|
||||
)
|
||||
|
||||
if image_process.returncode != 0:
|
||||
feedback.warning("Failed to display profile image")
|
||||
|
||||
# Display user information
|
||||
about_text = getattr(profile, "about", "") or "No description available"
|
||||
|
||||
console.print(
|
||||
Panel(
|
||||
Markdown(about_text),
|
||||
title=f"📊 {profile.user_profile.name}'s Profile",
|
||||
)
|
||||
)
|
||||
|
||||
# You can add more stats here if the API provides them
|
||||
feedback.success("User profile displayed successfully")
|
||||
|
||||
except Exception as e:
|
||||
feedback.error("Unexpected error occurred", str(e))
|
||||
raise click.Abort()
|
||||
169
viu/cli/commands/anilist/examples.py
Normal file
169
viu/cli/commands/anilist/examples.py
Normal file
@@ -0,0 +1,169 @@
|
||||
download = """
|
||||
\b
|
||||
\b\bExamples:
|
||||
# Basic download by title
|
||||
viu anilist download -t "Attack on Titan"
|
||||
\b
|
||||
# Download specific episodes
|
||||
viu anilist download -t "One Piece" --episode-range "1-10"
|
||||
\b
|
||||
# Download single episode
|
||||
viu anilist download -t "Death Note" --episode-range "1"
|
||||
\b
|
||||
# Download multiple specific episodes
|
||||
viu anilist download -t "Naruto" --episode-range "1,5,10"
|
||||
\b
|
||||
# Download with quality preference
|
||||
viu anilist download -t "Death Note" --quality 1080 --episode-range "1-5"
|
||||
\b
|
||||
# Download with multiple filters
|
||||
viu anilist download -g Action -T Isekai --score-greater 80 --status RELEASING
|
||||
\b
|
||||
# Download with concurrent downloads
|
||||
viu anilist download -t "Demon Slayer" --episode-range "1-5" --max-concurrent 3
|
||||
\b
|
||||
# Force redownload existing episodes
|
||||
viu anilist download -t "Your Name" --episode-range "1" --force-redownload
|
||||
\b
|
||||
# Download from a specific season and year
|
||||
viu anilist download --season WINTER --year 2024 -s POPULARITY_DESC
|
||||
\b
|
||||
# Download with genre filtering
|
||||
viu anilist download -g Action -g Adventure --score-greater 75
|
||||
\b
|
||||
# Download only completed series
|
||||
viu anilist download -g Fantasy --status FINISHED --score-greater 75
|
||||
\b
|
||||
# Download movies only
|
||||
viu anilist download -F MOVIE -s SCORE_DESC --quality best
|
||||
"""
|
||||
|
||||
|
||||
search = """
|
||||
\b
|
||||
\b\bExamples:
|
||||
# Basic search by title
|
||||
viu anilist search -t "Attack on Titan"
|
||||
\b
|
||||
# Search with multiple filters
|
||||
viu anilist search -g Action -T Isekai --score-greater 75 --status RELEASING
|
||||
\b
|
||||
# Get anime with the tag of isekai
|
||||
viu anilist search -T isekai
|
||||
\b
|
||||
# Get anime of 2024 and sort by popularity, finished or releasing, not in your list
|
||||
viu anilist search -y 2024 -s POPULARITY_DESC --status RELEASING --status FINISHED --not-on-list
|
||||
\b
|
||||
# Get anime of 2024 season WINTER
|
||||
viu anilist search -y 2024 --season WINTER
|
||||
\b
|
||||
# Get anime genre action and tag isekai,magic
|
||||
viu anilist search -g Action -T Isekai -T Magic
|
||||
\b
|
||||
# Get anime of 2024 thats finished airing
|
||||
viu anilist search -y 2024 -S FINISHED
|
||||
\b
|
||||
# Get the most favourite anime movies
|
||||
viu anilist search -f MOVIE -s FAVOURITES_DESC
|
||||
\b
|
||||
# Search with score and popularity filters
|
||||
viu anilist search --score-greater 80 --popularity-greater 50000
|
||||
\b
|
||||
# Search excluding certain genres and tags
|
||||
viu anilist search --genres-not Ecchi --tags-not "Hentai"
|
||||
\b
|
||||
# Search with date ranges (YYYYMMDD format)
|
||||
viu anilist search --start-date-greater 20200101 --start-date-lesser 20241231
|
||||
\b
|
||||
# Get only TV series, exclude certain statuses
|
||||
viu anilist search -f TV --status-not CANCELLED --status-not HIATUS
|
||||
\b
|
||||
# Paginated search with custom page size
|
||||
viu anilist search -g Action --page 2 --per-page 25
|
||||
\b
|
||||
# Search for manga specifically
|
||||
viu anilist search --media-type MANGA -g Fantasy
|
||||
\b
|
||||
# Complex search with multiple criteria
|
||||
viu anilist search -t "demon" -g Action -g Supernatural --score-greater 70 --year 2020 -s SCORE_DESC
|
||||
\b
|
||||
# Dump search results as JSON instead of interactive mode
|
||||
viu anilist search -g Action --dump-json
|
||||
"""
|
||||
|
||||
|
||||
main = """
|
||||
\b
|
||||
\b\bExamples:
|
||||
# ---- search ----
|
||||
\b
|
||||
# Basic search by title
|
||||
viu anilist search -t "Attack on Titan"
|
||||
\b
|
||||
# Search with multiple filters
|
||||
viu anilist search -g Action -T Isekai --score-greater 75 --status RELEASING
|
||||
\b
|
||||
# Get anime with the tag of isekai
|
||||
viu anilist search -T isekai
|
||||
\b
|
||||
# Get anime of 2024 and sort by popularity, finished or releasing, not in your list
|
||||
viu anilist search -y 2024 -s POPULARITY_DESC --status RELEASING --status FINISHED --not-on-list
|
||||
\b
|
||||
# Get anime of 2024 season WINTER
|
||||
viu anilist search -y 2024 --season WINTER
|
||||
\b
|
||||
# Get anime genre action and tag isekai,magic
|
||||
viu anilist search -g Action -T Isekai -T Magic
|
||||
\b
|
||||
# Get anime of 2024 thats finished airing
|
||||
viu anilist search -y 2024 -S FINISHED
|
||||
\b
|
||||
# Get the most favourite anime movies
|
||||
viu anilist search -f MOVIE -s FAVOURITES_DESC
|
||||
\b
|
||||
# Search with score and popularity filters
|
||||
viu anilist search --score-greater 80 --popularity-greater 50000
|
||||
\b
|
||||
# Search excluding certain genres and tags
|
||||
viu anilist search --genres-not Ecchi --tags-not "Hentai"
|
||||
\b
|
||||
# Search with date ranges (YYYYMMDD format)
|
||||
viu anilist search --start-date-greater 20200101 --start-date-lesser 20241231
|
||||
\b
|
||||
# Get only TV series, exclude certain statuses
|
||||
viu anilist search -f TV --status-not CANCELLED --status-not HIATUS
|
||||
\b
|
||||
# Paginated search with custom page size
|
||||
viu anilist search -g Action --page 2 --per-page 25
|
||||
\b
|
||||
# Search for manga specifically
|
||||
viu anilist search --media-type MANGA -g Fantasy
|
||||
\b
|
||||
# Complex search with multiple criteria
|
||||
viu anilist search -t "demon" -g Action -g Supernatural --score-greater 70 --year 2020 -s SCORE_DESC
|
||||
\b
|
||||
# Dump search results as JSON instead of interactive mode
|
||||
viu anilist search -g Action --dump-json
|
||||
\b
|
||||
# ---- login ----
|
||||
\b
|
||||
# To sign in just run
|
||||
viu anilist auth
|
||||
\b
|
||||
# To check your login status
|
||||
viu anilist auth --status
|
||||
\b
|
||||
# To log out and erase credentials
|
||||
viu anilist auth --logout
|
||||
\b
|
||||
# ---- notifier ----
|
||||
\b
|
||||
# basic form
|
||||
viu anilist notifier
|
||||
\b
|
||||
# with logging to stdout
|
||||
viu --log anilist notifier
|
||||
\b
|
||||
# with logging to a file. stored in the same place as your config
|
||||
viu --log-file anilist notifier
|
||||
"""
|
||||
141
viu/cli/commands/completions.py
Normal file
141
viu/cli/commands/completions.py
Normal file
@@ -0,0 +1,141 @@
|
||||
import click
|
||||
|
||||
|
||||
@click.command(
|
||||
help="Helper command to get shell completions",
|
||||
epilog="""
|
||||
\b
|
||||
\b\bExamples:
|
||||
# try to detect your shell and print completions
|
||||
viu completions
|
||||
\b
|
||||
# print fish completions
|
||||
viu completions --fish
|
||||
\b
|
||||
# print bash completions
|
||||
viu completions --bash
|
||||
\b
|
||||
# print zsh completions
|
||||
viu completions --zsh
|
||||
""",
|
||||
)
|
||||
@click.option("--fish", is_flag=True, help="print fish completions")
|
||||
@click.option("--zsh", is_flag=True, help="print zsh completions")
|
||||
@click.option("--bash", is_flag=True, help="print bash completions")
|
||||
def completions(fish, zsh, bash):
|
||||
if not fish or not zsh or not bash:
|
||||
import os
|
||||
|
||||
shell_env = os.environ.get("SHELL", "")
|
||||
if "fish" in shell_env:
|
||||
current_shell = "fish"
|
||||
elif "zsh" in shell_env:
|
||||
current_shell = "zsh"
|
||||
elif "bash" in shell_env:
|
||||
current_shell = "bash"
|
||||
else:
|
||||
current_shell = None
|
||||
else:
|
||||
current_shell = None
|
||||
if fish or (current_shell == "fish" and not zsh and not bash):
|
||||
print(
|
||||
"""
|
||||
function _viu_completion;
|
||||
set -l response (env _VIU_COMPLETE=fish_complete COMP_WORDS=(commandline -cp) COMP_CWORD=(commandline -t) viu);
|
||||
|
||||
for completion in $response;
|
||||
set -l metadata (string split "," $completion);
|
||||
|
||||
if test $metadata[1] = "dir";
|
||||
__fish_complete_directories $metadata[2];
|
||||
else if test $metadata[1] = "file";
|
||||
__fish_complete_path $metadata[2];
|
||||
else if test $metadata[1] = "plain";
|
||||
echo $metadata[2];
|
||||
end;
|
||||
end;
|
||||
end;
|
||||
|
||||
complete --no-files --command viu --arguments "(_viu_completion)";
|
||||
"""
|
||||
)
|
||||
elif zsh or (current_shell == "zsh" and not bash):
|
||||
print(
|
||||
"""
|
||||
#compdef viu
|
||||
|
||||
_viu_completion() {
|
||||
local -a completions
|
||||
local -a completions_with_descriptions
|
||||
local -a response
|
||||
(( ! $+commands[viu] )) && return 1
|
||||
|
||||
response=("${(@f)$(env COMP_WORDS="${words[*]}" COMP_CWORD=$((CURRENT-1)) _VIU_COMPLETE=zsh_complete viu)}")
|
||||
|
||||
for type key descr in ${response}; do
|
||||
if [[ "$type" == "plain" ]]; then
|
||||
if [[ "$descr" == "_" ]]; then
|
||||
completions+=("$key")
|
||||
else
|
||||
completions_with_descriptions+=("$key":"$descr")
|
||||
fi
|
||||
elif [[ "$type" == "dir" ]]; then
|
||||
_path_files -/
|
||||
elif [[ "$type" == "file" ]]; then
|
||||
_path_files -f
|
||||
fi
|
||||
done
|
||||
|
||||
if [ -n "$completions_with_descriptions" ]; then
|
||||
_describe -V unsorted completions_with_descriptions -U
|
||||
fi
|
||||
|
||||
if [ -n "$completions" ]; then
|
||||
compadd -U -V unsorted -a completions
|
||||
fi
|
||||
}
|
||||
|
||||
if [[ $zsh_eval_context[-1] == loadautofunc ]]; then
|
||||
# autoload from fpath, call function directly
|
||||
_viu_completion "$@"
|
||||
else
|
||||
# eval/source/. command, register function for later
|
||||
compdef _viu_completion viu
|
||||
fi
|
||||
"""
|
||||
)
|
||||
elif bash or current_shell == "bash":
|
||||
print(
|
||||
"""
|
||||
_viu_completion() {
|
||||
local IFS=$'\n'
|
||||
local response
|
||||
|
||||
response=$(env COMP_WORDS="${COMP_WORDS[*]}" COMP_CWORD=$COMP_CWORD _VIU_COMPLETE=bash_complete $1)
|
||||
|
||||
for completion in $response; do
|
||||
IFS=',' read type value <<< "$completion"
|
||||
|
||||
if [[ $type == 'dir' ]]; then
|
||||
COMPREPLY=()
|
||||
compopt -o dirnames
|
||||
elif [[ $type == 'file' ]]; then
|
||||
COMPREPLY=()
|
||||
compopt -o default
|
||||
elif [[ $type == 'plain' ]]; then
|
||||
COMPREPLY+=($value)
|
||||
fi
|
||||
done
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
_viu_completion_setup() {
|
||||
complete -o nosort -F _viu_completion viu
|
||||
}
|
||||
|
||||
_viu_completion_setup;
|
||||
"""
|
||||
)
|
||||
else:
|
||||
print("Could not detect shell")
|
||||
173
viu/cli/commands/config.py
Normal file
173
viu/cli/commands/config.py
Normal file
@@ -0,0 +1,173 @@
|
||||
import click
|
||||
|
||||
from ...core.config import AppConfig
|
||||
|
||||
|
||||
@click.command(
|
||||
help="Manage your config with ease",
|
||||
short_help="Edit your config",
|
||||
epilog="""
|
||||
\b
|
||||
\b\bExamples:
|
||||
# Edit your config in your default editor
|
||||
# NB: If it opens vim or vi exit with `:q`
|
||||
viu config
|
||||
\b
|
||||
# Start the interactive configuration wizard
|
||||
viu config --interactive
|
||||
\b
|
||||
# get the path of the config file
|
||||
viu config --path
|
||||
\b
|
||||
# print desktop entry info
|
||||
viu config --generate-desktop-entry
|
||||
\b
|
||||
# update your config without opening an editor
|
||||
viu --icons --selector fzf --preview full config --update
|
||||
\b
|
||||
# interactively define your config
|
||||
viu config --interactive
|
||||
\b
|
||||
# view the current contents of your config
|
||||
viu config --view
|
||||
""",
|
||||
)
|
||||
@click.option("--path", "-p", help="Print the config location and exit", is_flag=True)
|
||||
@click.option(
|
||||
"--view", "-v", help="View the current contents of your config", is_flag=True
|
||||
)
|
||||
@click.option(
|
||||
"--view-json",
|
||||
"-vj",
|
||||
help="View the current contents of your config in json format",
|
||||
is_flag=True,
|
||||
)
|
||||
@click.option(
|
||||
"--generate-desktop-entry",
|
||||
"-d",
|
||||
help="Generate the desktop entry of viu",
|
||||
is_flag=True,
|
||||
)
|
||||
@click.option(
|
||||
"--update",
|
||||
"-u",
|
||||
help="Persist all the config options passed to viu to your config file",
|
||||
is_flag=True,
|
||||
)
|
||||
@click.option(
|
||||
"--interactive",
|
||||
"-i",
|
||||
is_flag=True,
|
||||
help="Start the interactive configuration wizard.",
|
||||
)
|
||||
@click.pass_obj
|
||||
def config(
|
||||
user_config: AppConfig,
|
||||
path,
|
||||
view,
|
||||
view_json,
|
||||
generate_desktop_entry,
|
||||
update,
|
||||
interactive,
|
||||
):
|
||||
from ...core.constants import USER_CONFIG
|
||||
from ..config.editor import InteractiveConfigEditor
|
||||
from ..config.generate import generate_config_ini_from_app_model
|
||||
|
||||
if path:
|
||||
print(USER_CONFIG)
|
||||
elif view:
|
||||
from rich.console import Console
|
||||
from rich.syntax import Syntax
|
||||
|
||||
console = Console()
|
||||
config_ini = generate_config_ini_from_app_model(user_config)
|
||||
syntax = Syntax(
|
||||
config_ini,
|
||||
"ini",
|
||||
theme=user_config.general.pygment_style,
|
||||
line_numbers=True,
|
||||
word_wrap=True,
|
||||
)
|
||||
console.print(syntax)
|
||||
elif view_json:
|
||||
import json
|
||||
|
||||
print(json.dumps(user_config.model_dump(mode="json")))
|
||||
elif generate_desktop_entry:
|
||||
_generate_desktop_entry()
|
||||
elif interactive:
|
||||
editor = InteractiveConfigEditor(current_config=user_config)
|
||||
new_config = editor.run()
|
||||
with open(USER_CONFIG, "w", encoding="utf-8") as file:
|
||||
file.write(generate_config_ini_from_app_model(new_config))
|
||||
click.echo(f"Configuration saved successfully to {USER_CONFIG}")
|
||||
elif update:
|
||||
with open(USER_CONFIG, "w", encoding="utf-8") as file:
|
||||
file.write(generate_config_ini_from_app_model(user_config))
|
||||
print("update successfull")
|
||||
else:
|
||||
click.edit(filename=str(USER_CONFIG))
|
||||
|
||||
|
||||
def _generate_desktop_entry():
|
||||
"""
|
||||
Generates a desktop entry for Viu.
|
||||
"""
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from textwrap import dedent
|
||||
|
||||
from rich import print
|
||||
from rich.prompt import Confirm
|
||||
|
||||
from ...core.constants import (
|
||||
ICON_PATH,
|
||||
PLATFORM,
|
||||
PROJECT_NAME,
|
||||
USER_APPLICATIONS,
|
||||
__version__,
|
||||
)
|
||||
|
||||
EXECUTABLE = shutil.which("viu")
|
||||
if EXECUTABLE:
|
||||
cmds = f"{EXECUTABLE} --selector rofi anilist"
|
||||
else:
|
||||
cmds = f"{sys.executable} -m viu --selector rofi anilist"
|
||||
|
||||
# TODO: Get funs of the other platforms to complete this lol
|
||||
if PLATFORM == "win32":
|
||||
print(
|
||||
"Not implemented; the author thinks its not straight forward so welcomes lovers of windows to try and implement it themselves or to switch to a proper os like arch linux or pray the author gets bored 😜"
|
||||
)
|
||||
elif PLATFORM == "darwin":
|
||||
print(
|
||||
"Not implemented; the author thinks its not straight forward so welcomes lovers of mac to try and implement it themselves or to switch to a proper os like arch linux or pray the author gets bored 😜"
|
||||
)
|
||||
else:
|
||||
desktop_entry = dedent(
|
||||
f"""
|
||||
[Desktop Entry]
|
||||
Name={PROJECT_NAME.title()}
|
||||
Type=Application
|
||||
version={__version__}
|
||||
Path={Path().home()}
|
||||
Comment=Watch anime from your terminal
|
||||
Terminal=false
|
||||
Icon={ICON_PATH}
|
||||
Exec={cmds}
|
||||
Categories=Entertainment
|
||||
"""
|
||||
)
|
||||
desktop_entry_path = USER_APPLICATIONS / f"{PROJECT_NAME}.desktop"
|
||||
if desktop_entry_path.exists():
|
||||
if not Confirm.ask(
|
||||
f"The file already exists {desktop_entry_path}; or would you like to rewrite it",
|
||||
default=False,
|
||||
):
|
||||
return
|
||||
with open(desktop_entry_path, "w") as f:
|
||||
f.write(desktop_entry)
|
||||
with open(desktop_entry_path) as f:
|
||||
print(f"Successfully wrote \n{f.read()}")
|
||||
265
viu/cli/commands/download.py
Normal file
265
viu/cli/commands/download.py
Normal file
@@ -0,0 +1,265 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import click
|
||||
|
||||
from ...core.config import AppConfig
|
||||
from ...core.exceptions import ViuError
|
||||
from ..utils.completion import anime_titles_shell_complete
|
||||
from . import examples
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
from typing import TypedDict
|
||||
|
||||
from viu.cli.service.feedback.service import FeedbackService
|
||||
from typing_extensions import Unpack
|
||||
|
||||
from ...libs.provider.anime.base import BaseAnimeProvider
|
||||
from ...libs.provider.anime.types import Anime
|
||||
from ...libs.selectors.base import BaseSelector
|
||||
|
||||
class Options(TypedDict):
|
||||
anime_title: tuple
|
||||
episode_range: str
|
||||
file: Path | None
|
||||
force_unknown_ext: bool
|
||||
silent: bool
|
||||
verbose: bool
|
||||
merge: bool
|
||||
clean: bool
|
||||
wait_time: int
|
||||
prompt: bool
|
||||
force_ffmpeg: bool
|
||||
hls_use_mpegts: bool
|
||||
hls_use_h264: bool
|
||||
|
||||
|
||||
@click.command(
|
||||
help="Download anime using the anime provider for a specified range",
|
||||
short_help="Download anime",
|
||||
epilog=examples.download,
|
||||
)
|
||||
@click.option(
|
||||
"--anime_title",
|
||||
"-t",
|
||||
required=True,
|
||||
shell_complete=anime_titles_shell_complete,
|
||||
multiple=True,
|
||||
help="Specify which anime to download",
|
||||
)
|
||||
@click.option(
|
||||
"--episode-range",
|
||||
"-r",
|
||||
help="A range of episodes to download (start-end)",
|
||||
)
|
||||
@click.option(
|
||||
"--file",
|
||||
"-f",
|
||||
type=click.File(),
|
||||
help="A file to read from all anime to download",
|
||||
)
|
||||
@click.option(
|
||||
"--force-unknown-ext",
|
||||
"-F",
|
||||
help="This option forces yt-dlp to download extensions its not aware of",
|
||||
is_flag=True,
|
||||
)
|
||||
@click.option(
|
||||
"--silent/--no-silent",
|
||||
"-q/-V",
|
||||
type=bool,
|
||||
help="Download silently (during download)",
|
||||
default=True,
|
||||
)
|
||||
@click.option("--verbose", "-v", is_flag=True, help="Download verbosely (everywhere)")
|
||||
@click.option(
|
||||
"--merge", "-m", is_flag=True, help="Merge the subfile with video using ffmpeg"
|
||||
)
|
||||
@click.option(
|
||||
"--clean",
|
||||
"-c",
|
||||
is_flag=True,
|
||||
help="After merging delete the original files",
|
||||
)
|
||||
@click.option(
|
||||
"--prompt/--no-prompt",
|
||||
help="Whether to prompt for anything instead just do the best thing",
|
||||
default=True,
|
||||
)
|
||||
@click.option(
|
||||
"--force-ffmpeg",
|
||||
is_flag=True,
|
||||
help="Force the use of FFmpeg for downloading (supports large variety of streams but slower)",
|
||||
)
|
||||
@click.option(
|
||||
"--hls-use-mpegts",
|
||||
is_flag=True,
|
||||
help="Use mpegts for hls streams, resulted in .ts file (useful for some streams: see Docs) (this option forces --force-ffmpeg to be True)",
|
||||
)
|
||||
@click.option(
|
||||
"--hls-use-h264",
|
||||
is_flag=True,
|
||||
help="Use H.264 (MP4) for hls streams, resulted in .mp4 file (useful for some streams: see Docs) (this option forces --force-ffmpeg to be True)",
|
||||
)
|
||||
@click.pass_obj
|
||||
def download(config: AppConfig, **options: "Unpack[Options]"):
|
||||
from viu.cli.service.feedback.service import FeedbackService
|
||||
|
||||
from ...core.exceptions import ViuError
|
||||
from ...libs.provider.anime.params import (
|
||||
AnimeParams,
|
||||
SearchParams,
|
||||
)
|
||||
from ...libs.provider.anime.provider import create_provider
|
||||
from ...libs.selectors.selector import create_selector
|
||||
|
||||
feedback = FeedbackService(config)
|
||||
provider = create_provider(config.general.provider)
|
||||
selector = create_selector(config)
|
||||
|
||||
anime_titles = options["anime_title"]
|
||||
feedback.info(f"[green bold]Streaming:[/] {anime_titles}")
|
||||
for anime_title in anime_titles:
|
||||
# ---- search for anime ----
|
||||
feedback.info(f"[green bold]Searching for:[/] {anime_title}")
|
||||
with feedback.progress(f"Fetching anime search results for {anime_title}"):
|
||||
search_results = provider.search(
|
||||
SearchParams(
|
||||
query=anime_title, translation_type=config.stream.translation_type
|
||||
)
|
||||
)
|
||||
if not search_results:
|
||||
raise ViuError("No results were found matching your query")
|
||||
|
||||
_search_results = {
|
||||
search_result.title: search_result
|
||||
for search_result in search_results.results
|
||||
}
|
||||
|
||||
selected_anime_title = selector.choose(
|
||||
"Select Anime", list(_search_results.keys())
|
||||
)
|
||||
if not selected_anime_title:
|
||||
raise ViuError("No title selected")
|
||||
anime_result = _search_results[selected_anime_title]
|
||||
|
||||
# ---- fetch selected anime ----
|
||||
with feedback.progress(f"Fetching {anime_result.title}"):
|
||||
anime = provider.get(AnimeParams(id=anime_result.id, query=anime_title))
|
||||
|
||||
if not anime:
|
||||
raise ViuError(f"Failed to fetch anime {anime_result.title}")
|
||||
|
||||
available_episodes: list[str] = sorted(
|
||||
getattr(anime.episodes, config.stream.translation_type), key=float
|
||||
)
|
||||
|
||||
if options["episode_range"]:
|
||||
from ..utils.parser import parse_episode_range
|
||||
|
||||
try:
|
||||
episodes_range = parse_episode_range(
|
||||
options["episode_range"], available_episodes
|
||||
)
|
||||
|
||||
for episode in episodes_range:
|
||||
download_anime(
|
||||
config,
|
||||
options,
|
||||
provider,
|
||||
selector,
|
||||
feedback,
|
||||
anime,
|
||||
anime_title,
|
||||
episode,
|
||||
)
|
||||
except (ValueError, IndexError) as e:
|
||||
raise ViuError(f"Invalid episode range: {e}") from e
|
||||
else:
|
||||
episode = selector.choose(
|
||||
"Select Episode",
|
||||
getattr(anime.episodes, config.stream.translation_type),
|
||||
)
|
||||
if not episode:
|
||||
raise ViuError("No episode selected")
|
||||
download_anime(
|
||||
config,
|
||||
options,
|
||||
provider,
|
||||
selector,
|
||||
feedback,
|
||||
anime,
|
||||
anime_title,
|
||||
episode,
|
||||
)
|
||||
|
||||
|
||||
def download_anime(
|
||||
config: AppConfig,
|
||||
download_options: "Options",
|
||||
provider: "BaseAnimeProvider",
|
||||
selector: "BaseSelector",
|
||||
feedback: "FeedbackService",
|
||||
anime: "Anime",
|
||||
anime_title: str,
|
||||
episode: str,
|
||||
):
|
||||
from ...core.downloader import DownloadParams, create_downloader
|
||||
from ...libs.provider.anime.params import EpisodeStreamsParams
|
||||
|
||||
downloader = create_downloader(config.downloads)
|
||||
|
||||
with feedback.progress("Fetching episode streams"):
|
||||
streams = provider.episode_streams(
|
||||
EpisodeStreamsParams(
|
||||
anime_id=anime.id,
|
||||
query=anime_title,
|
||||
episode=episode,
|
||||
translation_type=config.stream.translation_type,
|
||||
)
|
||||
)
|
||||
if not streams:
|
||||
raise ViuError(
|
||||
f"Failed to get streams for anime: {anime.title}, episode: {episode}"
|
||||
)
|
||||
|
||||
if config.stream.server.value == "TOP":
|
||||
with feedback.progress("Fetching top server"):
|
||||
server = next(streams, None)
|
||||
if not server:
|
||||
raise ViuError(
|
||||
f"Failed to get server for anime: {anime.title}, episode: {episode}"
|
||||
)
|
||||
else:
|
||||
with feedback.progress("Fetching servers"):
|
||||
servers = {server.name: server for server in streams}
|
||||
servers_names = list(servers.keys())
|
||||
if config.stream.server in servers_names:
|
||||
server = servers[config.stream.server.value]
|
||||
else:
|
||||
server_name = selector.choose("Select Server", servers_names)
|
||||
if not server_name:
|
||||
raise ViuError("Server not selected")
|
||||
server = servers[server_name]
|
||||
stream_link = server.links[0].link
|
||||
if not stream_link:
|
||||
raise ViuError(
|
||||
f"Failed to get stream link for anime: {anime.title}, episode: {episode}"
|
||||
)
|
||||
feedback.info(f"[green bold]Now Downloading:[/] {anime.title} Episode: {episode}")
|
||||
downloader.download(
|
||||
DownloadParams(
|
||||
url=stream_link,
|
||||
anime_title=anime.title,
|
||||
episode_title=f"{anime.title}; Episode {episode}",
|
||||
subtitles=[sub.url for sub in server.subtitles],
|
||||
headers=server.headers,
|
||||
vid_format=config.downloads.ytdlp_format,
|
||||
force_unknown_ext=download_options["force_unknown_ext"],
|
||||
verbose=download_options["verbose"],
|
||||
hls_use_mpegts=download_options["hls_use_mpegts"],
|
||||
hls_use_h264=download_options["hls_use_h264"],
|
||||
silent=download_options["silent"],
|
||||
no_check_certificate=config.downloads.no_check_certificate,
|
||||
)
|
||||
)
|
||||
70
viu/cli/commands/examples.py
Normal file
70
viu/cli/commands/examples.py
Normal file
@@ -0,0 +1,70 @@
|
||||
download = """
|
||||
\b
|
||||
\b\bExamples:
|
||||
# Download all available episodes
|
||||
# multiple titles can be specified with -t option
|
||||
viu download -t <anime-title> -t <anime-title>
|
||||
# -- or --
|
||||
viu download -t <anime-title> -t <anime-title> -r ':'
|
||||
\b
|
||||
# download latest episode for the two anime titles
|
||||
# the number can be any no of latest episodes but a minus sign
|
||||
# must be present
|
||||
viu download -t <anime-title> -t <anime-title> -r '-1'
|
||||
\b
|
||||
# latest 5
|
||||
viu download -t <anime-title> -t <anime-title> -r '-5'
|
||||
\b
|
||||
# Download specific episode range
|
||||
# be sure to observe the range Syntax
|
||||
viu download -t <anime-title> -r '<episodes-start>:<episodes-end>:<step>'
|
||||
\b
|
||||
viu download -t <anime-title> -r '<episodes-start>:<episodes-end>'
|
||||
\b
|
||||
viu download -t <anime-title> -r '<episodes-start>:'
|
||||
\b
|
||||
viu download -t <anime-title> -r ':<episodes-end>'
|
||||
\b
|
||||
# download specific episode
|
||||
# remember python indexing starts at 0
|
||||
viu download -t <anime-title> -r '<episode-1>:<episode>'
|
||||
\b
|
||||
# merge subtitles with ffmpeg to mkv format; hianime tends to give subs as separate files
|
||||
# and dont prompt for anything
|
||||
# eg existing file in destination instead remove
|
||||
# and clean
|
||||
# ie remove original files (sub file and vid file)
|
||||
# only keep merged files
|
||||
viu download -t <anime-title> --merge --clean --no-prompt
|
||||
\b
|
||||
# EOF is used since -t always expects a title
|
||||
# you can supply anime titles from file or -t at the same time
|
||||
# from stdin
|
||||
echo -e "<anime-title>\\n<anime-title>\\n<anime-title>" | viu download -t "EOF" -r <range> -f -
|
||||
\b
|
||||
# from file
|
||||
viu download -t "EOF" -r <range> -f <file-path>
|
||||
"""
|
||||
search = """
|
||||
\b
|
||||
\b\bExamples:
|
||||
# basic form where you will still be prompted for the episode number
|
||||
# multiple titles can be specified with the -t option
|
||||
viu search -t <anime-title> -t <anime-title>
|
||||
\b
|
||||
# binge all episodes with this command
|
||||
viu search -t <anime-title> -r ':'
|
||||
\b
|
||||
# watch latest episode
|
||||
viu search -t <anime-title> -r '-1'
|
||||
\b
|
||||
# binge a specific episode range with this command
|
||||
# be sure to observe the range Syntax
|
||||
viu search -t <anime-title> -r '<start>:<stop>'
|
||||
\b
|
||||
viu search -t <anime-title> -r '<start>:<stop>:<step>'
|
||||
\b
|
||||
viu search -t <anime-title> -r '<start>:'
|
||||
\b
|
||||
viu search -t <anime-title> -r ':<end>'
|
||||
"""
|
||||
218
viu/cli/commands/queue.py
Normal file
218
viu/cli/commands/queue.py
Normal file
@@ -0,0 +1,218 @@
|
||||
import click
|
||||
from viu.core.config import AppConfig
|
||||
from viu.core.exceptions import ViuError
|
||||
from viu.libs.media_api.types import (
|
||||
MediaFormat,
|
||||
MediaGenre,
|
||||
MediaItem,
|
||||
MediaSeason,
|
||||
MediaSort,
|
||||
MediaStatus,
|
||||
MediaTag,
|
||||
MediaType,
|
||||
MediaYear,
|
||||
)
|
||||
|
||||
|
||||
@click.command(help="Queue episodes for the background worker to download.")
|
||||
# Search/Filter options (mirrors 'viu anilist download')
|
||||
@click.option("--title", "-t")
|
||||
@click.option("--page", "-p", type=click.IntRange(min=1), default=1)
|
||||
@click.option("--per-page", type=click.IntRange(min=1, max=50))
|
||||
@click.option("--season", type=click.Choice([s.value for s in MediaSeason]))
|
||||
@click.option(
|
||||
"--status", "-S", multiple=True, type=click.Choice([s.value for s in MediaStatus])
|
||||
)
|
||||
@click.option(
|
||||
"--status-not", multiple=True, type=click.Choice([s.value for s in MediaStatus])
|
||||
)
|
||||
@click.option("--sort", "-s", type=click.Choice([s.value for s in MediaSort]))
|
||||
@click.option(
|
||||
"--genres", "-g", multiple=True, type=click.Choice([g.value for g in MediaGenre])
|
||||
)
|
||||
@click.option(
|
||||
"--genres-not", multiple=True, type=click.Choice([g.value for g in MediaGenre])
|
||||
)
|
||||
@click.option("--tags", "-T", multiple=True, type=click.Choice([t.value for t in MediaTag]))
|
||||
@click.option("--tags-not", multiple=True, type=click.Choice([t.value for t in MediaTag]))
|
||||
@click.option(
|
||||
"--media-format",
|
||||
"-f",
|
||||
multiple=True,
|
||||
type=click.Choice([f.value for f in MediaFormat]),
|
||||
)
|
||||
@click.option("--media-type", type=click.Choice([t.value for t in MediaType]))
|
||||
@click.option("--year", "-y", type=click.Choice([y.value for y in MediaYear]))
|
||||
@click.option("--popularity-greater", type=click.IntRange(min=0))
|
||||
@click.option("--popularity-lesser", type=click.IntRange(min=0))
|
||||
@click.option("--score-greater", type=click.IntRange(min=0, max=100))
|
||||
@click.option("--score-lesser", type=click.IntRange(min=0, max=100))
|
||||
@click.option("--start-date-greater", type=int)
|
||||
@click.option("--start-date-lesser", type=int)
|
||||
@click.option("--end-date-greater", type=int)
|
||||
@click.option("--end-date-lesser", type=int)
|
||||
@click.option("--on-list/--not-on-list", "-L/-no-L", type=bool, default=None)
|
||||
# Queue-specific options
|
||||
@click.option(
|
||||
"--episode-range",
|
||||
"-r",
|
||||
required=True,
|
||||
help="Range of episodes to queue (e.g., '1-10', '5', '8:12').",
|
||||
)
|
||||
@click.option(
|
||||
"--yes",
|
||||
"-Y",
|
||||
is_flag=True,
|
||||
help="Automatically queue from all found anime without prompting for selection.",
|
||||
)
|
||||
@click.pass_obj
|
||||
def queue(config: AppConfig, **options):
|
||||
"""
|
||||
Search AniList with filters, select one or more anime (or use --yes),
|
||||
and queue the specified episode range for background download.
|
||||
The background worker should be running to process the queue.
|
||||
"""
|
||||
from viu.cli.service.download.service import DownloadService
|
||||
from viu.cli.service.feedback import FeedbackService
|
||||
from viu.cli.service.registry import MediaRegistryService
|
||||
from viu.cli.utils.parser import parse_episode_range
|
||||
from viu.libs.media_api.params import MediaSearchParams
|
||||
from viu.libs.media_api.api import create_api_client
|
||||
from viu.libs.provider.anime.provider import create_provider
|
||||
from viu.libs.selectors import create_selector
|
||||
from rich.progress import Progress
|
||||
|
||||
feedback = FeedbackService(config)
|
||||
selector = create_selector(config)
|
||||
media_api = create_api_client(config.general.media_api, config)
|
||||
provider = create_provider(config.general.provider)
|
||||
registry = MediaRegistryService(config.general.media_api, config.media_registry)
|
||||
download_service = DownloadService(config, registry, media_api, provider)
|
||||
|
||||
try:
|
||||
# Build search params mirroring anilist download
|
||||
sort_val = options.get("sort")
|
||||
status_val = options.get("status")
|
||||
status_not_val = options.get("status_not")
|
||||
genres_val = options.get("genres")
|
||||
genres_not_val = options.get("genres_not")
|
||||
tags_val = options.get("tags")
|
||||
tags_not_val = options.get("tags_not")
|
||||
media_format_val = options.get("media_format")
|
||||
media_type_val = options.get("media_type")
|
||||
season_val = options.get("season")
|
||||
year_val = options.get("year")
|
||||
|
||||
search_params = MediaSearchParams(
|
||||
query=options.get("title"),
|
||||
page=options.get("page", 1),
|
||||
per_page=options.get("per_page"),
|
||||
sort=MediaSort(sort_val) if sort_val else None,
|
||||
status_in=[MediaStatus(s) for s in status_val] if status_val else None,
|
||||
status_not_in=[MediaStatus(s) for s in status_not_val]
|
||||
if status_not_val
|
||||
else None,
|
||||
genre_in=[MediaGenre(g) for g in genres_val] if genres_val else None,
|
||||
genre_not_in=[MediaGenre(g) for g in genres_not_val]
|
||||
if genres_not_val
|
||||
else None,
|
||||
tag_in=[MediaTag(t) for t in tags_val] if tags_val else None,
|
||||
tag_not_in=[MediaTag(t) for t in tags_not_val] if tags_not_val else None,
|
||||
format_in=[MediaFormat(f) for f in media_format_val]
|
||||
if media_format_val
|
||||
else None,
|
||||
type=MediaType(media_type_val) if media_type_val else None,
|
||||
season=MediaSeason(season_val) if season_val else None,
|
||||
seasonYear=int(year_val) if year_val else None,
|
||||
popularity_greater=options.get("popularity_greater"),
|
||||
popularity_lesser=options.get("popularity_lesser"),
|
||||
averageScore_greater=options.get("score_greater"),
|
||||
averageScore_lesser=options.get("score_lesser"),
|
||||
startDate_greater=options.get("start_date_greater"),
|
||||
startDate_lesser=options.get("start_date_lesser"),
|
||||
endDate_greater=options.get("end_date_greater"),
|
||||
endDate_lesser=options.get("end_date_lesser"),
|
||||
on_list=options.get("on_list"),
|
||||
)
|
||||
|
||||
with Progress() as progress:
|
||||
progress.add_task("Searching AniList...", total=None)
|
||||
search_result = media_api.search_media(search_params)
|
||||
|
||||
if not search_result or not search_result.media:
|
||||
raise ViuError("No anime found matching your search criteria.")
|
||||
|
||||
if options.get("yes"):
|
||||
anime_to_queue = search_result.media
|
||||
else:
|
||||
choice_map: dict[str, MediaItem] = {
|
||||
(item.title.english or item.title.romaji or f"ID: {item.id}"): item
|
||||
for item in search_result.media
|
||||
}
|
||||
preview_command = None
|
||||
if config.general.preview != "none":
|
||||
from ..utils.preview import create_preview_context # type: ignore
|
||||
|
||||
with create_preview_context() as preview_ctx:
|
||||
preview_command = preview_ctx.get_anime_preview(
|
||||
list(choice_map.values()),
|
||||
list(choice_map.keys()),
|
||||
config,
|
||||
)
|
||||
selected_titles = selector.choose_multiple(
|
||||
"Select anime to queue",
|
||||
list(choice_map.keys()),
|
||||
preview=preview_command,
|
||||
)
|
||||
else:
|
||||
selected_titles = selector.choose_multiple(
|
||||
"Select anime to queue", list(choice_map.keys())
|
||||
)
|
||||
|
||||
if not selected_titles:
|
||||
feedback.warning("No anime selected. Nothing queued.")
|
||||
return
|
||||
anime_to_queue = [choice_map[title] for title in selected_titles]
|
||||
|
||||
episode_range_str = options.get("episode_range")
|
||||
total_queued = 0
|
||||
for media_item in anime_to_queue:
|
||||
available_episodes = [str(i + 1) for i in range(media_item.episodes or 0)]
|
||||
if not available_episodes:
|
||||
feedback.warning(
|
||||
f"No episode information for '{media_item.title.english}', skipping."
|
||||
)
|
||||
continue
|
||||
|
||||
try:
|
||||
episodes_to_queue = list(
|
||||
parse_episode_range(episode_range_str, available_episodes)
|
||||
)
|
||||
if not episodes_to_queue:
|
||||
feedback.warning(
|
||||
f"Episode range '{episode_range_str}' resulted in no episodes for '{media_item.title.english}'."
|
||||
)
|
||||
continue
|
||||
|
||||
queued_count = 0
|
||||
for ep in episodes_to_queue:
|
||||
if download_service.add_to_queue(media_item, ep):
|
||||
queued_count += 1
|
||||
|
||||
total_queued += queued_count
|
||||
feedback.success(
|
||||
f"Queued {queued_count} episodes for '{media_item.title.english}'."
|
||||
)
|
||||
except (ValueError, IndexError) as e:
|
||||
feedback.error(
|
||||
f"Invalid episode range for '{media_item.title.english}': {e}"
|
||||
)
|
||||
|
||||
feedback.success(
|
||||
f"Done. Total of {total_queued} episode(s) queued across all selections."
|
||||
)
|
||||
|
||||
except ViuError as e:
|
||||
feedback.error("Queue command failed", str(e))
|
||||
except Exception as e:
|
||||
feedback.error("An unexpected error occurred", str(e))
|
||||
3
viu/cli/commands/queue/__init__.py
Normal file
3
viu/cli/commands/queue/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .cmd import queue
|
||||
|
||||
__all__ = ["queue"]
|
||||
26
viu/cli/commands/queue/cmd.py
Normal file
26
viu/cli/commands/queue/cmd.py
Normal file
@@ -0,0 +1,26 @@
|
||||
import click
|
||||
|
||||
from ...utils.lazyloader import LazyGroup
|
||||
|
||||
commands = {
|
||||
"add": "add.add",
|
||||
"list": "list.list_cmd",
|
||||
"resume": "resume.resume",
|
||||
"clear": "clear.clear_cmd",
|
||||
}
|
||||
|
||||
|
||||
@click.group(
|
||||
cls=LazyGroup,
|
||||
name="queue",
|
||||
root="viu.cli.commands.queue.commands",
|
||||
invoke_without_command=False,
|
||||
help="Manage the download queue (add, list, resume, clear).",
|
||||
short_help="Manage the download queue.",
|
||||
lazy_subcommands=commands,
|
||||
)
|
||||
@click.pass_context
|
||||
def queue(ctx: click.Context):
|
||||
"""Queue management root command."""
|
||||
# No-op root; subcommands are lazy-loaded
|
||||
pass
|
||||
0
viu/cli/commands/queue/commands/__init__.py
Normal file
0
viu/cli/commands/queue/commands/__init__.py
Normal file
217
viu/cli/commands/queue/commands/add.py
Normal file
217
viu/cli/commands/queue/commands/add.py
Normal file
@@ -0,0 +1,217 @@
|
||||
import click
|
||||
from viu.core.config import AppConfig
|
||||
from viu.core.exceptions import ViuError
|
||||
from viu.libs.media_api.types import (
|
||||
MediaFormat,
|
||||
MediaGenre,
|
||||
MediaItem,
|
||||
MediaSeason,
|
||||
MediaSort,
|
||||
MediaStatus,
|
||||
MediaTag,
|
||||
MediaType,
|
||||
MediaYear,
|
||||
)
|
||||
|
||||
|
||||
@click.command(name="add", help="Add episodes to the background download queue.")
|
||||
@click.option("--title", "-t")
|
||||
@click.option("--page", "-p", type=click.IntRange(min=1), default=1)
|
||||
@click.option("--per-page", type=click.IntRange(min=1, max=50))
|
||||
@click.option("--season", type=click.Choice([s.value for s in MediaSeason]))
|
||||
@click.option(
|
||||
"--status", "-S", multiple=True, type=click.Choice([s.value for s in MediaStatus])
|
||||
)
|
||||
@click.option(
|
||||
"--status-not", multiple=True, type=click.Choice([s.value for s in MediaStatus])
|
||||
)
|
||||
@click.option("--sort", "-s", type=click.Choice([s.value for s in MediaSort]))
|
||||
@click.option(
|
||||
"--genres", "-g", multiple=True, type=click.Choice([g.value for g in MediaGenre])
|
||||
)
|
||||
@click.option(
|
||||
"--genres-not", multiple=True, type=click.Choice([g.value for g in MediaGenre])
|
||||
)
|
||||
@click.option(
|
||||
"--tags", "-T", multiple=True, type=click.Choice([t.value for t in MediaTag])
|
||||
)
|
||||
@click.option(
|
||||
"--tags-not", multiple=True, type=click.Choice([t.value for t in MediaTag])
|
||||
)
|
||||
@click.option(
|
||||
"--media-format",
|
||||
"-f",
|
||||
multiple=True,
|
||||
type=click.Choice([f.value for f in MediaFormat]),
|
||||
)
|
||||
@click.option("--media-type", type=click.Choice([t.value for t in MediaType]))
|
||||
@click.option("--year", "-y", type=click.Choice([y.value for y in MediaYear]))
|
||||
@click.option("--popularity-greater", type=click.IntRange(min=0))
|
||||
@click.option("--popularity-lesser", type=click.IntRange(min=0))
|
||||
@click.option("--score-greater", type=click.IntRange(min=0, max=100))
|
||||
@click.option("--score-lesser", type=click.IntRange(min=0, max=100))
|
||||
@click.option("--start-date-greater", type=int)
|
||||
@click.option("--start-date-lesser", type=int)
|
||||
@click.option("--end-date-greater", type=int)
|
||||
@click.option("--end-date-lesser", type=int)
|
||||
@click.option("--on-list/--not-on-list", "-L/-no-L", type=bool, default=None)
|
||||
# Queue-specific options
|
||||
@click.option(
|
||||
"--episode-range",
|
||||
"-r",
|
||||
required=True,
|
||||
help="Range of episodes to queue (e.g., '1-10', '5', '8:12').",
|
||||
)
|
||||
@click.option(
|
||||
"--yes",
|
||||
"-Y",
|
||||
is_flag=True,
|
||||
help="Queue for all found anime without prompting for selection.",
|
||||
)
|
||||
@click.pass_obj
|
||||
def add(config: AppConfig, **options):
|
||||
from viu.cli.service.download import DownloadService
|
||||
from viu.cli.service.feedback import FeedbackService
|
||||
from viu.cli.service.registry import MediaRegistryService
|
||||
from viu.cli.utils.parser import parse_episode_range
|
||||
from viu.libs.media_api.api import create_api_client
|
||||
from viu.libs.media_api.params import MediaSearchParams
|
||||
from viu.libs.provider.anime.provider import create_provider
|
||||
from viu.libs.selectors import create_selector
|
||||
from rich.progress import Progress
|
||||
|
||||
feedback = FeedbackService(config)
|
||||
selector = create_selector(config)
|
||||
media_api = create_api_client(config.general.media_api, config)
|
||||
provider = create_provider(config.general.provider)
|
||||
registry = MediaRegistryService(config.general.media_api, config.media_registry)
|
||||
download_service = DownloadService(config, registry, media_api, provider)
|
||||
|
||||
try:
|
||||
# Build search params mirroring anilist download
|
||||
sort_val = options.get("sort")
|
||||
status_val = options.get("status")
|
||||
status_not_val = options.get("status_not")
|
||||
genres_val = options.get("genres")
|
||||
genres_not_val = options.get("genres_not")
|
||||
tags_val = options.get("tags")
|
||||
tags_not_val = options.get("tags_not")
|
||||
media_format_val = options.get("media_format")
|
||||
media_type_val = options.get("media_type")
|
||||
season_val = options.get("season")
|
||||
year_val = options.get("year")
|
||||
|
||||
search_params = MediaSearchParams(
|
||||
query=options.get("title"),
|
||||
page=options.get("page", 1),
|
||||
per_page=options.get("per_page"),
|
||||
sort=MediaSort(sort_val) if sort_val else None,
|
||||
status_in=[MediaStatus(s) for s in status_val] if status_val else None,
|
||||
status_not_in=[MediaStatus(s) for s in status_not_val]
|
||||
if status_not_val
|
||||
else None,
|
||||
genre_in=[MediaGenre(g) for g in genres_val] if genres_val else None,
|
||||
genre_not_in=[MediaGenre(g) for g in genres_not_val]
|
||||
if genres_not_val
|
||||
else None,
|
||||
tag_in=[MediaTag(t) for t in tags_val] if tags_val else None,
|
||||
tag_not_in=[MediaTag(t) for t in tags_not_val] if tags_not_val else None,
|
||||
format_in=[MediaFormat(f) for f in media_format_val]
|
||||
if media_format_val
|
||||
else None,
|
||||
type=MediaType(media_type_val) if media_type_val else None,
|
||||
season=MediaSeason(season_val) if season_val else None,
|
||||
seasonYear=int(year_val) if year_val else None,
|
||||
popularity_greater=options.get("popularity_greater"),
|
||||
popularity_lesser=options.get("popularity_lesser"),
|
||||
averageScore_greater=options.get("score_greater"),
|
||||
averageScore_lesser=options.get("score_lesser"),
|
||||
startDate_greater=options.get("start_date_greater"),
|
||||
startDate_lesser=options.get("start_date_lesser"),
|
||||
endDate_greater=options.get("end_date_greater"),
|
||||
endDate_lesser=options.get("end_date_lesser"),
|
||||
on_list=options.get("on_list"),
|
||||
)
|
||||
|
||||
with Progress() as progress:
|
||||
progress.add_task("Searching AniList...", total=None)
|
||||
search_result = media_api.search_media(search_params)
|
||||
|
||||
if not search_result or not search_result.media:
|
||||
raise ViuError("No anime found matching your search criteria.")
|
||||
|
||||
if options.get("yes"):
|
||||
anime_to_queue = search_result.media
|
||||
else:
|
||||
choice_map: dict[str, MediaItem] = {
|
||||
(item.title.english or item.title.romaji or f"ID: {item.id}"): item
|
||||
for item in search_result.media
|
||||
}
|
||||
preview_command = None
|
||||
if config.general.preview != "none":
|
||||
from viu.cli.utils.preview import create_preview_context
|
||||
|
||||
with create_preview_context() as preview_ctx:
|
||||
preview_command = preview_ctx.get_anime_preview(
|
||||
list(choice_map.values()),
|
||||
list(choice_map.keys()),
|
||||
config,
|
||||
)
|
||||
selected_titles = selector.choose_multiple(
|
||||
"Select anime to queue",
|
||||
list(choice_map.keys()),
|
||||
preview=preview_command,
|
||||
)
|
||||
else:
|
||||
selected_titles = selector.choose_multiple(
|
||||
"Select anime to queue", list(choice_map.keys())
|
||||
)
|
||||
|
||||
if not selected_titles:
|
||||
feedback.warning("No anime selected. Nothing queued.")
|
||||
return
|
||||
anime_to_queue = [choice_map[title] for title in selected_titles]
|
||||
|
||||
episode_range_str = options.get("episode_range")
|
||||
total_queued = 0
|
||||
for media_item in anime_to_queue:
|
||||
# TODO: do a provider search here to determine episodes available maybe, or allow pasing of an episode list probably just change the format for parsing episodes
|
||||
available_episodes = [str(i + 1) for i in range(media_item.episodes or 0)]
|
||||
if not available_episodes:
|
||||
feedback.warning(
|
||||
f"No episode information for '{media_item.title.english}', skipping."
|
||||
)
|
||||
continue
|
||||
|
||||
try:
|
||||
episodes_to_queue = list(
|
||||
parse_episode_range(episode_range_str, available_episodes)
|
||||
)
|
||||
if not episodes_to_queue:
|
||||
feedback.warning(
|
||||
f"Episode range '{episode_range_str}' resulted in no episodes for '{media_item.title.english}'."
|
||||
)
|
||||
continue
|
||||
|
||||
queued_count = 0
|
||||
for ep in episodes_to_queue:
|
||||
if download_service.add_to_queue(media_item, ep):
|
||||
queued_count += 1
|
||||
|
||||
total_queued += queued_count
|
||||
feedback.success(
|
||||
f"Queued {queued_count} episodes for '{media_item.title.english}'."
|
||||
)
|
||||
except (ValueError, IndexError) as e:
|
||||
feedback.error(
|
||||
f"Invalid episode range for '{media_item.title.english}': {e}"
|
||||
)
|
||||
|
||||
feedback.success(
|
||||
f"Done. Total of {total_queued} episode(s) queued across all selections."
|
||||
)
|
||||
|
||||
except ViuError as e:
|
||||
feedback.error("Queue add failed", str(e))
|
||||
except Exception as e:
|
||||
feedback.error("An unexpected error occurred", str(e))
|
||||
30
viu/cli/commands/queue/commands/clear.py
Normal file
30
viu/cli/commands/queue/commands/clear.py
Normal file
@@ -0,0 +1,30 @@
|
||||
import click
|
||||
from viu.core.config import AppConfig
|
||||
|
||||
|
||||
@click.command(name="clear", help="Clear queued items from the registry (QUEUED -> NOT_DOWNLOADED).")
|
||||
@click.option("--force", is_flag=True, help="Do not prompt for confirmation.")
|
||||
@click.pass_obj
|
||||
def clear_cmd(config: AppConfig, force: bool):
|
||||
from viu.cli.service.feedback import FeedbackService
|
||||
from viu.cli.service.registry import MediaRegistryService
|
||||
from viu.cli.service.registry.models import DownloadStatus
|
||||
|
||||
feedback = FeedbackService(config)
|
||||
registry = MediaRegistryService(config.general.media_api, config.media_registry)
|
||||
|
||||
if not force and not click.confirm("This will clear all queued items. Continue?"):
|
||||
feedback.info("Aborted.")
|
||||
return
|
||||
|
||||
cleared = 0
|
||||
queued = registry.get_episodes_by_download_status(DownloadStatus.QUEUED)
|
||||
for media_id, ep in queued:
|
||||
ok = registry.update_episode_download_status(
|
||||
media_id=media_id,
|
||||
episode_number=ep,
|
||||
status=DownloadStatus.NOT_DOWNLOADED,
|
||||
)
|
||||
if ok:
|
||||
cleared += 1
|
||||
feedback.success(f"Cleared {cleared} queued episode(s).")
|
||||
60
viu/cli/commands/queue/commands/list.py
Normal file
60
viu/cli/commands/queue/commands/list.py
Normal file
@@ -0,0 +1,60 @@
|
||||
import click
|
||||
from viu.core.config import AppConfig
|
||||
|
||||
|
||||
@click.command(name="list", help="List items in the download queue and their statuses.")
|
||||
@click.option(
|
||||
"--status",
|
||||
type=click.Choice(["queued", "downloading", "completed", "failed", "paused"]),
|
||||
)
|
||||
@click.option("--detailed", is_flag=True)
|
||||
@click.pass_obj
|
||||
def list_cmd(config: AppConfig, status: str | None, detailed: bool | None):
|
||||
from viu.cli.service.feedback import FeedbackService
|
||||
from viu.cli.service.registry import MediaRegistryService
|
||||
from viu.cli.service.registry.models import DownloadStatus
|
||||
|
||||
feedback = FeedbackService(config)
|
||||
registry = MediaRegistryService(config.general.media_api, config.media_registry)
|
||||
|
||||
status_map = {
|
||||
"queued": DownloadStatus.QUEUED,
|
||||
"downloading": DownloadStatus.DOWNLOADING,
|
||||
"completed": DownloadStatus.COMPLETED,
|
||||
"failed": DownloadStatus.FAILED,
|
||||
"paused": DownloadStatus.PAUSED,
|
||||
}
|
||||
|
||||
# TODO: improve this by modifying the download_status function or create new function
|
||||
if detailed and status:
|
||||
target = status_map[status]
|
||||
episodes = registry.get_episodes_by_download_status(target)
|
||||
feedback.info(f"{len(episodes)} episode(s) with status {status}.")
|
||||
for media_id, ep in episodes:
|
||||
record = registry.get_media_record(media_id)
|
||||
if record:
|
||||
feedback.info(f"{record.media_item.title.english} episode {ep}")
|
||||
return
|
||||
|
||||
if status:
|
||||
target = status_map[status]
|
||||
episodes = registry.get_episodes_by_download_status(target)
|
||||
feedback.info(f"{len(episodes)} episode(s) with status {status}.")
|
||||
for media_id, ep in episodes:
|
||||
feedback.info(f"- media:{media_id} episode:{ep}")
|
||||
else:
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
|
||||
stats = registry.get_download_statistics()
|
||||
table = Table(title="Queue Status")
|
||||
table.add_column("Metric")
|
||||
table.add_column("Value")
|
||||
table.add_row("Queued", str(stats.get("queued", 0)))
|
||||
table.add_row("Downloading", str(stats.get("downloading", 0)))
|
||||
table.add_row("Completed", str(stats.get("downloaded", 0)))
|
||||
table.add_row("Failed", str(stats.get("failed", 0)))
|
||||
table.add_row("Paused", str(stats.get("paused", 0)))
|
||||
|
||||
console = Console()
|
||||
console.print(table)
|
||||
22
viu/cli/commands/queue/commands/resume.py
Normal file
22
viu/cli/commands/queue/commands/resume.py
Normal file
@@ -0,0 +1,22 @@
|
||||
import click
|
||||
from viu.core.config import AppConfig
|
||||
|
||||
|
||||
@click.command(name="resume", help="Submit any queued or in-progress downloads to the worker.")
|
||||
@click.pass_obj
|
||||
def resume(config: AppConfig):
|
||||
from viu.cli.service.download.service import DownloadService
|
||||
from viu.cli.service.feedback import FeedbackService
|
||||
from viu.cli.service.registry import MediaRegistryService
|
||||
from viu.libs.media_api.api import create_api_client
|
||||
from viu.libs.provider.anime.provider import create_provider
|
||||
|
||||
feedback = FeedbackService(config)
|
||||
media_api = create_api_client(config.general.media_api, config)
|
||||
provider = create_provider(config.general.provider)
|
||||
registry = MediaRegistryService(config.general.media_api, config.media_registry)
|
||||
download_service = DownloadService(config, registry, media_api, provider)
|
||||
|
||||
download_service.start()
|
||||
download_service.resume_unfinished_downloads()
|
||||
feedback.success("Submitted queued downloads to background worker.")
|
||||
3
viu/cli/commands/registry/__init__.py
Normal file
3
viu/cli/commands/registry/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .cmd import registry
|
||||
|
||||
__all__ = ["registry"]
|
||||
69
viu/cli/commands/registry/cmd.py
Normal file
69
viu/cli/commands/registry/cmd.py
Normal file
@@ -0,0 +1,69 @@
|
||||
import click
|
||||
|
||||
from ....core.config.model import AppConfig
|
||||
from ...utils.lazyloader import LazyGroup
|
||||
from . import examples
|
||||
|
||||
commands = {
|
||||
"sync": "sync.sync",
|
||||
"stats": "stats.stats",
|
||||
"search": "search.search",
|
||||
"export": "export.export",
|
||||
"import": "import_.import_",
|
||||
"clean": "clean.clean",
|
||||
"backup": "backup.backup",
|
||||
"restore": "restore.restore",
|
||||
}
|
||||
|
||||
|
||||
@click.group(
|
||||
cls=LazyGroup,
|
||||
name="registry",
|
||||
root="viu.cli.commands.registry.commands",
|
||||
invoke_without_command=True,
|
||||
help="Manage your local media registry - sync, search, backup and maintain your anime database",
|
||||
short_help="Local media registry management",
|
||||
lazy_subcommands=commands,
|
||||
epilog=examples.main,
|
||||
)
|
||||
@click.option(
|
||||
"--api",
|
||||
default="anilist",
|
||||
help="Media API to use (default: anilist)",
|
||||
type=click.Choice(["anilist"], case_sensitive=False),
|
||||
)
|
||||
@click.pass_context
|
||||
def registry(ctx: click.Context, api: str):
|
||||
"""
|
||||
The entry point for the 'registry' command. If no subcommand is invoked,
|
||||
it shows registry information and statistics.
|
||||
"""
|
||||
from ...service.feedback import FeedbackService
|
||||
from ...service.registry import MediaRegistryService
|
||||
|
||||
config: AppConfig = ctx.obj
|
||||
feedback = FeedbackService(config)
|
||||
|
||||
if ctx.invoked_subcommand is None:
|
||||
# Show registry overview and statistics
|
||||
try:
|
||||
registry_service = MediaRegistryService(api, config.media_registry)
|
||||
stats = registry_service.get_registry_stats()
|
||||
|
||||
feedback.info("Registry Overview", f"API: {api}")
|
||||
feedback.info("Total Media", f"{stats.get('total_media', 0)} entries")
|
||||
feedback.info(
|
||||
"Recently Updated",
|
||||
f"{stats.get('recently_updated', 0)} entries in last 7 days",
|
||||
)
|
||||
feedback.info("Storage Path", str(config.media_registry.media_dir))
|
||||
|
||||
# Show status breakdown if available
|
||||
status_breakdown = stats.get("status_breakdown", {})
|
||||
if status_breakdown:
|
||||
feedback.info("Status Breakdown:")
|
||||
for status, count in status_breakdown.items():
|
||||
feedback.info(f" {status}", f"{count} entries")
|
||||
|
||||
except Exception as e:
|
||||
feedback.error("Registry Error", f"Failed to load registry: {e}")
|
||||
1
viu/cli/commands/registry/commands/__init__.py
Normal file
1
viu/cli/commands/registry/commands/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Registry commands package
|
||||
258
viu/cli/commands/registry/commands/backup.py
Normal file
258
viu/cli/commands/registry/commands/backup.py
Normal file
@@ -0,0 +1,258 @@
|
||||
"""
|
||||
Registry backup command - create full backups of the registry
|
||||
"""
|
||||
|
||||
import json
|
||||
import tarfile
|
||||
from datetime import datetime
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import click
|
||||
|
||||
from .....core.config import AppConfig
|
||||
from ....service.feedback import FeedbackService
|
||||
from ....service.registry.service import MediaRegistryService
|
||||
|
||||
if TYPE_CHECKING:
|
||||
pass
|
||||
|
||||
|
||||
@click.command(help="Create a full backup of the registry")
|
||||
@click.option(
|
||||
"--output",
|
||||
"-o",
|
||||
type=click.Path(),
|
||||
help="Output backup file path (auto-generated if not specified)",
|
||||
)
|
||||
@click.option("--compress", "-c", is_flag=True, help="Compress the backup archive")
|
||||
@click.option("--include-cache", is_flag=True, help="Include cache files in backup")
|
||||
@click.option(
|
||||
"--format",
|
||||
"backup_format",
|
||||
type=click.Choice(["tar", "zip"], case_sensitive=False),
|
||||
default="tar",
|
||||
help="Backup archive format",
|
||||
)
|
||||
@click.option(
|
||||
"--api",
|
||||
default="anilist",
|
||||
type=click.Choice(["anilist"], case_sensitive=False),
|
||||
help="Media API registry to backup",
|
||||
)
|
||||
@click.pass_obj
|
||||
def backup(
|
||||
config: AppConfig,
|
||||
output: str | None,
|
||||
compress: bool,
|
||||
include_cache: bool,
|
||||
backup_format: str,
|
||||
api: str,
|
||||
):
|
||||
"""
|
||||
Create a complete backup of your media registry.
|
||||
|
||||
Includes all media records, index files, and optionally cache data.
|
||||
Backups can be compressed and are suitable for restoration.
|
||||
"""
|
||||
feedback = FeedbackService(config)
|
||||
|
||||
try:
|
||||
registry_service = MediaRegistryService(api, config.media_registry)
|
||||
|
||||
# Generate output filename if not specified
|
||||
if not output:
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
extension = (
|
||||
"tar.gz" if compress and backup_format == "tar" else backup_format
|
||||
)
|
||||
if backup_format == "zip":
|
||||
extension = "zip"
|
||||
output = f"viu_registry_backup_{api}_{timestamp}.{extension}"
|
||||
|
||||
output_path = Path(output)
|
||||
|
||||
# Get backup statistics before starting
|
||||
stats = registry_service.get_registry_stats()
|
||||
total_media = stats.get("total_media", 0)
|
||||
|
||||
feedback.info("Starting Backup", f"Backing up {total_media} media entries...")
|
||||
|
||||
# Create backup based on format
|
||||
if backup_format.lower() == "tar":
|
||||
_create_tar_backup(
|
||||
registry_service, output_path, compress, include_cache, feedback, api
|
||||
)
|
||||
elif backup_format.lower() == "zip":
|
||||
_create_zip_backup(
|
||||
registry_service, output_path, include_cache, feedback, api
|
||||
)
|
||||
|
||||
# Get final backup size
|
||||
backup_size = _format_file_size(output_path)
|
||||
|
||||
feedback.success(
|
||||
"Backup Complete", f"Registry backed up to {output_path} ({backup_size})"
|
||||
)
|
||||
|
||||
# Show backup contents summary
|
||||
_show_backup_summary(output_path, backup_format, feedback)
|
||||
|
||||
except Exception as e:
|
||||
feedback.error("Backup Error", f"Failed to create backup: {e}")
|
||||
raise click.Abort()
|
||||
|
||||
|
||||
def _create_tar_backup(
|
||||
registry_service: MediaRegistryService,
|
||||
output_path: Path,
|
||||
compress: bool,
|
||||
include_cache: bool,
|
||||
feedback: FeedbackService,
|
||||
api: str,
|
||||
):
|
||||
"""Create a tar-based backup."""
|
||||
mode = "w:gz" if compress else "w"
|
||||
|
||||
with tarfile.open(output_path, mode) as tar:
|
||||
# Add registry directory
|
||||
registry_dir = registry_service.config.media_dir / api
|
||||
if registry_dir.exists():
|
||||
tar.add(registry_dir, arcname=f"registry/{api}")
|
||||
feedback.info("Added to backup", f"Registry data ({api})")
|
||||
|
||||
# Add index directory
|
||||
index_dir = registry_service.config.index_dir
|
||||
if index_dir.exists():
|
||||
tar.add(index_dir, arcname="index")
|
||||
feedback.info("Added to backup", "Registry index")
|
||||
|
||||
# Add cache if requested
|
||||
if include_cache:
|
||||
cache_dir = registry_service.config.media_dir.parent / "cache"
|
||||
if cache_dir.exists():
|
||||
tar.add(cache_dir, arcname="cache")
|
||||
feedback.info("Added to backup", "Cache data")
|
||||
|
||||
# Add metadata file directly into the archive without creating a temp file
|
||||
try:
|
||||
metadata = _create_backup_metadata(registry_service, api, include_cache)
|
||||
metadata_bytes = json.dumps(metadata, indent=2, default=str).encode("utf-8")
|
||||
|
||||
tarinfo = tarfile.TarInfo(name="backup_metadata.json")
|
||||
tarinfo.size = len(metadata_bytes)
|
||||
tarinfo.mtime = int(datetime.now().timestamp())
|
||||
|
||||
with BytesIO(metadata_bytes) as bio:
|
||||
tar.addfile(tarinfo, bio)
|
||||
except Exception as e:
|
||||
feedback.warning("Metadata Error", f"Failed to add metadata: {e}")
|
||||
|
||||
|
||||
def _create_zip_backup(
|
||||
registry_service: MediaRegistryService,
|
||||
output_path: Path,
|
||||
include_cache: bool,
|
||||
feedback: FeedbackService,
|
||||
api: str,
|
||||
):
|
||||
"""Create a zip-based backup."""
|
||||
import zipfile
|
||||
|
||||
with zipfile.ZipFile(output_path, "w", zipfile.ZIP_DEFLATED) as zip_file:
|
||||
# Add registry directory
|
||||
registry_dir = registry_service.config.media_dir / api
|
||||
if registry_dir.exists():
|
||||
for file_path in registry_dir.rglob("*"):
|
||||
if file_path.is_file():
|
||||
arcname = f"registry/{api}/{file_path.relative_to(registry_dir)}"
|
||||
zip_file.write(file_path, arcname)
|
||||
feedback.info("Added to backup", f"Registry data ({api})")
|
||||
|
||||
# Add index directory
|
||||
index_dir = registry_service.config.index_dir
|
||||
if index_dir.exists():
|
||||
for file_path in index_dir.rglob("*"):
|
||||
if file_path.is_file():
|
||||
arcname = f"index/{file_path.relative_to(index_dir)}"
|
||||
zip_file.write(file_path, arcname)
|
||||
feedback.info("Added to backup", "Registry index")
|
||||
|
||||
# Add cache if requested
|
||||
if include_cache:
|
||||
cache_dir = registry_service.config.media_dir.parent / "cache"
|
||||
if cache_dir.exists():
|
||||
for file_path in cache_dir.rglob("*"):
|
||||
if file_path.is_file():
|
||||
arcname = f"cache/{file_path.relative_to(cache_dir)}"
|
||||
zip_file.write(file_path, arcname)
|
||||
feedback.info("Added to backup", "Cache data")
|
||||
|
||||
# Add metadata
|
||||
try:
|
||||
metadata = _create_backup_metadata(registry_service, api, include_cache)
|
||||
metadata_json = json.dumps(metadata, indent=2, default=str)
|
||||
zip_file.writestr("backup_metadata.json", metadata_json)
|
||||
except Exception as e:
|
||||
feedback.warning("Metadata Error", f"Failed to add metadata: {e}")
|
||||
|
||||
|
||||
def _create_backup_metadata(
|
||||
registry_service: MediaRegistryService, api: str, include_cache: bool
|
||||
) -> dict:
|
||||
"""Create backup metadata."""
|
||||
from .....core.constants import __version__
|
||||
|
||||
stats = registry_service.get_registry_stats()
|
||||
|
||||
return {
|
||||
"backup_timestamp": datetime.now().isoformat(),
|
||||
"viu_version": __version__,
|
||||
"registry_version": stats.get("version"),
|
||||
"api": api,
|
||||
"total_media": stats.get("total_media", 0),
|
||||
"include_cache": include_cache,
|
||||
"registry_stats": stats,
|
||||
"backup_type": "full",
|
||||
}
|
||||
|
||||
|
||||
def _show_backup_summary(
|
||||
backup_path: Path, format_type: str, feedback: FeedbackService
|
||||
):
|
||||
"""Show summary of backup contents."""
|
||||
try:
|
||||
if format_type.lower() == "tar":
|
||||
with tarfile.open(backup_path, "r:*") as tar:
|
||||
members = tar.getmembers()
|
||||
file_count = len([m for m in members if m.isfile()])
|
||||
dir_count = len([m for m in members if m.isdir()])
|
||||
else: # zip
|
||||
import zipfile
|
||||
|
||||
with zipfile.ZipFile(backup_path, "r") as zip_file:
|
||||
info_list = zip_file.infolist()
|
||||
file_count = len([info for info in info_list if not info.is_dir()])
|
||||
dir_count = len([info for info in info_list if info.is_dir()])
|
||||
|
||||
feedback.info("Backup Contents", f"{file_count} files, {dir_count} directories")
|
||||
|
||||
except Exception as e:
|
||||
feedback.warning("Summary Error", f"Could not analyze backup contents: {e}")
|
||||
|
||||
|
||||
def _format_file_size(file_path: Path) -> str:
|
||||
"""Format file size in human-readable format."""
|
||||
try:
|
||||
size_bytes: float = float(file_path.stat().st_size)
|
||||
if size_bytes == 0:
|
||||
return "0 B"
|
||||
size_name = ("B", "KB", "MB", "GB", "TB")
|
||||
i = 0
|
||||
while size_bytes >= 1024.0 and i < len(size_name) - 1:
|
||||
size_bytes /= 1024.0
|
||||
i += 1
|
||||
return f"{size_bytes:.1f} {size_name[i]}"
|
||||
except FileNotFoundError:
|
||||
return "Unknown size"
|
||||
285
viu/cli/commands/registry/commands/clean.py
Normal file
285
viu/cli/commands/registry/commands/clean.py
Normal file
@@ -0,0 +1,285 @@
|
||||
"""
|
||||
Registry clean command - clean up orphaned entries and invalid data
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import Dict, List
|
||||
|
||||
import click
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
|
||||
from .....core.config import AppConfig
|
||||
from ....service.feedback import FeedbackService
|
||||
from ....service.registry.service import MediaRegistryService
|
||||
|
||||
|
||||
@click.command(help="Clean up orphaned entries and invalid data from registry")
|
||||
@click.option(
|
||||
"--dry-run", is_flag=True, help="Show what would be cleaned without making changes"
|
||||
)
|
||||
@click.option(
|
||||
"--orphaned",
|
||||
is_flag=True,
|
||||
help="Remove orphaned media records (index entries without files)",
|
||||
)
|
||||
@click.option("--invalid", is_flag=True, help="Remove invalid or corrupted entries")
|
||||
@click.option("--duplicates", is_flag=True, help="Remove duplicate entries")
|
||||
@click.option(
|
||||
"--old-format", is_flag=True, help="Clean entries from old registry format versions"
|
||||
)
|
||||
@click.option(
|
||||
"--force", "-f", is_flag=True, help="Force cleanup without confirmation prompts"
|
||||
)
|
||||
@click.option(
|
||||
"--api",
|
||||
default="anilist",
|
||||
type=click.Choice(["anilist"], case_sensitive=False),
|
||||
help="Media API registry to clean",
|
||||
)
|
||||
@click.pass_obj
|
||||
def clean(
|
||||
config: AppConfig,
|
||||
dry_run: bool,
|
||||
orphaned: bool,
|
||||
invalid: bool,
|
||||
duplicates: bool,
|
||||
old_format: bool,
|
||||
force: bool,
|
||||
api: str,
|
||||
):
|
||||
"""
|
||||
Clean up your local media registry.
|
||||
|
||||
Can remove orphaned entries, invalid data, duplicates, and entries
|
||||
from old format versions. Use --dry-run to preview changes.
|
||||
"""
|
||||
feedback = FeedbackService(config)
|
||||
console = Console()
|
||||
|
||||
# Default to all cleanup types if none specified
|
||||
if not any([orphaned, invalid, duplicates, old_format]):
|
||||
orphaned = invalid = duplicates = old_format = True
|
||||
|
||||
try:
|
||||
registry_service = MediaRegistryService(api, config.media_registry)
|
||||
|
||||
cleanup_results: Dict[str, List] = {
|
||||
"orphaned": [],
|
||||
"invalid": [],
|
||||
"duplicates": [],
|
||||
"old_format": [],
|
||||
}
|
||||
|
||||
# Analyze registry for cleanup opportunities
|
||||
with feedback.progress("Analyzing registry..."):
|
||||
_analyze_registry(
|
||||
registry_service,
|
||||
cleanup_results,
|
||||
orphaned,
|
||||
invalid,
|
||||
duplicates,
|
||||
old_format,
|
||||
)
|
||||
|
||||
# Show cleanup summary
|
||||
_display_cleanup_summary(console, cleanup_results, config.general.icons)
|
||||
|
||||
total_items = sum(len(items) for items in cleanup_results.values())
|
||||
if total_items == 0:
|
||||
feedback.success(
|
||||
"Registry Clean", "No cleanup needed - registry is already clean!"
|
||||
)
|
||||
return
|
||||
|
||||
if not dry_run:
|
||||
if not force and not click.confirm(
|
||||
f"Clean up {total_items} items from registry?"
|
||||
):
|
||||
feedback.info("Cleanup Cancelled", "No changes were made")
|
||||
return
|
||||
|
||||
# Perform cleanup
|
||||
_perform_cleanup(registry_service, cleanup_results, feedback)
|
||||
else:
|
||||
feedback.info("Dry Run Complete", f"Would clean up {total_items} items")
|
||||
|
||||
except Exception as e:
|
||||
feedback.error("Cleanup Error", f"Failed to clean registry: {e}")
|
||||
raise click.Abort()
|
||||
|
||||
|
||||
def _analyze_registry(
|
||||
registry_service: MediaRegistryService,
|
||||
results: Dict[str, List],
|
||||
check_orphaned: bool,
|
||||
check_invalid: bool,
|
||||
check_duplicates: bool,
|
||||
check_old_format: bool,
|
||||
):
|
||||
"""Analyze registry for cleanup opportunities."""
|
||||
if check_orphaned:
|
||||
results["orphaned"] = _find_orphaned_entries(registry_service)
|
||||
if check_invalid:
|
||||
results["invalid"] = _find_invalid_entries(registry_service)
|
||||
if check_duplicates:
|
||||
results["duplicates"] = _find_duplicate_entries(registry_service)
|
||||
if check_old_format:
|
||||
results["old_format"] = _find_old_format_entries(registry_service)
|
||||
|
||||
|
||||
def _find_orphaned_entries(registry_service: MediaRegistryService) -> list:
|
||||
"""Find index entries that don't have corresponding media files."""
|
||||
orphaned = []
|
||||
index = registry_service._load_index()
|
||||
for entry_key, entry in index.media_index.items():
|
||||
media_file = registry_service._get_media_file_path(entry.media_id)
|
||||
if not media_file.exists():
|
||||
orphaned.append(
|
||||
{"id": entry.media_id, "key": entry_key, "reason": "Media file missing"}
|
||||
)
|
||||
return orphaned
|
||||
|
||||
|
||||
def _find_invalid_entries(registry_service: MediaRegistryService) -> list:
|
||||
"""Find invalid or corrupted entries."""
|
||||
invalid = []
|
||||
for media_file in registry_service.media_registry_dir.glob("*.json"):
|
||||
try:
|
||||
media_id = int(media_file.stem)
|
||||
record = registry_service.get_media_record(media_id)
|
||||
if (
|
||||
not record
|
||||
or not record.media_item
|
||||
or not record.media_item.title.english
|
||||
and not record.media_item.title.romaji
|
||||
):
|
||||
invalid.append(
|
||||
{
|
||||
"id": media_id,
|
||||
"file": media_file,
|
||||
"reason": "Invalid record structure or missing title",
|
||||
}
|
||||
)
|
||||
except (ValueError, json.JSONDecodeError) as e:
|
||||
invalid.append(
|
||||
{
|
||||
"id": media_file.stem,
|
||||
"file": media_file,
|
||||
"reason": f"File corruption: {e}",
|
||||
}
|
||||
)
|
||||
return invalid
|
||||
|
||||
|
||||
def _find_duplicate_entries(registry_service: MediaRegistryService) -> list:
|
||||
"""Find duplicate entries (same media ID appearing multiple times)."""
|
||||
duplicates = []
|
||||
seen_ids = set()
|
||||
index = registry_service._load_index()
|
||||
for entry_key, entry in index.media_index.items():
|
||||
if entry.media_id in seen_ids:
|
||||
duplicates.append(
|
||||
{
|
||||
"id": entry.media_id,
|
||||
"key": entry_key,
|
||||
"reason": "Duplicate media ID in index",
|
||||
}
|
||||
)
|
||||
else:
|
||||
seen_ids.add(entry.media_id)
|
||||
return duplicates
|
||||
|
||||
|
||||
def _find_old_format_entries(registry_service: MediaRegistryService) -> list:
|
||||
"""Find entries from old registry format versions."""
|
||||
from ....service.registry.service import REGISTRY_VERSION
|
||||
|
||||
old_format = []
|
||||
index = registry_service._load_index()
|
||||
if index.version != REGISTRY_VERSION:
|
||||
old_format.append(
|
||||
{
|
||||
"id": "index",
|
||||
"file": registry_service._index_file,
|
||||
"reason": f"Index version mismatch ({index.version})",
|
||||
}
|
||||
)
|
||||
return old_format
|
||||
|
||||
|
||||
def _display_cleanup_summary(console: Console, results: Dict[str, List], icons: bool):
|
||||
"""Display summary of cleanup opportunities."""
|
||||
table = Table(title=f"{'🧹 ' if icons else ''}Registry Cleanup Summary")
|
||||
table.add_column("Category", style="cyan", no_wrap=True)
|
||||
table.add_column("Count", style="magenta", justify="right")
|
||||
table.add_column("Description", style="white")
|
||||
|
||||
categories = {
|
||||
"orphaned": "Orphaned Entries",
|
||||
"invalid": "Invalid/Corrupt Entries",
|
||||
"duplicates": "Duplicate Entries",
|
||||
"old_format": "Outdated Format",
|
||||
}
|
||||
for category, display_name in categories.items():
|
||||
count = len(results[category])
|
||||
description = "None found"
|
||||
if count > 0:
|
||||
reasons = {item["reason"] for item in results[category][:3]}
|
||||
description = "; ".join(list(reasons)[:2])
|
||||
if len(reasons) > 2:
|
||||
description += "..."
|
||||
table.add_row(display_name, str(count), description)
|
||||
console.print(table)
|
||||
console.print()
|
||||
|
||||
|
||||
def _perform_cleanup(
|
||||
registry_service: MediaRegistryService,
|
||||
results: Dict[str, List],
|
||||
feedback: FeedbackService,
|
||||
):
|
||||
"""Perform the actual cleanup operations."""
|
||||
cleaned_count = 0
|
||||
total_to_clean = sum(len(v) for v in results.values())
|
||||
|
||||
with feedback.progress("Cleaning registry...", total=total_to_clean) as (
|
||||
task_id,
|
||||
progress,
|
||||
):
|
||||
|
||||
def _cleanup_item(item_list, cleanup_func):
|
||||
nonlocal cleaned_count
|
||||
for item in item_list:
|
||||
try:
|
||||
cleanup_func(item)
|
||||
cleaned_count += 1
|
||||
except Exception as e:
|
||||
feedback.warning(
|
||||
"Cleanup Error",
|
||||
f"Failed to clean item {item.get('id', 'N/A')}: {e}",
|
||||
)
|
||||
progress.advance(task_id) # type: ignore
|
||||
|
||||
index = registry_service._load_index()
|
||||
|
||||
_cleanup_item(
|
||||
results["orphaned"], lambda item: index.media_index.pop(item["key"], None)
|
||||
)
|
||||
_cleanup_item(results["invalid"], lambda item: item["file"].unlink())
|
||||
_cleanup_item(
|
||||
results["duplicates"], lambda item: index.media_index.pop(item["key"], None)
|
||||
)
|
||||
|
||||
from ....service.registry.service import REGISTRY_VERSION
|
||||
|
||||
# For old format, we just re-save the index to update its version
|
||||
if results["old_format"]:
|
||||
index.version = REGISTRY_VERSION
|
||||
progress.advance(task_id, len(results["old_format"])) # type:ignore
|
||||
|
||||
registry_service._save_index(index)
|
||||
feedback.success(
|
||||
"Cleanup Complete",
|
||||
f"Successfully cleaned {cleaned_count} items from the registry.",
|
||||
)
|
||||
284
viu/cli/commands/registry/commands/export.py
Normal file
284
viu/cli/commands/registry/commands/export.py
Normal file
@@ -0,0 +1,284 @@
|
||||
"""
|
||||
Registry export command - export registry data to various formats
|
||||
"""
|
||||
|
||||
import csv
|
||||
import json
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import click
|
||||
|
||||
from .....core.config import AppConfig
|
||||
from ....service.feedback import FeedbackService
|
||||
from ....service.registry.service import MediaRegistryService
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ....service.registry.models import MediaRecord
|
||||
|
||||
|
||||
@click.command(help="Export registry data to various formats")
|
||||
@click.option(
|
||||
"--format",
|
||||
"output_format",
|
||||
type=click.Choice(["json", "csv", "xml"], case_sensitive=False),
|
||||
default="json",
|
||||
help="Export format",
|
||||
)
|
||||
@click.option(
|
||||
"--output",
|
||||
"-o",
|
||||
type=click.Path(path_type=Path),
|
||||
help="Output file path (auto-generated if not specified)",
|
||||
)
|
||||
@click.option(
|
||||
"--include-metadata", is_flag=True, help="Include detailed media metadata in export"
|
||||
)
|
||||
@click.option(
|
||||
"--status",
|
||||
multiple=True,
|
||||
type=click.Choice(
|
||||
["watching", "completed", "planning", "dropped", "paused", "repeating"],
|
||||
case_sensitive=False,
|
||||
),
|
||||
help="Only export specific status lists",
|
||||
)
|
||||
@click.option("--compress", is_flag=True, help="Compress the output file")
|
||||
@click.option(
|
||||
"--api",
|
||||
default="anilist",
|
||||
type=click.Choice(["anilist"], case_sensitive=False),
|
||||
help="Media API registry to export",
|
||||
)
|
||||
@click.pass_obj
|
||||
def export(
|
||||
config: AppConfig,
|
||||
output_format: str,
|
||||
output: Path | None,
|
||||
include_metadata: bool,
|
||||
status: tuple[str, ...],
|
||||
compress: bool,
|
||||
api: str,
|
||||
):
|
||||
"""
|
||||
Export your local media registry to various formats.
|
||||
|
||||
Supports JSON, CSV, and XML formats. Can optionally include
|
||||
detailed metadata and compress the output.
|
||||
"""
|
||||
feedback = FeedbackService(config)
|
||||
|
||||
try:
|
||||
registry_service = MediaRegistryService(api, config.media_registry)
|
||||
|
||||
# Generate output filename if not specified
|
||||
if not output:
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
extension = output_format.lower()
|
||||
if compress:
|
||||
extension += ".gz"
|
||||
output_path = Path(f"viu_registry_{api}_{timestamp}.{extension}")
|
||||
else:
|
||||
output_path = output
|
||||
|
||||
# Get export data
|
||||
export_data = _prepare_export_data(registry_service, include_metadata, status)
|
||||
|
||||
if not export_data["media"]:
|
||||
feedback.warning(
|
||||
"No Data", "No media entries to export based on your criteria."
|
||||
)
|
||||
return
|
||||
|
||||
# Export based on format
|
||||
if output_format.lower() == "json":
|
||||
_export_json(export_data, output_path)
|
||||
elif output_format.lower() == "csv":
|
||||
_export_csv(export_data, output_path)
|
||||
elif output_format.lower() == "xml":
|
||||
_export_xml(export_data, output_path)
|
||||
|
||||
if compress:
|
||||
_compress_file(output_path, feedback)
|
||||
output_path = output_path.with_suffix(output_path.suffix + ".gz")
|
||||
|
||||
feedback.success(
|
||||
"Export Complete",
|
||||
f"Registry exported to {output_path} ({_format_file_size(output_path)})",
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
feedback.error("Export Error", f"Failed to export registry: {e}")
|
||||
raise click.Abort()
|
||||
|
||||
|
||||
def _prepare_export_data(
|
||||
registry_service: MediaRegistryService,
|
||||
include_metadata: bool,
|
||||
status_filter: tuple[str, ...],
|
||||
) -> dict:
|
||||
"""Prepare data for export based on options."""
|
||||
from .....libs.media_api.types import UserMediaListStatus
|
||||
|
||||
status_map = {
|
||||
"watching": UserMediaListStatus.WATCHING,
|
||||
"completed": UserMediaListStatus.COMPLETED,
|
||||
"planning": UserMediaListStatus.PLANNING,
|
||||
"dropped": UserMediaListStatus.DROPPED,
|
||||
"paused": UserMediaListStatus.PAUSED,
|
||||
"repeating": UserMediaListStatus.REPEATING,
|
||||
}
|
||||
status_enums = {status_map[s] for s in status_filter}
|
||||
|
||||
export_data = {
|
||||
"metadata": {
|
||||
"export_timestamp": datetime.now().isoformat(),
|
||||
"registry_version": registry_service._load_index().version,
|
||||
"include_metadata": include_metadata,
|
||||
"filtered_status": list(status_filter) if status_filter else "all",
|
||||
},
|
||||
"statistics": registry_service.get_registry_stats(),
|
||||
"media": [],
|
||||
}
|
||||
|
||||
all_records = registry_service.get_all_media_records()
|
||||
|
||||
for record in all_records:
|
||||
index_entry = registry_service.get_media_index_entry(record.media_item.id)
|
||||
|
||||
if status_enums and (not index_entry or index_entry.status not in status_enums):
|
||||
continue
|
||||
|
||||
media_data = _flatten_record_for_export(record, index_entry, include_metadata)
|
||||
export_data["media"].append(media_data)
|
||||
|
||||
return export_data
|
||||
|
||||
|
||||
def _flatten_record_for_export(
|
||||
record: "MediaRecord", index_entry, include_metadata: bool
|
||||
) -> dict:
|
||||
"""Helper to convert a MediaRecord into a flat dictionary for exporting."""
|
||||
media_item = record.media_item
|
||||
|
||||
data = {
|
||||
"id": media_item.id,
|
||||
"title_english": media_item.title.english,
|
||||
"title_romaji": media_item.title.romaji,
|
||||
"title_native": media_item.title.native,
|
||||
"user_status": index_entry.status.value
|
||||
if index_entry and index_entry.status
|
||||
else None,
|
||||
"user_progress": index_entry.progress if index_entry else None,
|
||||
"user_score": index_entry.score if index_entry else None,
|
||||
"user_last_watched": index_entry.last_watched.isoformat()
|
||||
if index_entry and index_entry.last_watched
|
||||
else None,
|
||||
"user_notes": index_entry.notes if index_entry else None,
|
||||
}
|
||||
|
||||
if include_metadata:
|
||||
data.update(
|
||||
{
|
||||
"format": media_item.format.value if media_item.format else None,
|
||||
"episodes": media_item.episodes,
|
||||
"duration_minutes": media_item.duration,
|
||||
"media_status": media_item.status.value if media_item.status else None,
|
||||
"start_date": media_item.start_date.isoformat()
|
||||
if media_item.start_date
|
||||
else None,
|
||||
"end_date": media_item.end_date.isoformat()
|
||||
if media_item.end_date
|
||||
else None,
|
||||
"average_score": media_item.average_score,
|
||||
"popularity": media_item.popularity,
|
||||
"genres": ", ".join([genre.value for genre in media_item.genres]),
|
||||
"tags": ", ".join([tag.name.value for tag in media_item.tags]),
|
||||
"studios": ", ".join(
|
||||
[studio.name for studio in media_item.studios if studio.name]
|
||||
),
|
||||
"description": media_item.description,
|
||||
"cover_image_large": media_item.cover_image.large
|
||||
if media_item.cover_image
|
||||
else None,
|
||||
}
|
||||
)
|
||||
return data
|
||||
|
||||
|
||||
def _export_json(data: dict, output_path: Path):
|
||||
"""Export data to JSON format."""
|
||||
with open(output_path, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||
|
||||
|
||||
def _export_csv(data: dict, output_path: Path):
|
||||
"""Export data to CSV format."""
|
||||
if not data["media"]:
|
||||
return
|
||||
|
||||
fieldnames = list(data["media"][0].keys())
|
||||
|
||||
with open(output_path, "w", encoding="utf-8", newline="") as f:
|
||||
writer = csv.DictWriter(f, fieldnames=fieldnames)
|
||||
writer.writeheader()
|
||||
writer.writerows(data["media"])
|
||||
|
||||
|
||||
def _export_xml(data: dict, output_path: Path):
|
||||
"""Export data to XML format."""
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
root = ET.Element("viu_registry")
|
||||
|
||||
# Add metadata
|
||||
metadata_elem = ET.SubElement(root, "metadata")
|
||||
for key, value in data["metadata"].items():
|
||||
if value is not None:
|
||||
elem = ET.SubElement(metadata_elem, key)
|
||||
elem.text = str(value)
|
||||
|
||||
# Add media
|
||||
media_list_elem = ET.SubElement(root, "media_list")
|
||||
for media in data["media"]:
|
||||
media_elem = ET.SubElement(media_list_elem, "media")
|
||||
for key, value in media.items():
|
||||
if value is not None:
|
||||
field_elem = ET.SubElement(media_elem, key)
|
||||
field_elem.text = str(value)
|
||||
|
||||
# Write XML
|
||||
tree = ET.ElementTree(root)
|
||||
ET.indent(tree, space=" ", level=0) # Pretty print
|
||||
tree.write(output_path, encoding="utf-8", xml_declaration=True)
|
||||
|
||||
|
||||
def _compress_file(file_path: Path, feedback: FeedbackService):
|
||||
"""Compresses a file using gzip and removes the original."""
|
||||
import gzip
|
||||
import shutil
|
||||
|
||||
compressed_path = file_path.with_suffix(file_path.suffix + ".gz")
|
||||
try:
|
||||
with open(file_path, "rb") as f_in:
|
||||
with gzip.open(compressed_path, "wb") as f_out:
|
||||
shutil.copyfileobj(f_in, f_out)
|
||||
file_path.unlink() # Remove original file
|
||||
except Exception as e:
|
||||
feedback.warning("Compression Failed", f"Could not compress {file_path}: {e}")
|
||||
|
||||
|
||||
def _format_file_size(file_path: Path) -> str:
|
||||
"""Format file size in human-readable format."""
|
||||
try:
|
||||
size_bytes: float = float(file_path.stat().st_size)
|
||||
if size_bytes < 1024.0:
|
||||
return f"{int(size_bytes)} B"
|
||||
for unit in ["KB", "MB", "GB"]:
|
||||
if size_bytes < 1024.0:
|
||||
return f"{size_bytes:.1f} {unit}"
|
||||
size_bytes /= 1024.0
|
||||
return f"{size_bytes:.1f} TB"
|
||||
except FileNotFoundError:
|
||||
return "Unknown size"
|
||||
358
viu/cli/commands/registry/commands/import_.py
Normal file
358
viu/cli/commands/registry/commands/import_.py
Normal file
@@ -0,0 +1,358 @@
|
||||
"""
|
||||
Registry import command - import registry data from various formats
|
||||
"""
|
||||
|
||||
import csv
|
||||
import json
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import click
|
||||
|
||||
from .....core.config import AppConfig
|
||||
from .....libs.media_api.types import MediaItem, MediaTitle, UserMediaListStatus
|
||||
from ....service.feedback import FeedbackService
|
||||
from ....service.registry.service import MediaRegistryService
|
||||
|
||||
|
||||
@click.command(name="import", help="Import registry data from various formats")
|
||||
@click.argument("input_file", type=click.Path(exists=True, path_type=Path))
|
||||
@click.option(
|
||||
"--format",
|
||||
"input_format",
|
||||
type=click.Choice(["json", "csv", "xml", "auto"], case_sensitive=False),
|
||||
default="auto",
|
||||
help="Input format (auto-detect if not specified)",
|
||||
)
|
||||
@click.option(
|
||||
"--merge", is_flag=True, help="Merge with existing registry (default: replace)"
|
||||
)
|
||||
@click.option(
|
||||
"--dry-run", is_flag=True, help="Show what would be imported without making changes"
|
||||
)
|
||||
@click.option(
|
||||
"--force",
|
||||
"-f",
|
||||
is_flag=True,
|
||||
help="Force import even if format version doesn't match",
|
||||
)
|
||||
@click.option("--backup", is_flag=True, help="Create backup before importing")
|
||||
@click.option(
|
||||
"--api",
|
||||
default="anilist",
|
||||
type=click.Choice(["anilist"], case_sensitive=False),
|
||||
help="Media API registry to import to",
|
||||
)
|
||||
@click.pass_obj
|
||||
def import_(
|
||||
config: AppConfig,
|
||||
input_file: Path,
|
||||
input_format: str,
|
||||
merge: bool,
|
||||
dry_run: bool,
|
||||
force: bool,
|
||||
backup: bool,
|
||||
api: str,
|
||||
):
|
||||
"""
|
||||
Import media registry data from various formats.
|
||||
|
||||
Supports JSON, CSV, and XML formats exported by the export command
|
||||
or compatible third-party tools.
|
||||
"""
|
||||
feedback = FeedbackService(config)
|
||||
|
||||
try:
|
||||
registry_service = MediaRegistryService(api, config.media_registry)
|
||||
|
||||
# Create backup if requested
|
||||
if backup and not dry_run:
|
||||
_create_backup(registry_service, feedback, api)
|
||||
|
||||
# Auto-detect format if needed
|
||||
if input_format == "auto":
|
||||
input_format = _detect_format(input_file)
|
||||
feedback.info(
|
||||
"Format Detection", f"Detected format: {input_format.upper()}"
|
||||
)
|
||||
|
||||
# Parse input file
|
||||
import_data = _parse_input_file(input_file, input_format)
|
||||
|
||||
# Validate import data
|
||||
_validate_import_data(import_data, force, feedback)
|
||||
|
||||
# Import data
|
||||
_import_data(registry_service, import_data, merge, dry_run, feedback)
|
||||
|
||||
if not dry_run:
|
||||
feedback.success(
|
||||
"Import Complete",
|
||||
f"Successfully imported {len(import_data.get('media', []))} media entries",
|
||||
)
|
||||
else:
|
||||
feedback.info(
|
||||
"Dry Run Complete",
|
||||
f"Would import {len(import_data.get('media', []))} media entries",
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
feedback.error("Import Error", f"Failed to import registry: {e}")
|
||||
raise click.Abort()
|
||||
|
||||
|
||||
def _create_backup(
|
||||
registry_service: MediaRegistryService, feedback: FeedbackService, api: str
|
||||
):
|
||||
"""Create a backup before importing."""
|
||||
from .export import _export_json, _prepare_export_data
|
||||
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
backup_path = Path(f"viu_registry_pre_import_{api}_{timestamp}.json")
|
||||
|
||||
export_data = _prepare_export_data(registry_service, True, ())
|
||||
_export_json(export_data, backup_path)
|
||||
|
||||
feedback.info("Backup Created", f"Registry backed up to {backup_path}")
|
||||
|
||||
|
||||
def _detect_format(file_path: Path) -> str:
|
||||
"""Auto-detect file format based on extension and content."""
|
||||
extension = file_path.suffix.lower()
|
||||
if ".gz" in file_path.suffixes:
|
||||
return "json" # Assume gzipped jsons for now
|
||||
if extension == ".json":
|
||||
return "json"
|
||||
elif extension == ".csv":
|
||||
return "csv"
|
||||
elif extension == ".xml":
|
||||
return "xml"
|
||||
|
||||
# Fallback to content detection
|
||||
try:
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
content = f.read(100).strip()
|
||||
if content.startswith(("{", "[")):
|
||||
return "json"
|
||||
elif content.startswith("<?xml") or content.startswith("<"):
|
||||
return "xml"
|
||||
elif "," in content:
|
||||
return "csv"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
raise click.ClickException(f"Could not auto-detect format for {file_path}")
|
||||
|
||||
|
||||
def _parse_input_file(file_path: Path, format_type: str) -> dict:
|
||||
"""Parse input file based on format."""
|
||||
if format_type == "json":
|
||||
return _parse_json(file_path)
|
||||
if format_type == "csv":
|
||||
return _parse_csv(file_path)
|
||||
if format_type == "xml":
|
||||
return _parse_xml(file_path)
|
||||
raise click.ClickException(f"Unsupported format: {format_type}")
|
||||
|
||||
|
||||
def _safe_int(value: Optional[str]) -> Optional[int]:
|
||||
if value is None or value == "":
|
||||
return None
|
||||
try:
|
||||
return int(value)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def _safe_float(value: Optional[str]) -> Optional[float]:
|
||||
if value is None or value == "":
|
||||
return None
|
||||
try:
|
||||
return float(value)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def _parse_json(file_path: Path) -> dict:
|
||||
"""Parse JSON input file."""
|
||||
try:
|
||||
if ".gz" in file_path.suffixes:
|
||||
import gzip
|
||||
|
||||
with gzip.open(file_path, "rt", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
else:
|
||||
with file_path.open("r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except json.JSONDecodeError as e:
|
||||
raise click.ClickException(f"Invalid JSON format: {e}")
|
||||
|
||||
|
||||
def _parse_csv(file_path: Path) -> dict:
|
||||
"""Parse CSV input file."""
|
||||
import_data = {"metadata": {"source_format": "csv"}, "media": []}
|
||||
try:
|
||||
with file_path.open("r", encoding="utf-8", newline="") as f:
|
||||
reader = csv.DictReader(f)
|
||||
for row in reader:
|
||||
media_data: Dict[str, Any] = {
|
||||
"id": _safe_int(row.get("id")),
|
||||
"title": {
|
||||
"english": row.get("title_english"),
|
||||
"romaji": row.get("title_romaji"),
|
||||
"native": row.get("title_native"),
|
||||
},
|
||||
"user_status": {
|
||||
"status": row.get("status"),
|
||||
"progress": _safe_int(row.get("progress")),
|
||||
"score": _safe_float(row.get("score")),
|
||||
"last_watched": row.get("last_watched"),
|
||||
"notes": row.get("notes"),
|
||||
},
|
||||
}
|
||||
if "format" in row: # Check if detailed metadata is present
|
||||
media_data.update(
|
||||
{
|
||||
"format": row.get("format"),
|
||||
"episodes": _safe_int(row.get("episodes")),
|
||||
"duration": _safe_int(row.get("duration")),
|
||||
"media_status": row.get("media_status"),
|
||||
"start_date": row.get("start_date"),
|
||||
"end_date": row.get("end_date"),
|
||||
"average_score": _safe_float(row.get("average_score")),
|
||||
"popularity": _safe_int(row.get("popularity")),
|
||||
"genres": row.get("genres", "").split(",")
|
||||
if row.get("genres")
|
||||
else [],
|
||||
"description": row.get("description"),
|
||||
}
|
||||
)
|
||||
import_data["media"].append(media_data)
|
||||
except (ValueError, KeyError, csv.Error) as e:
|
||||
raise click.ClickException(f"Invalid CSV format: {e}")
|
||||
return import_data
|
||||
|
||||
|
||||
def _parse_xml(file_path: Path) -> dict:
|
||||
"""Parse XML input file."""
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
try:
|
||||
tree = ET.parse(file_path)
|
||||
root = tree.getroot()
|
||||
import_data: Dict[str, Any] = {"metadata": {}, "media": []}
|
||||
|
||||
for child in root.find("metadata") or []:
|
||||
import_data["metadata"][child.tag] = child.text
|
||||
|
||||
for media_elem in root.find("media_list") or []:
|
||||
media_data = {child.tag: child.text for child in media_elem}
|
||||
# Reconstruct nested structures for consistency with other parsers
|
||||
media_data["id"] = _safe_int(media_data.get("id"))
|
||||
media_data["title"] = {
|
||||
"english": media_data.pop("title_english", None),
|
||||
"romaji": media_data.pop("title_romaji", None),
|
||||
"native": media_data.pop("title_native", None),
|
||||
}
|
||||
media_data["user_status"] = {
|
||||
"status": media_data.pop("user_status", None),
|
||||
"progress": _safe_int(media_data.pop("user_progress", None)),
|
||||
"score": _safe_float(media_data.pop("user_score", None)),
|
||||
"last_watched": media_data.pop("user_last_watched", None),
|
||||
"notes": media_data.pop("user_notes", None),
|
||||
}
|
||||
import_data["media"].append(media_data)
|
||||
except ET.ParseError as e:
|
||||
raise click.ClickException(f"Invalid XML format: {e}")
|
||||
return import_data
|
||||
|
||||
|
||||
def _validate_import_data(data: dict, force: bool, feedback: FeedbackService):
|
||||
"""Validate import data structure and compatibility."""
|
||||
if "media" not in data or not isinstance(data["media"], list):
|
||||
raise click.ClickException(
|
||||
"Import data missing or has invalid 'media' section."
|
||||
)
|
||||
if not data["media"]:
|
||||
feedback.warning("No Media", "Import file contains no media entries.")
|
||||
return
|
||||
|
||||
for i, media in enumerate(data["media"]):
|
||||
if "id" not in media or "title" not in media:
|
||||
raise click.ClickException(
|
||||
f"Media entry {i + 1} missing required 'id' or 'title' field."
|
||||
)
|
||||
if not isinstance(media.get("title"), dict):
|
||||
raise click.ClickException(f"Media entry {i + 1} has invalid title format.")
|
||||
|
||||
feedback.info(
|
||||
"Validation",
|
||||
f"Import data validated - {len(data['media'])} media entries found.",
|
||||
)
|
||||
|
||||
|
||||
def _import_data(
|
||||
registry_service: MediaRegistryService,
|
||||
data: dict,
|
||||
merge: bool,
|
||||
dry_run: bool,
|
||||
feedback: FeedbackService,
|
||||
):
|
||||
"""Import data into the registry."""
|
||||
from .....libs.media_api.types import MediaType
|
||||
|
||||
imported_count, updated_count, error_count = 0, 0, 0
|
||||
status_map = {status.value: status for status in UserMediaListStatus}
|
||||
|
||||
for media_data in data["media"]:
|
||||
try:
|
||||
media_id = media_data.get("id")
|
||||
if not media_id:
|
||||
error_count += 1
|
||||
continue
|
||||
|
||||
title = MediaTitle(**media_data.get("title", {}))
|
||||
media_item = MediaItem(id=media_id, title=title, type=MediaType.ANIME)
|
||||
|
||||
if dry_run:
|
||||
feedback.info(
|
||||
"Would import", title.english or title.romaji or f"ID:{media_id}"
|
||||
)
|
||||
imported_count += 1
|
||||
continue
|
||||
|
||||
existing_record = registry_service.get_media_record(media_id)
|
||||
if existing_record and not merge:
|
||||
continue
|
||||
|
||||
updated_count += 1 if existing_record else 0
|
||||
imported_count += 1 if not existing_record else 0
|
||||
|
||||
record = registry_service.get_or_create_record(media_item)
|
||||
registry_service.save_media_record(record)
|
||||
|
||||
user_status = media_data.get("user_status", {})
|
||||
if user_status.get("status"):
|
||||
status_enum = status_map.get(str(user_status["status"]).lower())
|
||||
if status_enum:
|
||||
registry_service.update_media_index_entry(
|
||||
media_id,
|
||||
media_item=media_item,
|
||||
status=status_enum,
|
||||
progress=str(user_status.get("progress", 0)),
|
||||
score=user_status.get("score"),
|
||||
notes=user_status.get("notes"),
|
||||
)
|
||||
except Exception as e:
|
||||
error_count += 1
|
||||
feedback.warning(
|
||||
"Import Error",
|
||||
f"Failed to import media {media_data.get('id', 'unknown')}: {e}",
|
||||
)
|
||||
|
||||
if not dry_run:
|
||||
feedback.info(
|
||||
"Import Summary",
|
||||
f"Imported: {imported_count}, Updated: {updated_count}, Errors: {error_count}",
|
||||
)
|
||||
286
viu/cli/commands/registry/commands/restore.py
Normal file
286
viu/cli/commands/registry/commands/restore.py
Normal file
@@ -0,0 +1,286 @@
|
||||
"""
|
||||
Registry restore command - restore registry from backup files
|
||||
"""
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import tarfile
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
|
||||
from .....core.config import AppConfig
|
||||
from ....service.feedback import FeedbackService
|
||||
from ....service.registry.service import MediaRegistryService
|
||||
|
||||
|
||||
@click.command(help="Restore registry from a backup file")
|
||||
@click.argument("backup_file", type=click.Path(exists=True, path_type=Path))
|
||||
@click.option(
|
||||
"--force", "-f", is_flag=True, help="Force restore even if current registry exists"
|
||||
)
|
||||
@click.option(
|
||||
"--backup-current",
|
||||
is_flag=True,
|
||||
help="Create backup of current registry before restoring",
|
||||
)
|
||||
@click.option("--verify", is_flag=True, help="Verify backup integrity before restoring")
|
||||
@click.option(
|
||||
"--api",
|
||||
default="anilist",
|
||||
type=click.Choice(["anilist"], case_sensitive=False),
|
||||
help="Media API registry to restore to",
|
||||
)
|
||||
@click.pass_obj
|
||||
def restore(
|
||||
config: AppConfig,
|
||||
backup_file: Path,
|
||||
force: bool,
|
||||
backup_current: bool,
|
||||
verify: bool,
|
||||
api: str,
|
||||
):
|
||||
"""
|
||||
Restore your media registry from a backup file.
|
||||
|
||||
Can restore from tar or zip backups created by the backup command.
|
||||
Optionally creates a backup of the current registry before restoring.
|
||||
"""
|
||||
feedback = FeedbackService(config)
|
||||
|
||||
try:
|
||||
# Detect backup format
|
||||
backup_format = _detect_backup_format(backup_file)
|
||||
feedback.info("Backup Format", f"Detected {backup_format.upper()} format")
|
||||
|
||||
# Verify backup if requested
|
||||
if verify:
|
||||
if not _verify_backup(backup_file, backup_format, feedback):
|
||||
feedback.error(
|
||||
"Verification Failed",
|
||||
"Backup file appears to be corrupted or invalid",
|
||||
)
|
||||
raise click.Abort()
|
||||
feedback.success("Verification", "Backup file integrity verified")
|
||||
|
||||
# Check if current registry exists
|
||||
registry_service = MediaRegistryService(api, config.media_registry)
|
||||
registry_exists = _check_registry_exists(registry_service)
|
||||
|
||||
if registry_exists and not force:
|
||||
if not click.confirm(
|
||||
"Current registry exists. This will overwrite it. Continue with restore?"
|
||||
):
|
||||
feedback.info("Restore Cancelled", "No changes were made")
|
||||
return
|
||||
|
||||
# Create backup of current registry if requested
|
||||
if backup_current and registry_exists:
|
||||
_backup_current_registry(registry_service, api, feedback)
|
||||
|
||||
# Show restore summary
|
||||
_show_restore_summary(backup_file, backup_format, feedback)
|
||||
|
||||
# Perform restore
|
||||
_perform_restore(backup_file, backup_format, config, api, feedback)
|
||||
|
||||
feedback.success(
|
||||
"Restore Complete", "Registry has been successfully restored from backup"
|
||||
)
|
||||
|
||||
# Verify restored registry
|
||||
try:
|
||||
restored_service = MediaRegistryService(api, config.media_registry)
|
||||
stats = restored_service.get_registry_stats()
|
||||
feedback.info(
|
||||
"Restored Registry",
|
||||
f"Contains {stats.get('total_media', 0)} media entries",
|
||||
)
|
||||
except Exception as e:
|
||||
feedback.warning(
|
||||
"Verification Warning", f"Could not verify restored registry: {e}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
feedback.error("Restore Error", f"Failed to restore registry: {e}")
|
||||
raise click.Abort()
|
||||
|
||||
|
||||
def _detect_backup_format(backup_file: Path) -> str:
|
||||
"""Detect backup file format."""
|
||||
suffixes = "".join(backup_file.suffixes).lower()
|
||||
if ".tar" in suffixes or ".gz" in suffixes or ".tgz" in suffixes:
|
||||
return "tar"
|
||||
elif ".zip" in suffixes:
|
||||
return "zip"
|
||||
raise click.ClickException(f"Could not detect backup format for {backup_file}")
|
||||
|
||||
|
||||
def _verify_backup(
|
||||
backup_file: Path, format_type: str, feedback: FeedbackService
|
||||
) -> bool:
|
||||
"""Verify backup file integrity."""
|
||||
try:
|
||||
has_registry = has_index = has_metadata = False
|
||||
if format_type == "tar":
|
||||
with tarfile.open(backup_file, "r:*") as tar:
|
||||
names = tar.getnames()
|
||||
has_registry = any("registry/" in name for name in names)
|
||||
has_index = any("index/" in name for name in names)
|
||||
has_metadata = "backup_metadata.json" in names
|
||||
if has_metadata:
|
||||
metadata_member = tar.getmember("backup_metadata.json")
|
||||
if metadata_file := tar.extractfile(metadata_member):
|
||||
metadata = json.load(metadata_file)
|
||||
else: # zip
|
||||
import zipfile
|
||||
|
||||
with zipfile.ZipFile(backup_file, "r") as zip_file:
|
||||
names = zip_file.namelist()
|
||||
has_registry = any("registry/" in name for name in names)
|
||||
has_index = any("index/" in name for name in names)
|
||||
has_metadata = "backup_metadata.json" in names
|
||||
if has_metadata:
|
||||
with zip_file.open("backup_metadata.json") as metadata_file:
|
||||
metadata = json.load(metadata_file)
|
||||
|
||||
if has_metadata:
|
||||
feedback.info(
|
||||
"Backup Info", f"Created: {metadata.get('backup_timestamp', 'Unknown')}"
|
||||
)
|
||||
feedback.info(
|
||||
"Backup Info", f"Total Media: {metadata.get('total_media', 'Unknown')}"
|
||||
)
|
||||
|
||||
return has_registry and has_index
|
||||
except (tarfile.ReadError, zipfile.BadZipFile, json.JSONDecodeError):
|
||||
return False
|
||||
except Exception as e:
|
||||
feedback.warning("Verification Warning", f"Could not fully verify backup: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def _check_registry_exists(registry_service: MediaRegistryService) -> bool:
|
||||
"""Check if a registry already exists."""
|
||||
try:
|
||||
stats = registry_service.get_registry_stats()
|
||||
return stats.get("total_media", 0) > 0
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _backup_current_registry(
|
||||
registry_service: MediaRegistryService, api: str, feedback: FeedbackService
|
||||
):
|
||||
"""Create backup of current registry before restoring."""
|
||||
from .backup import _create_tar_backup
|
||||
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
backup_path = Path(f"viu_registry_pre_restore_{api}_{timestamp}.tar.gz")
|
||||
|
||||
try:
|
||||
_create_tar_backup(registry_service, backup_path, True, False, feedback, api)
|
||||
feedback.success("Current Registry Backed Up", f"Saved to {backup_path}")
|
||||
except Exception as e:
|
||||
feedback.warning("Backup Warning", f"Failed to backup current registry: {e}")
|
||||
|
||||
|
||||
def _show_restore_summary(
|
||||
backup_file: Path, format_type: str, feedback: FeedbackService
|
||||
):
|
||||
"""Show summary of what will be restored."""
|
||||
try:
|
||||
file_count = media_files = 0
|
||||
if format_type == "tar":
|
||||
with tarfile.open(backup_file, "r:*") as tar:
|
||||
members = tar.getmembers()
|
||||
file_count = len([m for m in members if m.isfile()])
|
||||
media_files = len(
|
||||
[
|
||||
m
|
||||
for m in members
|
||||
if m.name.startswith("registry/") and m.name.endswith(".json")
|
||||
]
|
||||
)
|
||||
else: # zip
|
||||
import zipfile
|
||||
|
||||
with zipfile.ZipFile(backup_file, "r") as zip_file:
|
||||
info_list = zip_file.infolist()
|
||||
file_count = len([info for info in info_list if not info.is_dir()])
|
||||
media_files = len(
|
||||
[
|
||||
info
|
||||
for info in info_list
|
||||
if info.filename.startswith("registry/")
|
||||
and info.filename.endswith(".json")
|
||||
]
|
||||
)
|
||||
|
||||
feedback.info(
|
||||
"Restore Preview",
|
||||
f"Backup contains {file_count} files, including {media_files} media entries.",
|
||||
)
|
||||
except Exception as e:
|
||||
feedback.warning("Preview Error", f"Could not analyze backup: {e}")
|
||||
|
||||
|
||||
def _perform_restore(
|
||||
backup_file: Path,
|
||||
format_type: str,
|
||||
config: AppConfig,
|
||||
api: str,
|
||||
feedback: FeedbackService,
|
||||
):
|
||||
"""Perform the actual restore operation."""
|
||||
temp_dir = Path(
|
||||
config.media_registry.media_dir.parent
|
||||
/ f"restore_temp_{datetime.now().timestamp()}"
|
||||
)
|
||||
temp_dir.mkdir(exist_ok=True, parents=True)
|
||||
|
||||
try:
|
||||
with feedback.progress("Restoring from backup...") as (task_id, progress):
|
||||
# 1. Extract backup
|
||||
progress.update(task_id, description="Extracting backup...")
|
||||
if format_type == "tar":
|
||||
with tarfile.open(backup_file, "r:*") as tar:
|
||||
tar.extractall(temp_dir)
|
||||
else:
|
||||
import zipfile
|
||||
|
||||
with zipfile.ZipFile(backup_file, "r") as zip_file:
|
||||
zip_file.extractall(temp_dir)
|
||||
feedback.info("Extraction", "Backup extracted to temporary directory")
|
||||
|
||||
# 2. Prepare paths
|
||||
registry_dir = config.media_registry.media_dir / api
|
||||
index_dir = config.media_registry.index_dir
|
||||
cache_dir = config.media_registry.media_dir.parent / "cache"
|
||||
|
||||
# 3. Clean existing data
|
||||
progress.update(task_id, description="Cleaning existing registry...")
|
||||
if registry_dir.exists():
|
||||
shutil.rmtree(registry_dir)
|
||||
if index_dir.exists():
|
||||
shutil.rmtree(index_dir)
|
||||
if cache_dir.exists():
|
||||
shutil.rmtree(cache_dir)
|
||||
feedback.info("Cleanup", "Removed existing registry, index, and cache data")
|
||||
|
||||
# 4. Move extracted files
|
||||
progress.update(task_id, description="Moving new files into place...")
|
||||
if (extracted_registry := temp_dir / "registry" / api).exists():
|
||||
shutil.move(str(extracted_registry), str(registry_dir))
|
||||
if (extracted_index := temp_dir / "index").exists():
|
||||
shutil.move(str(extracted_index), str(index_dir))
|
||||
if (extracted_cache := temp_dir / "cache").exists():
|
||||
shutil.move(str(extracted_cache), str(cache_dir))
|
||||
|
||||
progress.update(task_id, description="Finalizing...")
|
||||
|
||||
finally:
|
||||
if temp_dir.exists():
|
||||
shutil.rmtree(temp_dir)
|
||||
feedback.info("Cleanup", "Temporary files removed")
|
||||
214
viu/cli/commands/registry/commands/search.py
Normal file
214
viu/cli/commands/registry/commands/search.py
Normal file
@@ -0,0 +1,214 @@
|
||||
"""
|
||||
Registry search command - search through the local media registry
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import click
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
|
||||
from .....core.config import AppConfig
|
||||
from .....libs.media_api.params import MediaSearchParams
|
||||
from .....libs.media_api.types import (
|
||||
MediaFormat,
|
||||
MediaGenre,
|
||||
MediaSort,
|
||||
UserMediaListStatus,
|
||||
)
|
||||
from ....service.feedback import FeedbackService
|
||||
from ....service.registry.service import MediaRegistryService
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .....libs.media_api.types import MediaSearchResult
|
||||
|
||||
|
||||
@click.command(help="Search through the local media registry")
|
||||
@click.argument("query", required=False)
|
||||
@click.option(
|
||||
"--status",
|
||||
type=click.Choice(
|
||||
[s.value for s in UserMediaListStatus],
|
||||
case_sensitive=False,
|
||||
),
|
||||
help="Filter by watch status",
|
||||
)
|
||||
@click.option(
|
||||
"--genre", multiple=True, help="Filter by genre (can be used multiple times)"
|
||||
)
|
||||
@click.option(
|
||||
"--format",
|
||||
type=click.Choice(
|
||||
[
|
||||
f.value
|
||||
for f in MediaFormat
|
||||
if f not in [MediaFormat.MANGA, MediaFormat.NOVEL, MediaFormat.ONE_SHOT]
|
||||
],
|
||||
case_sensitive=False,
|
||||
),
|
||||
help="Filter by format",
|
||||
)
|
||||
@click.option("--year", type=int, help="Filter by release year")
|
||||
@click.option("--min-score", type=float, help="Minimum average score (0.0 - 10.0)")
|
||||
@click.option("--max-score", type=float, help="Maximum average score (0.0 - 10.0)")
|
||||
@click.option(
|
||||
"--sort",
|
||||
type=click.Choice(
|
||||
["title", "score", "popularity", "year", "episodes", "updated"],
|
||||
case_sensitive=False,
|
||||
),
|
||||
default="title",
|
||||
help="Sort results by field",
|
||||
)
|
||||
@click.option("--limit", type=int, default=20, help="Maximum number of results to show")
|
||||
@click.option(
|
||||
"--json", "output_json", is_flag=True, help="Output results in JSON format"
|
||||
)
|
||||
@click.option(
|
||||
"--api",
|
||||
default="anilist",
|
||||
type=click.Choice(["anilist"], case_sensitive=False),
|
||||
help="Media API registry to search",
|
||||
)
|
||||
@click.pass_obj
|
||||
def search(
|
||||
config: AppConfig,
|
||||
query: str | None,
|
||||
status: str | None,
|
||||
genre: tuple[str, ...],
|
||||
format: str | None,
|
||||
year: int | None,
|
||||
min_score: float | None,
|
||||
max_score: float | None,
|
||||
sort: str,
|
||||
limit: int,
|
||||
output_json: bool,
|
||||
api: str,
|
||||
):
|
||||
"""
|
||||
Search through your local media registry.
|
||||
|
||||
You can search by title and filter by various criteria like status,
|
||||
genre, format, year, and score range.
|
||||
"""
|
||||
feedback = FeedbackService(config)
|
||||
console = Console()
|
||||
|
||||
try:
|
||||
registry_service = MediaRegistryService(api, config.media_registry)
|
||||
|
||||
search_params = _build_search_params(
|
||||
query, status, genre, format, year, min_score, max_score, sort, limit
|
||||
)
|
||||
|
||||
with feedback.progress("Searching local registry..."):
|
||||
result = registry_service.search_for_media(search_params)
|
||||
|
||||
if not result or not result.media:
|
||||
feedback.info("No Results", "No media found matching your criteria")
|
||||
return
|
||||
|
||||
if output_json:
|
||||
print(json.dumps(result.model_dump(mode="json"), indent=2))
|
||||
return
|
||||
|
||||
_display_search_results(console, result, config.general.icons)
|
||||
|
||||
except Exception as e:
|
||||
feedback.error("Search Error", f"Failed to search registry: {e}")
|
||||
raise click.Abort()
|
||||
|
||||
|
||||
def _build_search_params(
|
||||
query: str | None,
|
||||
status: str | None,
|
||||
genre: tuple[str, ...],
|
||||
format_str: str | None,
|
||||
year: int | None,
|
||||
min_score: float | None,
|
||||
max_score: float | None,
|
||||
sort: str,
|
||||
limit: int,
|
||||
) -> MediaSearchParams:
|
||||
"""Build MediaSearchParams from command options for local filtering."""
|
||||
sort_map = {
|
||||
"title": MediaSort.TITLE_ROMAJI,
|
||||
"score": MediaSort.SCORE_DESC,
|
||||
"popularity": MediaSort.POPULARITY_DESC,
|
||||
"year": MediaSort.START_DATE_DESC,
|
||||
"episodes": MediaSort.EPISODES_DESC,
|
||||
"updated": MediaSort.UPDATED_AT_DESC,
|
||||
}
|
||||
|
||||
# Safely convert strings to enums
|
||||
format_enum = next(
|
||||
(f for f in MediaFormat if f.value.lower() == (format_str or "").lower()), None
|
||||
)
|
||||
genre_enums = [
|
||||
g for g_str in genre for g in MediaGenre if g.value.lower() == g_str.lower()
|
||||
]
|
||||
|
||||
# Note: Local search handles status separately as it's part of the index, not MediaItem
|
||||
|
||||
return MediaSearchParams(
|
||||
query=query,
|
||||
per_page=limit,
|
||||
sort=[sort_map.get(sort.lower(), MediaSort.TITLE_ROMAJI)],
|
||||
averageScore_greater=int(min_score * 10) if min_score is not None else None,
|
||||
averageScore_lesser=int(max_score * 10) if max_score is not None else None,
|
||||
genre_in=genre_enums or None,
|
||||
format_in=[format_enum] if format_enum else None,
|
||||
seasonYear=year,
|
||||
)
|
||||
|
||||
|
||||
def _display_search_results(console: Console, result: "MediaSearchResult", icons: bool):
|
||||
"""Display search results in a formatted table."""
|
||||
table = Table(
|
||||
title=f"{'🔍 ' if icons else ''}Search Results ({len(result.media)} found)"
|
||||
)
|
||||
table.add_column("Title", style="cyan", min_width=30, overflow="ellipsis")
|
||||
table.add_column("Year", style="dim", justify="center")
|
||||
table.add_column("Format", style="magenta", justify="center")
|
||||
table.add_column("Episodes", style="green", justify="center")
|
||||
table.add_column("Score", style="yellow", justify="center")
|
||||
table.add_column("Status", style="blue", justify="center")
|
||||
table.add_column("Progress", style="white", justify="center")
|
||||
|
||||
for media in result.media:
|
||||
title = media.title.english or media.title.romaji or "Unknown"
|
||||
year = str(media.start_date.year) if media.start_date else "N/A"
|
||||
episodes_total = str(media.episodes) if media.episodes else "?"
|
||||
score = (
|
||||
f"{media.average_score / 10:.1f}"
|
||||
if media.average_score is not None
|
||||
else "N/A"
|
||||
)
|
||||
|
||||
status = "Not Listed"
|
||||
progress = "0"
|
||||
if media.user_status:
|
||||
status = (
|
||||
media.user_status.status.value.title()
|
||||
if media.user_status.status
|
||||
else "Unknown"
|
||||
)
|
||||
progress = f"{media.user_status.progress or 0}/{episodes_total}"
|
||||
|
||||
table.add_row(
|
||||
title,
|
||||
year,
|
||||
media.format.value if media.format else "N/A",
|
||||
episodes_total,
|
||||
score,
|
||||
status,
|
||||
progress,
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
|
||||
if result.page_info and result.page_info.total > len(result.media):
|
||||
console.print(
|
||||
f"\n[dim]Showing {len(result.media)} of {result.page_info.total} total results[/dim]"
|
||||
)
|
||||
254
viu/cli/commands/registry/commands/stats.py
Normal file
254
viu/cli/commands/registry/commands/stats.py
Normal file
@@ -0,0 +1,254 @@
|
||||
"""
|
||||
Registry stats command - show detailed statistics about the local registry
|
||||
"""
|
||||
|
||||
import json
|
||||
from datetime import datetime, timedelta
|
||||
from typing import TYPE_CHECKING, Dict
|
||||
|
||||
import click
|
||||
from rich.columns import Columns
|
||||
from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
from rich.table import Table
|
||||
|
||||
from .....core.config import AppConfig
|
||||
from ....service.feedback import FeedbackService
|
||||
from ....service.registry.service import MediaRegistryService
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ....service.registry.service import StatBreakdown
|
||||
|
||||
# --- Constants for better maintainability ---
|
||||
TOP_N_STATS = 10
|
||||
|
||||
|
||||
@click.command(help="Show detailed statistics about the local media registry")
|
||||
@click.option(
|
||||
"--detailed",
|
||||
"-d",
|
||||
is_flag=True,
|
||||
help="Show detailed breakdown by genre, format, and year",
|
||||
)
|
||||
@click.option(
|
||||
"--json", "output_json", is_flag=True, help="Output statistics in JSON format"
|
||||
)
|
||||
@click.option(
|
||||
"--api",
|
||||
default="anilist",
|
||||
type=click.Choice(["anilist"], case_sensitive=False),
|
||||
help="Media API to show stats for",
|
||||
)
|
||||
@click.pass_obj
|
||||
def stats(config: AppConfig, detailed: bool, output_json: bool, api: str):
|
||||
"""
|
||||
Display comprehensive statistics about your local media registry.
|
||||
|
||||
Shows total counts, status breakdown, and optionally detailed
|
||||
analysis by genre, format, and release year.
|
||||
"""
|
||||
feedback = FeedbackService(config)
|
||||
console = Console()
|
||||
|
||||
try:
|
||||
registry_service = MediaRegistryService(api, config.media_registry)
|
||||
stats_data = registry_service.get_registry_stats()
|
||||
|
||||
if output_json:
|
||||
print(json.dumps(stats_data, indent=2, default=str))
|
||||
return
|
||||
|
||||
_display_stats_overview(console, stats_data, api, config.general.icons)
|
||||
|
||||
if detailed:
|
||||
_display_detailed_stats(console, stats_data, config.general.icons)
|
||||
|
||||
except Exception as e:
|
||||
feedback.error("Stats Error", f"Failed to generate statistics: {e}")
|
||||
raise click.Abort()
|
||||
|
||||
|
||||
def _display_stats_overview(
|
||||
console: Console, stats: "StatBreakdown", api: str, icons: bool
|
||||
):
|
||||
"""
|
||||
Display the main overview and status breakdown tables.
|
||||
"""
|
||||
# --- Main Overview Table ---
|
||||
overview_table = Table.grid(expand=True, padding=(0, 1))
|
||||
overview_table.add_column("Metric", style="bold cyan", no_wrap=True)
|
||||
overview_table.add_column("Value", style="white")
|
||||
|
||||
overview_table.add_row("Media API:", api.title())
|
||||
overview_table.add_row("Total Media:", str(stats.get("total_media", 0)))
|
||||
overview_table.add_row("Registry Version:", str(stats.get("version", "Unknown")))
|
||||
|
||||
# Format "Last Updated" timestamp to be more human-readable
|
||||
last_updated_str = stats.get("last_updated", "Never")
|
||||
if last_updated_str != "Never":
|
||||
try:
|
||||
last_updated_dt = datetime.fromisoformat(last_updated_str)
|
||||
last_updated_str = _format_timedelta(datetime.now() - last_updated_dt)
|
||||
except (ValueError, TypeError):
|
||||
pass # Keep original string if parsing fails
|
||||
overview_table.add_row("Last Updated:", last_updated_str)
|
||||
|
||||
# Format storage size
|
||||
storage_size_str = _format_storage_size(float(stats.get("storage_size_bytes", 0)))
|
||||
overview_table.add_row("Storage Size:", storage_size_str)
|
||||
|
||||
console.print(
|
||||
Panel(
|
||||
overview_table,
|
||||
title=f"{'📊 ' if icons else ''}Registry Overview",
|
||||
border_style="cyan",
|
||||
)
|
||||
)
|
||||
console.print()
|
||||
|
||||
# --- Status Breakdown Table ---
|
||||
status_breakdown = stats.get("status_breakdown", {})
|
||||
if status_breakdown:
|
||||
status_table = _create_breakdown_table(
|
||||
title=f"{'📋 ' if icons else ''}Status Breakdown",
|
||||
data=status_breakdown,
|
||||
key_header="Status",
|
||||
value_header="Count",
|
||||
show_percentage=True,
|
||||
)
|
||||
console.print(status_table)
|
||||
console.print()
|
||||
|
||||
# --- Download Status Table ---
|
||||
download_stats = stats.get("download_stats", {})
|
||||
if download_stats:
|
||||
download_table = _create_breakdown_table(
|
||||
title=f"{'💾 ' if icons else ''}Download Status",
|
||||
data=download_stats,
|
||||
key_header="Status",
|
||||
value_header="Count",
|
||||
show_percentage=False,
|
||||
)
|
||||
console.print(download_table)
|
||||
console.print()
|
||||
|
||||
|
||||
def _display_detailed_stats(console: Console, stats: "StatBreakdown", icons: bool):
|
||||
"""
|
||||
Display detailed breakdowns by various categories using a column layout.
|
||||
"""
|
||||
genre_table = _create_breakdown_table(
|
||||
title=f"{'🎭 ' if icons else ''}Top {TOP_N_STATS} Genres",
|
||||
data=stats.get("genre_breakdown", {}),
|
||||
key_header="Genre",
|
||||
value_header="Count",
|
||||
limit=TOP_N_STATS,
|
||||
)
|
||||
|
||||
format_table = _create_breakdown_table(
|
||||
title=f"{'📺 ' if icons else ''}Format Breakdown",
|
||||
data=stats.get("format_breakdown", {}),
|
||||
key_header="Format",
|
||||
value_header="Count",
|
||||
show_percentage=True,
|
||||
)
|
||||
|
||||
year_table = _create_breakdown_table(
|
||||
title=f"{'📅 ' if icons else ''}Top {TOP_N_STATS} Release Years",
|
||||
data=stats.get("year_breakdown", {}),
|
||||
key_header="Year",
|
||||
value_header="Count",
|
||||
sort_by_key=True,
|
||||
limit=TOP_N_STATS,
|
||||
)
|
||||
|
||||
rating_table = _create_breakdown_table(
|
||||
title=f"{'⭐ ' if icons else ''}Score Distribution",
|
||||
data=stats.get("rating_breakdown", {}),
|
||||
key_header="Score Range",
|
||||
value_header="Count",
|
||||
sort_by_key=True,
|
||||
reverse_sort=False,
|
||||
)
|
||||
|
||||
# Render tables in columns for a compact view
|
||||
console.print(Columns([genre_table, format_table], equal=True, expand=True))
|
||||
console.print()
|
||||
console.print(Columns([year_table, rating_table], equal=True, expand=True))
|
||||
|
||||
|
||||
def _create_breakdown_table(
|
||||
title: str,
|
||||
data: Dict,
|
||||
key_header: str,
|
||||
value_header: str,
|
||||
show_percentage: bool = False,
|
||||
sort_by_key: bool = False,
|
||||
reverse_sort: bool = True,
|
||||
limit: int = 0,
|
||||
) -> Table:
|
||||
"""
|
||||
Generic helper to create a rich Table for breakdown statistics.
|
||||
"""
|
||||
table = Table(title=title)
|
||||
table.add_column(key_header, style="cyan")
|
||||
table.add_column(value_header, style="magenta", justify="right")
|
||||
if show_percentage:
|
||||
table.add_column("Percentage", style="green", justify="right")
|
||||
|
||||
if not data:
|
||||
row = (
|
||||
["No data available", "-", "-"]
|
||||
if show_percentage
|
||||
else ["No data available", "-"]
|
||||
)
|
||||
table.add_row(*row)
|
||||
return table
|
||||
|
||||
total = sum(data.values())
|
||||
|
||||
# Determine sorting method
|
||||
def sort_key(item):
|
||||
return item[0] if sort_by_key else item[1]
|
||||
|
||||
sorted_data = sorted(data.items(), key=sort_key, reverse=reverse_sort)
|
||||
|
||||
# Apply limit if specified
|
||||
if limit > 0:
|
||||
sorted_data = sorted_data[:limit]
|
||||
|
||||
for key, count in sorted_data:
|
||||
row = [str(key).title(), str(count)]
|
||||
if show_percentage:
|
||||
percentage = (count / total * 100) if total > 0 else 0
|
||||
row.append(f"{percentage:.1f}%")
|
||||
table.add_row(*row)
|
||||
|
||||
return table
|
||||
|
||||
|
||||
def _format_storage_size(size_bytes: float) -> str:
|
||||
"""Formats bytes into a human-readable string (KB, MB, GB)."""
|
||||
if size_bytes == 0:
|
||||
return "0 B"
|
||||
size_name = ("B", "KB", "MB", "GB", "TB")
|
||||
i = 0
|
||||
while size_bytes >= 1024.0 and i < len(size_name) - 1:
|
||||
size_bytes /= 1024.0
|
||||
i += 1
|
||||
return f"{size_bytes:.2f} {size_name[i]}"
|
||||
|
||||
|
||||
def _format_timedelta(delta: timedelta) -> str:
|
||||
"""Formats a timedelta into a human-readable relative time string."""
|
||||
seconds = int(delta.total_seconds())
|
||||
if seconds < 60:
|
||||
return "Just now"
|
||||
minutes = seconds // 60
|
||||
if minutes < 60:
|
||||
return f"{minutes} minute{'s' if minutes > 1 else ''} ago"
|
||||
hours = minutes // 60
|
||||
if hours < 24:
|
||||
return f"{hours} hour{'s' if hours > 1 else ''} ago"
|
||||
days = hours // 24
|
||||
return f"{days} day{'s' if days > 1 else ''} ago"
|
||||
272
viu/cli/commands/registry/commands/sync.py
Normal file
272
viu/cli/commands/registry/commands/sync.py
Normal file
@@ -0,0 +1,272 @@
|
||||
"""
|
||||
Registry sync command - synchronize local registry with remote media API
|
||||
"""
|
||||
|
||||
import click
|
||||
from viu.cli.service.feedback.service import FeedbackService
|
||||
from viu.cli.service.registry.service import MediaRegistryService
|
||||
|
||||
from .....core.config import AppConfig
|
||||
|
||||
|
||||
@click.command(help="Synchronize local registry with remote media API")
|
||||
@click.option(
|
||||
"--download", "-d", is_flag=True, help="Download remote user list to local registry"
|
||||
)
|
||||
@click.option(
|
||||
"--upload", "-u", is_flag=True, help="Upload local registry changes to remote API"
|
||||
)
|
||||
@click.option(
|
||||
"--force", "-f", is_flag=True, help="Force sync even if there are conflicts"
|
||||
)
|
||||
@click.option(
|
||||
"--dry-run", is_flag=True, help="Show what would be synced without making changes"
|
||||
)
|
||||
@click.option(
|
||||
"--status",
|
||||
multiple=True,
|
||||
type=click.Choice(
|
||||
["watching", "completed", "planning", "dropped", "paused", "repeating"],
|
||||
case_sensitive=False,
|
||||
),
|
||||
help="Only sync specific status lists (can be used multiple times)",
|
||||
)
|
||||
@click.option(
|
||||
"--api",
|
||||
default="anilist",
|
||||
type=click.Choice(["anilist"], case_sensitive=False),
|
||||
help="Media API to sync with",
|
||||
)
|
||||
@click.pass_obj
|
||||
def sync(
|
||||
config: AppConfig,
|
||||
download: bool,
|
||||
upload: bool,
|
||||
force: bool,
|
||||
dry_run: bool,
|
||||
status: tuple[str, ...],
|
||||
api: str,
|
||||
):
|
||||
"""
|
||||
Synchronize local registry with remote media API.
|
||||
|
||||
This command can download your remote media list to the local registry,
|
||||
upload local changes to the remote API, or both.
|
||||
"""
|
||||
|
||||
from .....libs.media_api.api import create_api_client
|
||||
from .....libs.media_api.types import UserMediaListStatus
|
||||
from ....service.auth import AuthService
|
||||
from ....service.feedback import FeedbackService
|
||||
from ....service.registry import MediaRegistryService
|
||||
|
||||
feedback = FeedbackService(config)
|
||||
auth = AuthService(config.general.media_api)
|
||||
registry_service = MediaRegistryService(api, config.media_registry)
|
||||
|
||||
media_api_client = create_api_client(api, config)
|
||||
|
||||
# Default to both download and upload if neither specified
|
||||
if not download and not upload:
|
||||
download = upload = True
|
||||
|
||||
# Check authentication
|
||||
|
||||
if profile := auth.get_auth():
|
||||
if not media_api_client.authenticate(profile.token):
|
||||
feedback.error(
|
||||
"Authentication Required",
|
||||
f"You must be logged in to {api} to sync your media list.",
|
||||
)
|
||||
feedback.info("Run this command to authenticate:", f"viu {api} auth")
|
||||
raise click.Abort()
|
||||
|
||||
# Determine which statuses to sync
|
||||
status_list = (
|
||||
list(status)
|
||||
if status
|
||||
else ["watching", "completed", "planning", "dropped", "paused", "repeating"]
|
||||
)
|
||||
|
||||
# Convert to enum values
|
||||
status_map = {
|
||||
"watching": UserMediaListStatus.WATCHING,
|
||||
"completed": UserMediaListStatus.COMPLETED,
|
||||
"planning": UserMediaListStatus.PLANNING,
|
||||
"dropped": UserMediaListStatus.DROPPED,
|
||||
"paused": UserMediaListStatus.PAUSED,
|
||||
"repeating": UserMediaListStatus.REPEATING,
|
||||
}
|
||||
|
||||
statuses_to_sync = [status_map[s] for s in status_list]
|
||||
|
||||
if download:
|
||||
_sync_download(
|
||||
media_api_client,
|
||||
registry_service,
|
||||
statuses_to_sync,
|
||||
feedback,
|
||||
dry_run,
|
||||
force,
|
||||
)
|
||||
|
||||
if upload:
|
||||
_sync_upload(
|
||||
media_api_client,
|
||||
registry_service,
|
||||
statuses_to_sync,
|
||||
feedback,
|
||||
dry_run,
|
||||
force,
|
||||
)
|
||||
|
||||
feedback.success("Sync Complete", "Registry synchronization finished successfully")
|
||||
|
||||
|
||||
def _sync_download(
|
||||
api_client, registry_service, statuses, feedback: "FeedbackService", dry_run, force
|
||||
):
|
||||
"""Download remote media list to local registry."""
|
||||
from .....libs.media_api.params import UserMediaListSearchParams
|
||||
|
||||
feedback.info("Starting Download", "Fetching remote media lists...")
|
||||
|
||||
total_downloaded = 0
|
||||
total_updated = 0
|
||||
with feedback.progress("Downloading media lists...", total=len(statuses)) as (
|
||||
task_id,
|
||||
progress,
|
||||
):
|
||||
for status in statuses:
|
||||
try:
|
||||
# Fetch all pages for this status
|
||||
page = 1
|
||||
while True:
|
||||
params = UserMediaListSearchParams(
|
||||
status=status, page=page, per_page=50
|
||||
)
|
||||
|
||||
result = api_client.search_media_list(params)
|
||||
if not result or not result.media:
|
||||
break
|
||||
|
||||
for media_item in result.media:
|
||||
if dry_run:
|
||||
feedback.info(
|
||||
"Would download",
|
||||
f"{media_item.title.english or media_item.title.romaji} ({status.value})",
|
||||
)
|
||||
else:
|
||||
# Get or create record and update with user status
|
||||
record = registry_service.get_or_create_record(media_item)
|
||||
|
||||
# Update index entry with latest status
|
||||
if media_item.user_status:
|
||||
registry_service.update_media_index_entry(
|
||||
media_item.id,
|
||||
media_item=media_item,
|
||||
status=media_item.user_status.status,
|
||||
progress=str(media_item.user_status.progress or 0),
|
||||
score=media_item.user_status.score,
|
||||
repeat=media_item.user_status.repeat,
|
||||
notes=media_item.user_status.notes,
|
||||
)
|
||||
total_updated += 1
|
||||
|
||||
registry_service.save_media_record(record)
|
||||
total_downloaded += 1
|
||||
|
||||
if not result.page_info.has_next_page:
|
||||
break
|
||||
page += 1
|
||||
|
||||
except Exception as e:
|
||||
feedback.error(f"Download Error ({status.value})", str(e))
|
||||
continue
|
||||
|
||||
progress.advance(task_id) # type:ignore
|
||||
|
||||
if not dry_run:
|
||||
feedback.success(
|
||||
"Download Complete",
|
||||
f"Downloaded {total_downloaded} media entries, updated {total_updated} existing entries",
|
||||
)
|
||||
|
||||
|
||||
def _sync_upload(
|
||||
api_client,
|
||||
registry_service: MediaRegistryService,
|
||||
statuses,
|
||||
feedback,
|
||||
dry_run,
|
||||
force,
|
||||
):
|
||||
"""Upload local registry changes to remote API."""
|
||||
feedback.info("Starting Upload", "Syncing local changes to remote...")
|
||||
|
||||
total_uploaded = 0
|
||||
total_errors = 0
|
||||
|
||||
with feedback.progress("Uploading changes..."):
|
||||
try:
|
||||
# Get all media records from registry
|
||||
all_records = registry_service.get_all_media_records()
|
||||
|
||||
for record in all_records:
|
||||
try:
|
||||
# Get the index entry for this media
|
||||
index_entry = registry_service.get_media_index_entry(
|
||||
record.media_item.id
|
||||
)
|
||||
if not index_entry or not index_entry.status:
|
||||
continue
|
||||
|
||||
# Only sync if status is in our target list
|
||||
if index_entry.status.value not in statuses:
|
||||
continue
|
||||
|
||||
if dry_run:
|
||||
feedback.info(
|
||||
"Would upload",
|
||||
f"{record.media_item.title.english or record.media_item.title.romaji} "
|
||||
f"({index_entry.status.value}, progress: {index_entry.progress or 0})",
|
||||
)
|
||||
else:
|
||||
# Update remote list entry
|
||||
from .....libs.media_api.params import (
|
||||
UpdateUserMediaListEntryParams,
|
||||
)
|
||||
|
||||
update_params = UpdateUserMediaListEntryParams(
|
||||
media_id=record.media_item.id,
|
||||
status=index_entry.status,
|
||||
progress=index_entry.progress,
|
||||
score=index_entry.score,
|
||||
)
|
||||
|
||||
if api_client.update_list_entry(update_params):
|
||||
total_uploaded += 1
|
||||
else:
|
||||
total_errors += 1
|
||||
feedback.warning(
|
||||
"Upload Failed",
|
||||
f"Failed to upload {record.media_item.title.english or record.media_item.title.romaji}",
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
total_errors += 1
|
||||
feedback.error(
|
||||
"Upload Error",
|
||||
f"Failed to upload media {record.media_item.id}: {e}",
|
||||
)
|
||||
continue
|
||||
|
||||
except Exception as e:
|
||||
feedback.error("Upload Error", f"Failed to get local records: {e}")
|
||||
return
|
||||
|
||||
if not dry_run:
|
||||
feedback.success(
|
||||
"Upload Complete",
|
||||
f"Uploaded {total_uploaded} entries, {total_errors} errors",
|
||||
)
|
||||
31
viu/cli/commands/registry/examples.py
Normal file
31
viu/cli/commands/registry/examples.py
Normal file
@@ -0,0 +1,31 @@
|
||||
"""
|
||||
Example usage for the registry command
|
||||
"""
|
||||
|
||||
main = """
|
||||
|
||||
Examples:
|
||||
# Sync with remote AniList
|
||||
viu registry sync --upload --download
|
||||
|
||||
# Show detailed registry statistics
|
||||
viu registry stats --detailed
|
||||
|
||||
# Search local registry
|
||||
viu registry search "attack on titan"
|
||||
|
||||
# Export registry to JSON
|
||||
viu registry export --format json --output backup.json
|
||||
|
||||
# Import from backup
|
||||
viu registry import backup.json
|
||||
|
||||
# Clean up orphaned entries
|
||||
viu registry clean --dry-run
|
||||
|
||||
# Create full backup
|
||||
viu registry backup --compress
|
||||
|
||||
# Restore from backup
|
||||
viu registry restore backup.tar.gz
|
||||
"""
|
||||
193
viu/cli/commands/search.py
Normal file
193
viu/cli/commands/search.py
Normal file
@@ -0,0 +1,193 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import click
|
||||
|
||||
from ...core.config import AppConfig
|
||||
from ...core.exceptions import ViuError
|
||||
from ..utils.completion import anime_titles_shell_complete
|
||||
from . import examples
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import TypedDict
|
||||
|
||||
from viu.cli.service.feedback.service import FeedbackService
|
||||
from typing_extensions import Unpack
|
||||
|
||||
from ...libs.provider.anime.base import BaseAnimeProvider
|
||||
from ...libs.provider.anime.types import Anime
|
||||
from ...libs.selectors.base import BaseSelector
|
||||
|
||||
class Options(TypedDict):
|
||||
anime_title: list[str]
|
||||
episode_range: str | None
|
||||
|
||||
|
||||
@click.command(
|
||||
help="This subcommand directly interacts with the provider to enable basic streaming. Useful for binging anime.",
|
||||
short_help="Binge anime",
|
||||
epilog=examples.search,
|
||||
)
|
||||
@click.option(
|
||||
"--anime-title",
|
||||
"-t",
|
||||
required=True,
|
||||
shell_complete=anime_titles_shell_complete,
|
||||
multiple=True,
|
||||
help="Specify which anime to download",
|
||||
)
|
||||
@click.option(
|
||||
"--episode-range",
|
||||
"-r",
|
||||
help="A range of episodes to binge (start-end)",
|
||||
)
|
||||
@click.pass_obj
|
||||
def search(config: AppConfig, **options: "Unpack[Options]"):
|
||||
from viu.cli.service.feedback.service import FeedbackService
|
||||
|
||||
from ...core.exceptions import ViuError
|
||||
from ...libs.provider.anime.params import (
|
||||
AnimeParams,
|
||||
SearchParams,
|
||||
)
|
||||
from ...libs.provider.anime.provider import create_provider
|
||||
from ...libs.selectors.selector import create_selector
|
||||
|
||||
feedback = FeedbackService(config)
|
||||
provider = create_provider(config.general.provider)
|
||||
selector = create_selector(config)
|
||||
|
||||
anime_titles = options["anime_title"]
|
||||
feedback.info(f"[green bold]Streaming:[/] {anime_titles}")
|
||||
for anime_title in anime_titles:
|
||||
# ---- search for anime ----
|
||||
feedback.info(f"[green bold]Searching for:[/] {anime_title}")
|
||||
with feedback.progress(f"Fetching anime search results for {anime_title}"):
|
||||
search_results = provider.search(
|
||||
SearchParams(
|
||||
query=anime_title, translation_type=config.stream.translation_type
|
||||
)
|
||||
)
|
||||
if not search_results:
|
||||
raise ViuError("No results were found matching your query")
|
||||
|
||||
_search_results = {
|
||||
search_result.title: search_result
|
||||
for search_result in search_results.results
|
||||
}
|
||||
|
||||
selected_anime_title = selector.choose(
|
||||
"Select Anime", list(_search_results.keys())
|
||||
)
|
||||
if not selected_anime_title:
|
||||
raise ViuError("No title selected")
|
||||
anime_result = _search_results[selected_anime_title]
|
||||
|
||||
# ---- fetch selected anime ----
|
||||
with feedback.progress(f"Fetching {anime_result.title}"):
|
||||
anime = provider.get(AnimeParams(id=anime_result.id, query=anime_title))
|
||||
|
||||
if not anime:
|
||||
raise ViuError(f"Failed to fetch anime {anime_result.title}")
|
||||
|
||||
available_episodes: list[str] = sorted(
|
||||
getattr(anime.episodes, config.stream.translation_type), key=float
|
||||
)
|
||||
|
||||
if options["episode_range"]:
|
||||
from ..utils.parser import parse_episode_range
|
||||
|
||||
try:
|
||||
episodes_range = parse_episode_range(
|
||||
options["episode_range"], available_episodes
|
||||
)
|
||||
|
||||
for episode in episodes_range:
|
||||
stream_anime(
|
||||
config,
|
||||
provider,
|
||||
selector,
|
||||
feedback,
|
||||
anime,
|
||||
episode,
|
||||
anime_title,
|
||||
)
|
||||
except (ValueError, IndexError) as e:
|
||||
raise ViuError(f"Invalid episode range: {e}") from e
|
||||
else:
|
||||
episode = selector.choose(
|
||||
"Select Episode",
|
||||
getattr(anime.episodes, config.stream.translation_type),
|
||||
)
|
||||
if not episode:
|
||||
raise ViuError("No episode selected")
|
||||
stream_anime(
|
||||
config, provider, selector, feedback, anime, episode, anime_title
|
||||
)
|
||||
|
||||
|
||||
def stream_anime(
|
||||
config: AppConfig,
|
||||
provider: "BaseAnimeProvider",
|
||||
selector: "BaseSelector",
|
||||
feedback: "FeedbackService",
|
||||
anime: "Anime",
|
||||
episode: str,
|
||||
anime_title: str,
|
||||
):
|
||||
from viu.cli.service.player.service import PlayerService
|
||||
|
||||
from ...libs.player.params import PlayerParams
|
||||
from ...libs.provider.anime.params import EpisodeStreamsParams
|
||||
|
||||
player_service = PlayerService(config, provider)
|
||||
|
||||
with feedback.progress("Fetching episode streams"):
|
||||
streams = provider.episode_streams(
|
||||
EpisodeStreamsParams(
|
||||
anime_id=anime.id,
|
||||
query=anime_title,
|
||||
episode=episode,
|
||||
translation_type=config.stream.translation_type,
|
||||
)
|
||||
)
|
||||
if not streams:
|
||||
raise ViuError(
|
||||
f"Failed to get streams for anime: {anime.title}, episode: {episode}"
|
||||
)
|
||||
|
||||
if config.stream.server.value == "TOP":
|
||||
with feedback.progress("Fetching top server"):
|
||||
server = next(streams, None)
|
||||
if not server:
|
||||
raise ViuError(
|
||||
f"Failed to get server for anime: {anime.title}, episode: {episode}"
|
||||
)
|
||||
else:
|
||||
with feedback.progress("Fetching servers"):
|
||||
servers = {server.name: server for server in streams}
|
||||
servers_names = list(servers.keys())
|
||||
if config.stream.server.value in servers_names:
|
||||
server = servers[config.stream.server.value]
|
||||
else:
|
||||
server_name = selector.choose("Select Server", servers_names)
|
||||
if not server_name:
|
||||
raise ViuError("Server not selected")
|
||||
server = servers[server_name]
|
||||
stream_link = server.links[0].link
|
||||
if not stream_link:
|
||||
raise ViuError(
|
||||
f"Failed to get stream link for anime: {anime.title}, episode: {episode}"
|
||||
)
|
||||
feedback.info(f"[green bold]Now Streaming:[/] {anime.title} Episode: {episode}")
|
||||
|
||||
player_service.play(
|
||||
PlayerParams(
|
||||
url=stream_link,
|
||||
title=f"{anime.title}; Episode {episode}",
|
||||
query=anime_title,
|
||||
episode=episode,
|
||||
subtitles=[sub.url for sub in server.subtitles],
|
||||
headers=server.headers,
|
||||
),
|
||||
anime,
|
||||
)
|
||||
160
viu/cli/commands/update.py
Normal file
160
viu/cli/commands/update.py
Normal file
@@ -0,0 +1,160 @@
|
||||
"""Update command for Viu CLI."""
|
||||
|
||||
import sys
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import click
|
||||
from rich import print
|
||||
from rich.console import Console
|
||||
from rich.markdown import Markdown
|
||||
|
||||
from ..utils.update import check_for_updates, update_app
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ...core.config import AppConfig
|
||||
|
||||
|
||||
@click.command(
|
||||
help="Update Viu to the latest version",
|
||||
short_help="Update Viu",
|
||||
epilog="""
|
||||
\b
|
||||
\b\bExamples:
|
||||
# Check for updates and update if available
|
||||
viu update
|
||||
\b
|
||||
# Force update even if already up to date
|
||||
viu update --force
|
||||
\b
|
||||
# Only check for updates without updating
|
||||
viu update --check-only
|
||||
\b
|
||||
# Show release notes for the latest version
|
||||
viu update --release-notes
|
||||
""",
|
||||
)
|
||||
@click.option(
|
||||
"--force",
|
||||
"-f",
|
||||
is_flag=True,
|
||||
help="Force update even if already up to date",
|
||||
)
|
||||
@click.option(
|
||||
"--check-only",
|
||||
"-c",
|
||||
is_flag=True,
|
||||
help="Only check for updates without updating",
|
||||
)
|
||||
@click.option(
|
||||
"--release-notes",
|
||||
"-r",
|
||||
is_flag=True,
|
||||
help="Show release notes for the latest version",
|
||||
)
|
||||
@click.pass_context
|
||||
@click.pass_obj
|
||||
def update(
|
||||
config: "AppConfig",
|
||||
ctx: click.Context,
|
||||
force: bool,
|
||||
check_only: bool,
|
||||
release_notes: bool,
|
||||
) -> None:
|
||||
"""
|
||||
Update Viu to the latest version.
|
||||
|
||||
This command checks for available updates and optionally updates
|
||||
the application to the latest version from the configured sources
|
||||
(pip, uv, pipx, git, or nix depending on installation method).
|
||||
|
||||
Args:
|
||||
config: The application configuration object
|
||||
ctx: The click context containing CLI options
|
||||
force: Whether to force update even if already up to date
|
||||
check_only: Whether to only check for updates without updating
|
||||
release_notes: Whether to show release notes for the latest version
|
||||
"""
|
||||
try:
|
||||
if release_notes:
|
||||
print("[cyan]Fetching latest release notes...[/]")
|
||||
is_latest, release_json = check_for_updates()
|
||||
|
||||
if not release_json:
|
||||
print(
|
||||
"[yellow]Could not fetch release information. Please check your internet connection.[/]"
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
version = release_json.get("tag_name", "unknown")
|
||||
release_name = release_json.get("name", version)
|
||||
release_body = release_json.get("body", "No release notes available.")
|
||||
published_at = release_json.get("published_at", "unknown")
|
||||
|
||||
console = Console()
|
||||
|
||||
print(f"[bold cyan]Release: {release_name}[/]")
|
||||
print(f"[dim]Version: {version}[/]")
|
||||
print(f"[dim]Published: {published_at}[/]")
|
||||
print()
|
||||
|
||||
# Display release notes as markdown if available
|
||||
if release_body.strip():
|
||||
markdown = Markdown(release_body)
|
||||
console.print(markdown)
|
||||
else:
|
||||
print("[dim]No release notes available for this version.[/]")
|
||||
|
||||
return
|
||||
|
||||
elif check_only:
|
||||
print("[cyan]Checking for updates...[/]")
|
||||
is_latest, release_json = check_for_updates()
|
||||
|
||||
if not release_json:
|
||||
print(
|
||||
"[yellow]Could not check for updates. Please check your internet connection.[/]"
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
if is_latest:
|
||||
print("[green]Viu is up to date![/]")
|
||||
print(
|
||||
f"[dim]Current version: {release_json.get('tag_name', 'unknown')}[/]"
|
||||
)
|
||||
else:
|
||||
latest_version = release_json.get("tag_name", "unknown")
|
||||
print(f"[yellow]Update available: {latest_version}[/]")
|
||||
print("[dim]Run 'viu update' to update[/]")
|
||||
sys.exit(1)
|
||||
else:
|
||||
print("[cyan]Checking for updates and updating if necessary...[/]")
|
||||
success, release_json = update_app(force=force)
|
||||
|
||||
if not release_json:
|
||||
print(
|
||||
"[red]Could not check for updates. Please check your internet connection.[/]"
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
if success:
|
||||
latest_version = release_json.get("tag_name", "unknown")
|
||||
print(f"[green]Successfully updated to version {latest_version}![/]")
|
||||
else:
|
||||
if force:
|
||||
print(
|
||||
"[red]Update failed. Please check the error messages above.[/]"
|
||||
)
|
||||
sys.exit(1)
|
||||
# If not forced and update failed, it might be because already up to date
|
||||
# The update_app function already prints appropriate messages
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print("\n[yellow]Update cancelled by user.[/]")
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
print(f"[red]An error occurred during update: {e}[/]")
|
||||
# Get trace option from parent context
|
||||
trace = ctx.parent.params.get("trace", False) if ctx.parent else False
|
||||
if trace:
|
||||
raise
|
||||
sys.exit(1)
|
||||
47
viu/cli/commands/worker.py
Normal file
47
viu/cli/commands/worker.py
Normal file
@@ -0,0 +1,47 @@
|
||||
import click
|
||||
from viu.core.config import AppConfig
|
||||
|
||||
|
||||
@click.command(help="Run the background worker for notifications and downloads.")
|
||||
@click.pass_obj
|
||||
def worker(config: AppConfig):
|
||||
"""
|
||||
Starts the long-running background worker process.
|
||||
This process will periodically check for AniList notifications and
|
||||
process any queued downloads. It's recommended to run this in the
|
||||
background (e.g., 'viu worker &') or as a system service.
|
||||
"""
|
||||
from viu.cli.service.auth import AuthService
|
||||
from viu.cli.service.download.service import DownloadService
|
||||
from viu.cli.service.feedback import FeedbackService
|
||||
from viu.cli.service.notification.service import NotificationService
|
||||
from viu.cli.service.registry.service import MediaRegistryService
|
||||
from viu.cli.service.worker.service import BackgroundWorkerService
|
||||
from viu.libs.media_api.api import create_api_client
|
||||
from viu.libs.provider.anime.provider import create_provider
|
||||
|
||||
feedback = FeedbackService(config)
|
||||
if not config.worker.enabled:
|
||||
feedback.warning("Worker is disabled in the configuration. Exiting.")
|
||||
return
|
||||
|
||||
# Instantiate services
|
||||
media_api = create_api_client(config.general.media_api, config)
|
||||
# Authenticate if credentials exist (enables notifications)
|
||||
auth = AuthService(config.general.media_api)
|
||||
if profile := auth.get_auth():
|
||||
try:
|
||||
media_api.authenticate(profile.token)
|
||||
except Exception:
|
||||
pass
|
||||
provider = create_provider(config.general.provider)
|
||||
registry = MediaRegistryService(config.general.media_api, config.media_registry)
|
||||
|
||||
notification_service = NotificationService(config, media_api, registry)
|
||||
download_service = DownloadService(config, registry, media_api, provider)
|
||||
worker_service = BackgroundWorkerService(
|
||||
config.worker, notification_service, download_service
|
||||
)
|
||||
|
||||
feedback.info("Starting background worker...", "Press Ctrl+C to stop.")
|
||||
worker_service.run()
|
||||
4
viu/cli/config/__init__.py
Normal file
4
viu/cli/config/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from .generate import generate_config_ini_from_app_model
|
||||
from .loader import ConfigLoader
|
||||
|
||||
__all__ = ["ConfigLoader", "generate_config_ini_from_app_model"]
|
||||
143
viu/cli/config/editor.py
Normal file
143
viu/cli/config/editor.py
Normal file
@@ -0,0 +1,143 @@
|
||||
import textwrap
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal, get_args, get_origin
|
||||
|
||||
from InquirerPy import inquirer
|
||||
from InquirerPy.validator import NumberValidator
|
||||
from pydantic import BaseModel
|
||||
from pydantic.fields import FieldInfo
|
||||
from rich import print
|
||||
|
||||
from ...core.config.model import AppConfig
|
||||
|
||||
|
||||
class InteractiveConfigEditor:
|
||||
"""A wizard to guide users through setting up their configuration interactively."""
|
||||
|
||||
def __init__(self, current_config: AppConfig):
|
||||
self.config = current_config.model_copy(deep=True) # Work on a copy
|
||||
|
||||
def run(self) -> AppConfig:
|
||||
"""Starts the interactive configuration wizard."""
|
||||
print(
|
||||
"[bold cyan]Welcome to the Viu Interactive Configurator![/bold cyan]"
|
||||
)
|
||||
print("Let's set up your experience. Press Ctrl+C at any time to exit.")
|
||||
print("Current values will be shown as defaults.")
|
||||
|
||||
try:
|
||||
for section_name, section_model in self.config:
|
||||
if not isinstance(section_model, BaseModel):
|
||||
continue
|
||||
|
||||
if not inquirer.confirm(
|
||||
message=f"Configure '{section_name.title()}' settings?",
|
||||
default=True,
|
||||
).execute():
|
||||
continue
|
||||
|
||||
self._prompt_for_section(section_name, section_model)
|
||||
|
||||
print("\n[bold green]Configuration complete![/bold green]")
|
||||
return self.config
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print("\n[bold yellow]Configuration cancelled.[/bold yellow]")
|
||||
# Return original config if user cancels
|
||||
return self.config
|
||||
|
||||
def _prompt_for_section(self, section_name: str, section_model: BaseModel):
|
||||
"""Generates prompts for all fields in a given config section."""
|
||||
print(f"\n--- [bold magenta]{section_name.title()} Settings[/bold magenta] ---")
|
||||
|
||||
for field_name, field_info in section_model.model_fields.items():
|
||||
# Skip complex multi-line fields as agreed
|
||||
if section_name == "fzf" and field_name in ["opts", "header_ascii_art"]:
|
||||
continue
|
||||
|
||||
current_value = getattr(section_model, field_name)
|
||||
prompt = self._create_prompt(field_name, field_info, current_value)
|
||||
|
||||
if prompt:
|
||||
new_value = prompt.execute()
|
||||
|
||||
# Explicitly cast the value to the correct type before setting it.
|
||||
field_type = field_info.annotation
|
||||
if new_value is not None:
|
||||
if field_type is Path:
|
||||
new_value = Path(new_value).expanduser()
|
||||
elif field_type is int:
|
||||
new_value = int(new_value)
|
||||
elif field_type is float:
|
||||
new_value = float(new_value)
|
||||
|
||||
setattr(section_model, field_name, new_value)
|
||||
|
||||
def _create_prompt(
|
||||
self, field_name: str, field_info: FieldInfo, current_value: Any
|
||||
):
|
||||
"""Creates the appropriate InquirerPy prompt for a given Pydantic field."""
|
||||
field_type = field_info.annotation
|
||||
help_text = textwrap.fill(
|
||||
field_info.description or "No description available.", width=80
|
||||
)
|
||||
message = f"{field_name.replace('_', ' ').title()}:"
|
||||
|
||||
# Boolean fields
|
||||
if field_type is bool:
|
||||
return inquirer.confirm(
|
||||
message=message, default=current_value, long_instruction=help_text
|
||||
)
|
||||
|
||||
# Literal (Choice) fields
|
||||
if hasattr(field_type, "__origin__") and get_origin(field_type) is Literal:
|
||||
choices = list(get_args(field_type))
|
||||
return inquirer.select(
|
||||
message=message,
|
||||
choices=choices,
|
||||
default=current_value,
|
||||
long_instruction=help_text,
|
||||
)
|
||||
|
||||
# Numeric fields
|
||||
if field_type is int:
|
||||
return inquirer.number(
|
||||
message=message,
|
||||
default=int(current_value),
|
||||
long_instruction=help_text,
|
||||
min_allowed=getattr(field_info, "gt", None)
|
||||
or getattr(field_info, "ge", None),
|
||||
max_allowed=getattr(field_info, "lt", None)
|
||||
or getattr(field_info, "le", None),
|
||||
validate=NumberValidator(),
|
||||
)
|
||||
if field_type is float:
|
||||
return inquirer.number(
|
||||
message=message,
|
||||
default=float(current_value),
|
||||
float_allowed=True,
|
||||
long_instruction=help_text,
|
||||
)
|
||||
|
||||
# Path fields
|
||||
if field_type is Path:
|
||||
# Use text prompt for paths to allow '~' expansion, as FilePathPrompt can be tricky
|
||||
return inquirer.text(
|
||||
message=message, default=str(current_value), long_instruction=help_text
|
||||
)
|
||||
|
||||
# String fields
|
||||
if field_type is str:
|
||||
# Check for 'examples' to provide choices
|
||||
if hasattr(field_info, "examples") and field_info.examples:
|
||||
return inquirer.fuzzy(
|
||||
message=message,
|
||||
choices=field_info.examples,
|
||||
default=str(current_value),
|
||||
long_instruction=help_text,
|
||||
)
|
||||
return inquirer.text(
|
||||
message=message, default=str(current_value), long_instruction=help_text
|
||||
)
|
||||
|
||||
return None
|
||||
154
viu/cli/config/generate.py
Normal file
154
viu/cli/config/generate.py
Normal file
@@ -0,0 +1,154 @@
|
||||
import textwrap
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal, get_args, get_origin
|
||||
|
||||
from pydantic.fields import FieldInfo
|
||||
|
||||
from ...core.config import AppConfig
|
||||
from ...core.constants import APP_ASCII_ART, DISCORD_INVITE, PROJECT_NAME, REPO_HOME
|
||||
|
||||
# The header for the config file.
|
||||
config_asci = "\n".join(
|
||||
[f"# {line}" for line in APP_ASCII_ART.read_text(encoding="utf-8").split()]
|
||||
)
|
||||
CONFIG_HEADER = f"""
|
||||
# ==============================================================================
|
||||
#
|
||||
{config_asci}
|
||||
#
|
||||
# ==============================================================================
|
||||
# This file was auto-generated from the application's configuration model.
|
||||
# You can modify these values to customize the behavior of Viu.
|
||||
# For path-based options, you can use '~' for your home directory.
|
||||
""".lstrip()
|
||||
|
||||
CONFIG_FOOTER = f"""
|
||||
# ==============================================================================
|
||||
#
|
||||
# HOPE YOU ENJOY {PROJECT_NAME} AND BE SURE TO STAR THE PROJECT ON GITHUB
|
||||
# {REPO_HOME}
|
||||
#
|
||||
# Also join the discord server
|
||||
# where the anime tech community lives :)
|
||||
# {DISCORD_INVITE}
|
||||
#
|
||||
# ==============================================================================
|
||||
""".lstrip()
|
||||
|
||||
|
||||
def generate_config_ini_from_app_model(app_model: AppConfig) -> str:
|
||||
"""Generate a configuration file content from a Pydantic model."""
|
||||
|
||||
config_ini_content = [CONFIG_HEADER]
|
||||
|
||||
for section_name, section_model in app_model:
|
||||
section_comment = section_model.model_config.get("title", "")
|
||||
|
||||
config_ini_content.append(f"\n#\n# {section_comment}\n#")
|
||||
config_ini_content.append(f"[{section_name}]")
|
||||
|
||||
for field_name, field_info in section_model.model_fields.items():
|
||||
description = field_info.description or ""
|
||||
if description:
|
||||
wrapped_comment = textwrap.fill(
|
||||
description,
|
||||
width=78,
|
||||
initial_indent="# ",
|
||||
subsequent_indent="# ",
|
||||
)
|
||||
config_ini_content.append(f"\n{wrapped_comment}")
|
||||
|
||||
field_type_comment = _get_field_type_comment(field_info)
|
||||
if field_type_comment:
|
||||
wrapped_comment = textwrap.fill(
|
||||
field_type_comment,
|
||||
width=78,
|
||||
initial_indent="# ",
|
||||
subsequent_indent="# ",
|
||||
)
|
||||
config_ini_content.append(wrapped_comment)
|
||||
|
||||
field_value = getattr(section_model, field_name)
|
||||
if isinstance(field_value, bool):
|
||||
value_str = str(field_value).lower()
|
||||
elif isinstance(field_value, Path):
|
||||
value_str = str(field_value)
|
||||
elif field_value is None:
|
||||
value_str = ""
|
||||
elif isinstance(field_value, Enum):
|
||||
value_str = field_value.value
|
||||
else:
|
||||
value_str = str(field_value)
|
||||
|
||||
config_ini_content.append(f"{field_name} = {value_str}")
|
||||
|
||||
config_ini_content.extend(["\n", CONFIG_FOOTER])
|
||||
return "\n".join(config_ini_content)
|
||||
|
||||
|
||||
def _get_field_type_comment(field_info: FieldInfo) -> str:
|
||||
"""Generate a comment with type information for a field."""
|
||||
field_type = field_info.annotation
|
||||
|
||||
# Handle Literal and Enum types
|
||||
possible_values = []
|
||||
if field_type is not None:
|
||||
if isinstance(field_type, type) and issubclass(field_type, Enum):
|
||||
possible_values = [member.value for member in field_type]
|
||||
elif hasattr(field_type, "__origin__") and get_origin(field_type) is Literal:
|
||||
args = get_args(field_type)
|
||||
if args:
|
||||
possible_values = list(args)
|
||||
|
||||
if possible_values:
|
||||
return f"Possible values: [ {', '.join(map(str, possible_values))} ]"
|
||||
|
||||
# Handle basic types and numeric ranges
|
||||
type_name = _get_type_name(field_type)
|
||||
range_info = _get_range_info(field_info)
|
||||
|
||||
if range_info:
|
||||
return f"Type: {type_name} ({range_info})"
|
||||
elif type_name:
|
||||
return f"Type: {type_name}"
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
def _get_type_name(field_type: Any) -> str:
|
||||
"""Get a user-friendly name for a field's type."""
|
||||
if field_type is str:
|
||||
return "string"
|
||||
if field_type is int:
|
||||
return "integer"
|
||||
if field_type is float:
|
||||
return "float"
|
||||
if field_type is bool:
|
||||
return "boolean"
|
||||
if field_type is Path:
|
||||
return "path"
|
||||
return ""
|
||||
|
||||
|
||||
def _get_range_info(field_info: FieldInfo) -> str:
|
||||
"""Get a string describing the numeric range of a field."""
|
||||
constraints = {}
|
||||
if hasattr(field_info, "metadata") and field_info.metadata:
|
||||
for constraint in field_info.metadata:
|
||||
constraint_type = type(constraint).__name__
|
||||
if constraint_type == "Ge" and hasattr(constraint, "ge"):
|
||||
constraints["min"] = constraint.ge
|
||||
elif constraint_type == "Le" and hasattr(constraint, "le"):
|
||||
constraints["max"] = constraint.le
|
||||
elif constraint_type == "Gt" and hasattr(constraint, "gt"):
|
||||
constraints["min"] = constraint.gt + 1
|
||||
elif constraint_type == "Lt" and hasattr(constraint, "lt"):
|
||||
constraints["max"] = constraint.lt - 1
|
||||
|
||||
if constraints:
|
||||
min_val = constraints.get("min", "N/A")
|
||||
max_val = constraints.get("max", "N/A")
|
||||
return f"Range: {min_val}-{max_val}"
|
||||
|
||||
return ""
|
||||
115
viu/cli/config/loader.py
Normal file
115
viu/cli/config/loader.py
Normal file
@@ -0,0 +1,115 @@
|
||||
import configparser
|
||||
from pathlib import Path
|
||||
from typing import Dict
|
||||
|
||||
import click
|
||||
from pydantic import ValidationError
|
||||
|
||||
from ...core.config import AppConfig
|
||||
from ...core.constants import USER_CONFIG
|
||||
from ...core.exceptions import ConfigError
|
||||
|
||||
|
||||
class ConfigLoader:
|
||||
"""
|
||||
Handles loading the application configuration from an .ini file.
|
||||
|
||||
It ensures a default configuration exists, reads the .ini file,
|
||||
and uses Pydantic to parse and validate the data into a type-safe
|
||||
AppConfig object.
|
||||
"""
|
||||
|
||||
def __init__(self, config_path: Path = USER_CONFIG):
|
||||
"""
|
||||
Initializes the loader with the path to the configuration file.
|
||||
|
||||
Args:
|
||||
config_path: The path to the user's config.ini file.
|
||||
"""
|
||||
self.config_path = config_path
|
||||
self.parser = configparser.ConfigParser(
|
||||
interpolation=None,
|
||||
# Allow boolean values without a corresponding value (e.g., `enabled` vs `enabled = true`)
|
||||
allow_no_value=True,
|
||||
# Behave like a dictionary, preserving case sensitivity of keys
|
||||
dict_type=dict,
|
||||
)
|
||||
|
||||
def _handle_first_run(self) -> AppConfig:
|
||||
"""Handles the configuration process when no config file is found."""
|
||||
click.echo(
|
||||
"[bold yellow]Welcome to Viu![/bold yellow] No configuration file found."
|
||||
)
|
||||
from InquirerPy import inquirer
|
||||
|
||||
from .editor import InteractiveConfigEditor
|
||||
from .generate import generate_config_ini_from_app_model
|
||||
|
||||
choice = inquirer.select( # type: ignore
|
||||
message="How would you like to proceed?",
|
||||
choices=[
|
||||
"Use default settings (Recommended for new users)",
|
||||
"Configure settings interactively",
|
||||
],
|
||||
default="Use default settings (Recommended for new users)",
|
||||
).execute()
|
||||
|
||||
if "interactively" in choice:
|
||||
editor = InteractiveConfigEditor(AppConfig())
|
||||
app_config = editor.run()
|
||||
else:
|
||||
app_config = AppConfig()
|
||||
|
||||
config_ini_content = generate_config_ini_from_app_model(app_config)
|
||||
try:
|
||||
self.config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self.config_path.write_text(config_ini_content, encoding="utf-8")
|
||||
click.echo(
|
||||
f"Configuration file created at: [green]{self.config_path}[/green]"
|
||||
)
|
||||
except Exception as e:
|
||||
raise ConfigError(
|
||||
f"Could not create configuration file at {self.config_path!s}. Please check permissions. Error: {e}",
|
||||
)
|
||||
|
||||
return app_config
|
||||
|
||||
def load(self, update: Dict = {}) -> AppConfig:
|
||||
"""
|
||||
Loads the configuration and returns a populated, validated AppConfig object.
|
||||
|
||||
Returns:
|
||||
An instance of AppConfig with values from the user's .ini file.
|
||||
|
||||
Raises:
|
||||
click.ClickException: If the configuration file contains validation errors.
|
||||
"""
|
||||
if not self.config_path.exists():
|
||||
return self._handle_first_run()
|
||||
|
||||
try:
|
||||
self.parser.read(self.config_path, encoding="utf-8")
|
||||
except configparser.Error as e:
|
||||
raise ConfigError(
|
||||
f"Error parsing configuration file '{self.config_path}':\n{e}"
|
||||
)
|
||||
|
||||
# Convert the configparser object into a nested dictionary that mirrors
|
||||
# the structure of our AppConfig Pydantic model.
|
||||
config_dict = {
|
||||
section: dict(self.parser.items(section))
|
||||
for section in self.parser.sections()
|
||||
}
|
||||
if update:
|
||||
for key in config_dict:
|
||||
if key in update:
|
||||
config_dict[key].update(update[key])
|
||||
try:
|
||||
app_config = AppConfig.model_validate(config_dict)
|
||||
return app_config
|
||||
except ValidationError as e:
|
||||
error_message = (
|
||||
f"Configuration error in '{self.config_path}'!\n"
|
||||
f"Please correct the following issues:\n\n{e}"
|
||||
)
|
||||
raise ConfigError(error_message)
|
||||
92
viu/cli/interactive/menu/media/download_episodes.py
Normal file
92
viu/cli/interactive/menu/media/download_episodes.py
Normal file
@@ -0,0 +1,92 @@
|
||||
from .....libs.provider.anime.params import AnimeParams, SearchParams
|
||||
from ...session import Context, session
|
||||
from ...state import InternalDirective, State
|
||||
|
||||
|
||||
@session.menu
|
||||
def download_episodes(ctx: Context, state: State) -> State | InternalDirective:
|
||||
"""Menu to select and download episodes synchronously."""
|
||||
from .....core.utils.fuzzy import fuzz
|
||||
from .....core.utils.normalizer import normalize_title
|
||||
from ....service.download.service import DownloadService
|
||||
|
||||
feedback = ctx.feedback
|
||||
selector = ctx.selector
|
||||
media_item = state.media_api.media_item
|
||||
config = ctx.config
|
||||
provider = ctx.provider
|
||||
|
||||
if not media_item:
|
||||
feedback.error("No media item selected for download.")
|
||||
return InternalDirective.BACK
|
||||
|
||||
media_title = media_item.title.english or media_item.title.romaji
|
||||
if not media_title:
|
||||
feedback.error("Cannot download: Media item has no title.")
|
||||
return InternalDirective.BACK
|
||||
|
||||
# Step 1: Find the anime on the provider to get a full episode list
|
||||
with feedback.progress(
|
||||
f"Searching for '{media_title}' on {provider.__class__.__name__}..."
|
||||
):
|
||||
provider_search_results = provider.search(
|
||||
SearchParams(
|
||||
query=normalize_title(media_title, config.general.provider.value, True)
|
||||
)
|
||||
)
|
||||
|
||||
if not provider_search_results or not provider_search_results.results:
|
||||
feedback.warning(f"Could not find '{media_title}' on provider.")
|
||||
return InternalDirective.BACK
|
||||
|
||||
provider_results_map = {res.title: res for res in provider_search_results.results}
|
||||
best_match_title = max(
|
||||
provider_results_map.keys(),
|
||||
key=lambda p_title: fuzz.ratio(
|
||||
normalize_title(p_title, config.general.provider.value).lower(),
|
||||
media_title.lower(),
|
||||
),
|
||||
)
|
||||
selected_provider_anime_ref = provider_results_map[best_match_title]
|
||||
|
||||
with feedback.progress(f"Fetching episode list for '{best_match_title}'..."):
|
||||
full_provider_anime = provider.get(
|
||||
AnimeParams(id=selected_provider_anime_ref.id, query=media_title)
|
||||
)
|
||||
|
||||
if not full_provider_anime:
|
||||
feedback.warning(f"Failed to fetch details for '{best_match_title}'.")
|
||||
return InternalDirective.BACK
|
||||
|
||||
available_episodes = getattr(
|
||||
full_provider_anime.episodes, config.stream.translation_type, []
|
||||
)
|
||||
if not available_episodes:
|
||||
feedback.warning("No episodes found for download.")
|
||||
return InternalDirective.BACK
|
||||
|
||||
# Step 2: Let user select episodes
|
||||
selected_episodes = selector.choose_multiple(
|
||||
"Select episodes to download (TAB to select, ENTER to confirm)",
|
||||
choices=available_episodes,
|
||||
)
|
||||
|
||||
if not selected_episodes:
|
||||
feedback.info("No episodes selected for download.")
|
||||
return InternalDirective.BACK
|
||||
|
||||
# Step 3: Download episodes synchronously
|
||||
# TODO: move to main ctx
|
||||
download_service = DownloadService(
|
||||
config, ctx.media_registry, ctx.media_api, ctx.provider
|
||||
)
|
||||
|
||||
feedback.info(
|
||||
f"Starting download of {len(selected_episodes)} episodes. This may take a while..."
|
||||
)
|
||||
download_service.download_episodes_sync(media_item, selected_episodes)
|
||||
|
||||
feedback.success(f"Finished downloading {len(selected_episodes)} episodes.")
|
||||
|
||||
# After downloading, return to the media actions menu
|
||||
return InternalDirective.BACK
|
||||
243
viu/cli/interactive/menu/media/downloads.py
Normal file
243
viu/cli/interactive/menu/media/downloads.py
Normal file
@@ -0,0 +1,243 @@
|
||||
import logging
|
||||
import random
|
||||
from typing import Callable, Dict
|
||||
|
||||
from .....libs.media_api.params import MediaSearchParams
|
||||
from .....libs.media_api.types import (
|
||||
MediaSort,
|
||||
MediaStatus,
|
||||
UserMediaListStatus,
|
||||
)
|
||||
from ...session import Context, session
|
||||
from ...state import InternalDirective, MediaApiState, MenuName, State
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
MenuAction = Callable[[], State | InternalDirective]
|
||||
|
||||
|
||||
@session.menu
|
||||
def downloads(ctx: Context, state: State) -> State | InternalDirective:
|
||||
"""Downloads menu showing locally stored media from registry."""
|
||||
icons = ctx.config.general.icons
|
||||
feedback = ctx.feedback
|
||||
feedback.clear_console()
|
||||
|
||||
options: Dict[str, MenuAction] = {
|
||||
f"{'🔥 ' if icons else ''}Trending (Local)": _create_local_media_list_action(
|
||||
ctx, state, MediaSort.TRENDING_DESC
|
||||
),
|
||||
f"{'🎞️ ' if icons else ''}Recent (Local)": _create_local_recent_media_action(
|
||||
ctx, state
|
||||
),
|
||||
f"{'📺 ' if icons else ''}Watching (Local)": _create_local_status_action(
|
||||
ctx, state, UserMediaListStatus.WATCHING
|
||||
),
|
||||
f"{'🔁 ' if icons else ''}Rewatching (Local)": _create_local_status_action(
|
||||
ctx, state, UserMediaListStatus.REPEATING
|
||||
),
|
||||
f"{'⏸️ ' if icons else ''}Paused (Local)": _create_local_status_action(
|
||||
ctx, state, UserMediaListStatus.PAUSED
|
||||
),
|
||||
f"{'📑 ' if icons else ''}Planned (Local)": _create_local_status_action(
|
||||
ctx, state, UserMediaListStatus.PLANNING
|
||||
),
|
||||
f"{'🔎 ' if icons else ''}Search (Local)": _create_local_search_media_list(
|
||||
ctx, state
|
||||
),
|
||||
f"{'🔔 ' if icons else ''}Recently Updated (Local)": _create_local_media_list_action(
|
||||
ctx, state, MediaSort.UPDATED_AT_DESC
|
||||
),
|
||||
f"{'✨ ' if icons else ''}Popular (Local)": _create_local_media_list_action(
|
||||
ctx, state, MediaSort.POPULARITY_DESC
|
||||
),
|
||||
f"{'💯 ' if icons else ''}Top Scored (Local)": _create_local_media_list_action(
|
||||
ctx, state, MediaSort.SCORE_DESC
|
||||
),
|
||||
f"{'💖 ' if icons else ''}Favourites (Local)": _create_local_media_list_action(
|
||||
ctx, state, MediaSort.FAVOURITES_DESC
|
||||
),
|
||||
f"{'🎲 ' if icons else ''}Random (Local)": _create_local_random_media_list(
|
||||
ctx, state
|
||||
),
|
||||
f"{'🎬 ' if icons else ''}Upcoming (Local)": _create_local_media_list_action(
|
||||
ctx, state, MediaSort.POPULARITY_DESC, MediaStatus.NOT_YET_RELEASED
|
||||
),
|
||||
f"{'✅ ' if icons else ''}Completed (Local)": _create_local_status_action(
|
||||
ctx, state, UserMediaListStatus.COMPLETED
|
||||
),
|
||||
f"{'🚮 ' if icons else ''}Dropped (Local)": _create_local_status_action(
|
||||
ctx, state, UserMediaListStatus.DROPPED
|
||||
),
|
||||
f"{'↩️ ' if icons else ''}Back to Main": lambda: InternalDirective.BACK,
|
||||
f"{'❌ ' if icons else ''}Exit": lambda: InternalDirective.EXIT,
|
||||
}
|
||||
|
||||
choice = ctx.selector.choose(
|
||||
prompt="Select Downloads Category",
|
||||
choices=list(options.keys()),
|
||||
)
|
||||
if not choice:
|
||||
return InternalDirective.RELOAD
|
||||
|
||||
selected_action = options[choice]
|
||||
next_step = selected_action()
|
||||
return next_step
|
||||
|
||||
|
||||
def _create_local_media_list_action(
|
||||
ctx: Context, state: State, sort: MediaSort, status: MediaStatus | None = None
|
||||
) -> MenuAction:
|
||||
"""Create action for searching local media with sorting and optional status filter."""
|
||||
|
||||
def action():
|
||||
feedback = ctx.feedback
|
||||
search_params = MediaSearchParams(sort=sort, status=status)
|
||||
|
||||
loading_message = "Searching local media registry"
|
||||
result = None
|
||||
with feedback.progress(loading_message):
|
||||
result = ctx.media_registry.search_for_media(search_params)
|
||||
|
||||
if result and result.media:
|
||||
return State(
|
||||
menu_name=MenuName.RESULTS,
|
||||
media_api=MediaApiState(
|
||||
search_result={
|
||||
media_item.id: media_item for media_item in result.media
|
||||
},
|
||||
search_params=search_params,
|
||||
page_info=result.page_info,
|
||||
),
|
||||
)
|
||||
else:
|
||||
feedback.info("No media found in local registry")
|
||||
return InternalDirective.RELOAD
|
||||
|
||||
return action
|
||||
|
||||
|
||||
def _create_local_random_media_list(ctx: Context, state: State) -> MenuAction:
|
||||
"""Create action for getting random local media."""
|
||||
|
||||
def action():
|
||||
feedback = ctx.feedback
|
||||
|
||||
loading_message = "Getting random local media"
|
||||
with feedback.progress(loading_message):
|
||||
# Get all records and pick random ones
|
||||
all_records = list(ctx.media_registry.get_all_media_records())
|
||||
|
||||
if not all_records:
|
||||
feedback.info("No media found in local registry")
|
||||
return InternalDirective.BACK
|
||||
|
||||
# Get up to 50 random records
|
||||
random_records = random.sample(all_records, min(50, len(all_records)))
|
||||
random_ids = [record.media_item.id for record in random_records]
|
||||
|
||||
search_params = MediaSearchParams(id_in=random_ids)
|
||||
result = ctx.media_registry.search_for_media(search_params)
|
||||
|
||||
if result and result.media:
|
||||
return State(
|
||||
menu_name=MenuName.RESULTS,
|
||||
media_api=MediaApiState(
|
||||
search_result={
|
||||
media_item.id: media_item for media_item in result.media
|
||||
},
|
||||
search_params=search_params,
|
||||
page_info=result.page_info,
|
||||
),
|
||||
)
|
||||
else:
|
||||
feedback.info("No media found in local registry")
|
||||
return InternalDirective.RELOAD
|
||||
|
||||
return action
|
||||
|
||||
|
||||
def _create_local_search_media_list(ctx: Context, state: State) -> MenuAction:
|
||||
"""Create action for searching local media by query."""
|
||||
|
||||
def action():
|
||||
feedback = ctx.feedback
|
||||
|
||||
query = ctx.selector.ask("Search Local Anime")
|
||||
if not query:
|
||||
return InternalDirective.BACK
|
||||
|
||||
search_params = MediaSearchParams(query=query)
|
||||
|
||||
loading_message = "Searching local media registry"
|
||||
result = None
|
||||
with feedback.progress(loading_message):
|
||||
result = ctx.media_registry.search_for_media(search_params)
|
||||
|
||||
if result and result.media:
|
||||
return State(
|
||||
menu_name=MenuName.RESULTS,
|
||||
media_api=MediaApiState(
|
||||
search_result={
|
||||
media_item.id: media_item for media_item in result.media
|
||||
},
|
||||
search_params=search_params,
|
||||
page_info=result.page_info,
|
||||
),
|
||||
)
|
||||
else:
|
||||
feedback.info("No media found in local registry")
|
||||
return InternalDirective.RELOAD
|
||||
|
||||
return action
|
||||
|
||||
|
||||
def _create_local_status_action(
|
||||
ctx: Context, state: State, status: UserMediaListStatus
|
||||
) -> MenuAction:
|
||||
"""Create action for getting local media by user status."""
|
||||
|
||||
def action():
|
||||
feedback = ctx.feedback
|
||||
|
||||
loading_message = f"Getting {status.value} media from local registry"
|
||||
result = None
|
||||
with feedback.progress(loading_message):
|
||||
result = ctx.media_registry.get_media_by_status(status)
|
||||
|
||||
if result and result.media:
|
||||
return State(
|
||||
menu_name=MenuName.RESULTS,
|
||||
media_api=MediaApiState(
|
||||
search_result={
|
||||
media_item.id: media_item for media_item in result.media
|
||||
},
|
||||
page_info=result.page_info,
|
||||
),
|
||||
)
|
||||
else:
|
||||
feedback.info(f"No {status.value} media found in local registry")
|
||||
return InternalDirective.RELOAD
|
||||
|
||||
return action
|
||||
|
||||
|
||||
def _create_local_recent_media_action(ctx: Context, state: State) -> MenuAction:
|
||||
"""Create action for getting recently watched local media."""
|
||||
|
||||
def action():
|
||||
result = ctx.media_registry.get_recently_watched()
|
||||
if result and result.media:
|
||||
return State(
|
||||
menu_name=MenuName.RESULTS,
|
||||
media_api=MediaApiState(
|
||||
search_result={
|
||||
media_item.id: media_item for media_item in result.media
|
||||
},
|
||||
page_info=result.page_info,
|
||||
),
|
||||
)
|
||||
else:
|
||||
ctx.feedback.info("No recently watched media found in local registry")
|
||||
return InternalDirective.RELOAD
|
||||
|
||||
return action
|
||||
119
viu/cli/interactive/menu/media/dynamic_search.py
Normal file
119
viu/cli/interactive/menu/media/dynamic_search.py
Normal file
@@ -0,0 +1,119 @@
|
||||
import json
|
||||
import logging
|
||||
|
||||
from .....core.constants import APP_CACHE_DIR, SCRIPTS_DIR
|
||||
from .....libs.media_api.params import MediaSearchParams
|
||||
from ...session import Context, session
|
||||
from ...state import InternalDirective, MediaApiState, MenuName, State
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SEARCH_CACHE_DIR = APP_CACHE_DIR / "search"
|
||||
SEARCH_RESULTS_FILE = SEARCH_CACHE_DIR / "current_search_results.json"
|
||||
FZF_SCRIPTS_DIR = SCRIPTS_DIR / "fzf"
|
||||
SEARCH_TEMPLATE_SCRIPT = (FZF_SCRIPTS_DIR / "search.template.sh").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
|
||||
|
||||
@session.menu
|
||||
def dynamic_search(ctx: Context, state: State) -> State | InternalDirective:
|
||||
"""Dynamic search menu that provides real-time search results."""
|
||||
feedback = ctx.feedback
|
||||
feedback.clear_console()
|
||||
|
||||
# Ensure cache directory exists
|
||||
SEARCH_CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Read the GraphQL search query
|
||||
from .....libs.media_api.anilist import gql
|
||||
|
||||
search_query = gql.SEARCH_MEDIA.read_text(encoding="utf-8")
|
||||
# Properly escape the GraphQL query for JSON
|
||||
search_query_escaped = json.dumps(search_query)
|
||||
|
||||
# Prepare the search script
|
||||
auth_header = ""
|
||||
profile = ctx.auth.get_auth()
|
||||
if ctx.media_api.is_authenticated() and profile:
|
||||
auth_header = f"Bearer {profile.token}"
|
||||
|
||||
search_command = SEARCH_TEMPLATE_SCRIPT
|
||||
|
||||
replacements = {
|
||||
"GRAPHQL_ENDPOINT": "https://graphql.anilist.co",
|
||||
"GRAPHQL_QUERY": search_query_escaped,
|
||||
"CACHE_DIR": str(SEARCH_CACHE_DIR),
|
||||
"SEARCH_RESULTS_FILE": str(SEARCH_RESULTS_FILE),
|
||||
"AUTH_HEADER": auth_header,
|
||||
}
|
||||
|
||||
for key, value in replacements.items():
|
||||
search_command = search_command.replace(f"{{{key}}}", str(value))
|
||||
|
||||
try:
|
||||
# Prepare preview functionality
|
||||
preview_command = None
|
||||
if ctx.config.general.preview != "none":
|
||||
from ....utils.preview import create_preview_context
|
||||
|
||||
with create_preview_context() as preview_ctx:
|
||||
preview_command = preview_ctx.get_dynamic_anime_preview(ctx.config)
|
||||
|
||||
choice = ctx.selector.search(
|
||||
prompt="Search Anime",
|
||||
search_command=search_command,
|
||||
preview=preview_command,
|
||||
)
|
||||
else:
|
||||
choice = ctx.selector.search(
|
||||
prompt="Search Anime",
|
||||
search_command=search_command,
|
||||
)
|
||||
except NotImplementedError:
|
||||
feedback.error("Dynamic search is not supported by your current selector")
|
||||
feedback.info("Please use the regular search option or switch to fzf selector")
|
||||
return InternalDirective.MAIN
|
||||
|
||||
if not choice:
|
||||
return InternalDirective.MAIN
|
||||
|
||||
# Read the cached search results
|
||||
if not SEARCH_RESULTS_FILE.exists():
|
||||
logger.error("Search results file not found")
|
||||
return InternalDirective.MAIN
|
||||
|
||||
with open(SEARCH_RESULTS_FILE, "r", encoding="utf-8") as f:
|
||||
raw_data = json.load(f)
|
||||
|
||||
# Transform the raw data into MediaSearchResult
|
||||
search_result = ctx.media_api.transform_raw_search_data(raw_data)
|
||||
|
||||
if not search_result or not search_result.media:
|
||||
feedback.info("No results found")
|
||||
return InternalDirective.MAIN
|
||||
|
||||
# Find the selected media item by matching the choice with the displayed format
|
||||
selected_media = None
|
||||
for media_item in search_result.media:
|
||||
if (
|
||||
media_item.title.english == choice.strip()
|
||||
or media_item.title.romaji == choice.strip()
|
||||
):
|
||||
selected_media = media_item
|
||||
break
|
||||
|
||||
if not selected_media:
|
||||
logger.error(f"Could not find selected media for choice: {choice}")
|
||||
return InternalDirective.MAIN
|
||||
|
||||
# Navigate to media actions with the selected item
|
||||
return State(
|
||||
menu_name=MenuName.MEDIA_ACTIONS,
|
||||
media_api=MediaApiState(
|
||||
search_result={media.id: media for media in search_result.media},
|
||||
media_id=selected_media.id,
|
||||
search_params=MediaSearchParams(),
|
||||
page_info=search_result.page_info,
|
||||
),
|
||||
)
|
||||
77
viu/cli/interactive/menu/media/episodes.py
Normal file
77
viu/cli/interactive/menu/media/episodes.py
Normal file
@@ -0,0 +1,77 @@
|
||||
from ...session import Context, session
|
||||
from ...state import InternalDirective, MenuName, State
|
||||
|
||||
|
||||
@session.menu
|
||||
def episodes(ctx: Context, state: State) -> State | InternalDirective:
|
||||
"""
|
||||
Displays available episodes for a selected provider anime and handles
|
||||
the logic for continuing from watch history or manual selection.
|
||||
"""
|
||||
config = ctx.config
|
||||
feedback = ctx.feedback
|
||||
feedback.clear_console()
|
||||
|
||||
provider_anime = state.provider.anime
|
||||
media_item = state.media_api.media_item
|
||||
|
||||
if not provider_anime or not media_item:
|
||||
feedback.error("Error: Anime details are missing.")
|
||||
return InternalDirective.BACK
|
||||
|
||||
available_episodes = getattr(
|
||||
provider_anime.episodes, config.stream.translation_type, []
|
||||
)
|
||||
if not available_episodes:
|
||||
feedback.warning(
|
||||
f"No '{config.stream.translation_type}' episodes found for this anime."
|
||||
)
|
||||
return InternalDirective.BACKX2
|
||||
|
||||
chosen_episode: str | None = None
|
||||
start_time: str | None = None
|
||||
|
||||
if config.stream.continue_from_watch_history:
|
||||
chosen_episode, start_time = ctx.watch_history.get_episode(media_item)
|
||||
|
||||
if not chosen_episode or ctx.switch.show_episodes_menu:
|
||||
choices = [*available_episodes, "Back"]
|
||||
|
||||
preview_command = None
|
||||
if ctx.config.general.preview != "none":
|
||||
from ....utils.preview import create_preview_context
|
||||
|
||||
with create_preview_context() as preview_ctx:
|
||||
preview_command = preview_ctx.get_episode_preview(
|
||||
available_episodes, media_item, ctx.config
|
||||
)
|
||||
|
||||
chosen_episode_str = ctx.selector.choose(
|
||||
prompt="Select Episode", choices=choices, preview=preview_command
|
||||
)
|
||||
|
||||
if not chosen_episode_str or chosen_episode_str == "Back":
|
||||
# TODO: should improve the back logic for menus that can be pass through
|
||||
return InternalDirective.BACKX2
|
||||
|
||||
chosen_episode = chosen_episode_str
|
||||
# Workers are automatically cleaned up when exiting the context
|
||||
else:
|
||||
# No preview mode
|
||||
chosen_episode_str = ctx.selector.choose(
|
||||
prompt="Select Episode", choices=choices, preview=None
|
||||
)
|
||||
|
||||
if not chosen_episode_str or chosen_episode_str == "Back":
|
||||
# TODO: should improve the back logic for menus that can be pass through
|
||||
return InternalDirective.BACKX2
|
||||
|
||||
chosen_episode = chosen_episode_str
|
||||
|
||||
return State(
|
||||
menu_name=MenuName.SERVERS,
|
||||
media_api=state.media_api,
|
||||
provider=state.provider.model_copy(
|
||||
update={"episode": chosen_episode, "start_time": start_time}
|
||||
),
|
||||
)
|
||||
242
viu/cli/interactive/menu/media/main.py
Normal file
242
viu/cli/interactive/menu/media/main.py
Normal file
@@ -0,0 +1,242 @@
|
||||
import logging
|
||||
import random
|
||||
from typing import Callable, Dict
|
||||
|
||||
from .....libs.media_api.params import MediaSearchParams, UserMediaListSearchParams
|
||||
from .....libs.media_api.types import (
|
||||
MediaSort,
|
||||
MediaStatus,
|
||||
UserMediaListStatus,
|
||||
)
|
||||
from ...session import Context, session
|
||||
from ...state import InternalDirective, MediaApiState, MenuName, State
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
MenuAction = Callable[[], State | InternalDirective]
|
||||
|
||||
|
||||
@session.menu
|
||||
def main(ctx: Context, state: State) -> State | InternalDirective:
|
||||
icons = ctx.config.general.icons
|
||||
feedback = ctx.feedback
|
||||
feedback.clear_console()
|
||||
|
||||
options: Dict[str, MenuAction] = {
|
||||
f"{'🔥 ' if icons else ''}Trending": _create_media_list_action(
|
||||
ctx, state, MediaSort.TRENDING_DESC
|
||||
),
|
||||
f"{'🎞️ ' if icons else ''}Recent": _create_recent_media_action(ctx, state),
|
||||
f"{'📺 ' if icons else ''}Watching": _create_user_list_action(
|
||||
ctx, state, UserMediaListStatus.WATCHING
|
||||
),
|
||||
f"{'🔁 ' if icons else ''}Rewatching": _create_user_list_action(
|
||||
ctx, state, UserMediaListStatus.REPEATING
|
||||
),
|
||||
f"{'⏸️ ' if icons else ''}Paused": _create_user_list_action(
|
||||
ctx, state, UserMediaListStatus.PAUSED
|
||||
),
|
||||
f"{'📑 ' if icons else ''}Planned": _create_user_list_action(
|
||||
ctx, state, UserMediaListStatus.PLANNING
|
||||
),
|
||||
f"{'🔎 ' if icons else ''}Search": _create_search_media_list(ctx, state),
|
||||
f"{'🔍 ' if icons else ''}Dynamic Search": _create_dynamic_search_action(
|
||||
ctx, state
|
||||
),
|
||||
f"{'🏠 ' if icons else ''}Downloads": _create_downloads_action(ctx, state),
|
||||
f"{'🔔 ' if icons else ''}Recently Updated": _create_media_list_action(
|
||||
ctx, state, MediaSort.UPDATED_AT_DESC
|
||||
),
|
||||
f"{'✨ ' if icons else ''}Popular": _create_media_list_action(
|
||||
ctx, state, MediaSort.POPULARITY_DESC
|
||||
),
|
||||
f"{'💯 ' if icons else ''}Top Scored": _create_media_list_action(
|
||||
ctx, state, MediaSort.SCORE_DESC
|
||||
),
|
||||
f"{'💖 ' if icons else ''}Favourites": _create_media_list_action(
|
||||
ctx, state, MediaSort.FAVOURITES_DESC
|
||||
),
|
||||
f"{'🎲 ' if icons else ''}Random": _create_random_media_list(ctx, state),
|
||||
f"{'🎬 ' if icons else ''}Upcoming": _create_media_list_action(
|
||||
ctx, state, MediaSort.POPULARITY_DESC, MediaStatus.NOT_YET_RELEASED
|
||||
),
|
||||
f"{'✅ ' if icons else ''}Completed": _create_user_list_action(
|
||||
ctx, state, UserMediaListStatus.COMPLETED
|
||||
),
|
||||
f"{'🚮 ' if icons else ''}Dropped": _create_user_list_action(
|
||||
ctx, state, UserMediaListStatus.DROPPED
|
||||
),
|
||||
f"{'📝 ' if icons else ''}Edit Config": lambda: InternalDirective.CONFIG_EDIT,
|
||||
f"{'❌ ' if icons else ''}Exit": lambda: InternalDirective.EXIT,
|
||||
}
|
||||
|
||||
choice = ctx.selector.choose(
|
||||
prompt="Select Category",
|
||||
choices=list(options.keys()),
|
||||
)
|
||||
if not choice:
|
||||
return InternalDirective.MAIN
|
||||
|
||||
selected_action = options[choice]
|
||||
|
||||
next_step = selected_action()
|
||||
return next_step
|
||||
|
||||
|
||||
def _create_media_list_action(
|
||||
ctx: Context, state: State, sort: MediaSort, status: MediaStatus | None = None
|
||||
) -> MenuAction:
|
||||
def action():
|
||||
feedback = ctx.feedback
|
||||
search_params = MediaSearchParams(sort=sort, status=status)
|
||||
|
||||
loading_message = "Fetching media list"
|
||||
result = None
|
||||
with feedback.progress(loading_message):
|
||||
result = ctx.media_api.search_media(search_params)
|
||||
|
||||
if result:
|
||||
return State(
|
||||
menu_name=MenuName.RESULTS,
|
||||
media_api=MediaApiState(
|
||||
search_result={
|
||||
media_item.id: media_item for media_item in result.media
|
||||
},
|
||||
search_params=search_params,
|
||||
page_info=result.page_info,
|
||||
),
|
||||
)
|
||||
else:
|
||||
return InternalDirective.MAIN
|
||||
|
||||
return action
|
||||
|
||||
|
||||
def _create_random_media_list(ctx: Context, state: State) -> MenuAction:
|
||||
def action():
|
||||
feedback = ctx.feedback
|
||||
search_params = MediaSearchParams(id_in=random.sample(range(1, 15000), k=50))
|
||||
|
||||
loading_message = "Fetching media list"
|
||||
result = None
|
||||
with feedback.progress(loading_message):
|
||||
result = ctx.media_api.search_media(search_params)
|
||||
|
||||
if result:
|
||||
return State(
|
||||
menu_name=MenuName.RESULTS,
|
||||
media_api=MediaApiState(
|
||||
search_result={
|
||||
media_item.id: media_item for media_item in result.media
|
||||
},
|
||||
search_params=search_params,
|
||||
page_info=result.page_info,
|
||||
),
|
||||
)
|
||||
else:
|
||||
return InternalDirective.MAIN
|
||||
|
||||
return action
|
||||
|
||||
|
||||
def _create_search_media_list(ctx: Context, state: State) -> MenuAction:
|
||||
def action():
|
||||
feedback = ctx.feedback
|
||||
|
||||
query = ctx.selector.ask("Search for Anime")
|
||||
if not query:
|
||||
return InternalDirective.MAIN
|
||||
|
||||
search_params = MediaSearchParams(query=query)
|
||||
|
||||
loading_message = "Fetching media list"
|
||||
result = None
|
||||
with feedback.progress(loading_message):
|
||||
result = ctx.media_api.search_media(search_params)
|
||||
|
||||
if result:
|
||||
return State(
|
||||
menu_name=MenuName.RESULTS,
|
||||
media_api=MediaApiState(
|
||||
search_result={
|
||||
media_item.id: media_item for media_item in result.media
|
||||
},
|
||||
search_params=search_params,
|
||||
page_info=result.page_info,
|
||||
),
|
||||
)
|
||||
else:
|
||||
return InternalDirective.MAIN
|
||||
|
||||
return action
|
||||
|
||||
|
||||
def _create_user_list_action(
|
||||
ctx: Context, state: State, status: UserMediaListStatus
|
||||
) -> MenuAction:
|
||||
"""A factory to create menu actions for fetching user lists, handling authentication."""
|
||||
|
||||
def action():
|
||||
feedback = ctx.feedback
|
||||
if not ctx.media_api.is_authenticated():
|
||||
feedback.error("You haven't logged in")
|
||||
return InternalDirective.MAIN
|
||||
|
||||
search_params = UserMediaListSearchParams(status=status)
|
||||
|
||||
loading_message = "Fetching media list"
|
||||
result = None
|
||||
with feedback.progress(loading_message):
|
||||
result = ctx.media_api.search_media_list(search_params)
|
||||
|
||||
if result:
|
||||
return State(
|
||||
menu_name=MenuName.RESULTS,
|
||||
media_api=MediaApiState(
|
||||
search_result={
|
||||
media_item.id: media_item for media_item in result.media
|
||||
},
|
||||
search_params=search_params,
|
||||
page_info=result.page_info,
|
||||
),
|
||||
)
|
||||
else:
|
||||
return InternalDirective.MAIN
|
||||
|
||||
return action
|
||||
|
||||
|
||||
def _create_recent_media_action(ctx: Context, state: State) -> MenuAction:
|
||||
def action():
|
||||
result = ctx.media_registry.get_recently_watched()
|
||||
if result:
|
||||
return State(
|
||||
menu_name=MenuName.RESULTS,
|
||||
media_api=MediaApiState(
|
||||
search_result={
|
||||
media_item.id: media_item for media_item in result.media
|
||||
},
|
||||
page_info=result.page_info,
|
||||
),
|
||||
)
|
||||
else:
|
||||
return InternalDirective.MAIN
|
||||
|
||||
return action
|
||||
|
||||
|
||||
def _create_downloads_action(ctx: Context, state: State) -> MenuAction:
|
||||
"""Create action to navigate to the downloads menu."""
|
||||
|
||||
def action():
|
||||
return State(menu_name=MenuName.DOWNLOADS)
|
||||
|
||||
return action
|
||||
|
||||
|
||||
def _create_dynamic_search_action(ctx: Context, state: State) -> MenuAction:
|
||||
"""Create action to navigate to the dynamic search menu."""
|
||||
|
||||
def action():
|
||||
return State(menu_name=MenuName.DYNAMIC_SEARCH)
|
||||
|
||||
return action
|
||||
749
viu/cli/interactive/menu/media/media_actions.py
Normal file
749
viu/cli/interactive/menu/media/media_actions.py
Normal file
@@ -0,0 +1,749 @@
|
||||
from typing import Callable, Dict, Literal, Optional
|
||||
|
||||
from .....libs.media_api.params import (
|
||||
MediaRecommendationParams,
|
||||
MediaRelationsParams,
|
||||
UpdateUserMediaListEntryParams,
|
||||
)
|
||||
from .....libs.media_api.types import (
|
||||
MediaItem,
|
||||
MediaStatus,
|
||||
UserMediaListStatus,
|
||||
)
|
||||
from .....libs.player.params import PlayerParams
|
||||
from ...session import Context, session
|
||||
from ...state import InternalDirective, MediaApiState, MenuName, State
|
||||
|
||||
MenuAction = Callable[[], State | InternalDirective]
|
||||
|
||||
|
||||
@session.menu
|
||||
def media_actions(ctx: Context, state: State) -> State | InternalDirective:
|
||||
from ....service.registry.service import DownloadStatus
|
||||
|
||||
feedback = ctx.feedback
|
||||
|
||||
icons = ctx.config.general.icons
|
||||
|
||||
media_item = state.media_api.media_item
|
||||
|
||||
if not media_item:
|
||||
feedback.error("Media item is not in state")
|
||||
return InternalDirective.BACK
|
||||
progress = _get_progress_string(ctx, state.media_api.media_item)
|
||||
|
||||
# Check for downloaded episodes to conditionally show options
|
||||
record = ctx.media_registry.get_media_record(media_item.id)
|
||||
has_downloads = False
|
||||
if record:
|
||||
has_downloads = any(
|
||||
ep.download_status == DownloadStatus.COMPLETED
|
||||
and ep.file_path
|
||||
and ep.file_path.exists()
|
||||
for ep in record.media_episodes
|
||||
)
|
||||
|
||||
options: Dict[str, MenuAction] = {
|
||||
f"{'▶️ ' if icons else ''}Stream {progress}": _stream(ctx, state),
|
||||
f"{'📽️ ' if icons else ''}Episodes": _stream(
|
||||
ctx, state, force_episodes_menu=True
|
||||
),
|
||||
}
|
||||
|
||||
if has_downloads:
|
||||
options[f"{'💾 ' if icons else ''}Stream (Downloads)"] = _stream_downloads(
|
||||
ctx, state
|
||||
)
|
||||
options[f"{'💿 ' if icons else ''}Episodes (Downloads)"] = _stream_downloads(
|
||||
ctx, state, force_episodes_menu=True
|
||||
)
|
||||
|
||||
options.update(
|
||||
{
|
||||
f"{'📥 ' if icons else ''}Download": _download_episodes(ctx, state),
|
||||
f"{'📼 ' if icons else ''}Watch Trailer": _watch_trailer(ctx, state),
|
||||
f"{'🔗 ' if icons else ''}Recommendations": _view_recommendations(
|
||||
ctx, state
|
||||
),
|
||||
f"{'🔄 ' if icons else ''}Related Anime": _view_relations(ctx, state),
|
||||
f"{'👥 ' if icons else ''}Characters": _view_characters(ctx, state),
|
||||
f"{'📅 ' if icons else ''}Airing Schedule": _view_airing_schedule(
|
||||
ctx, state
|
||||
),
|
||||
f"{'📝 ' if icons else ''}View Reviews": _view_reviews(ctx, state),
|
||||
f"{'➕ ' if icons else ''}Add/Update List": _manage_user_media_list(
|
||||
ctx, state
|
||||
),
|
||||
f"{'➕ ' if icons else ''}Add/Update List (Bulk)": _manage_user_media_list_in_bulk(
|
||||
ctx, state
|
||||
),
|
||||
f"{'⭐ ' if icons else ''}Score Anime": _score_anime(ctx, state),
|
||||
f"{'ℹ️ ' if icons else ''}View Info": _view_info(ctx, state),
|
||||
f"{'📀 ' if icons else ''}Change Provider (Current: {ctx.config.general.provider.value.upper()})": _change_provider(
|
||||
ctx, state
|
||||
),
|
||||
f"{'🔘 ' if icons else ''}Toggle Auto Select Anime (Current: {ctx.config.general.auto_select_anime_result})": _toggle_config_state(
|
||||
ctx, state, "AUTO_ANIME"
|
||||
),
|
||||
f"{'🔘 ' if icons else ''}Toggle Auto Next Episode (Current: {ctx.config.stream.auto_next})": _toggle_config_state(
|
||||
ctx, state, "AUTO_EPISODE"
|
||||
),
|
||||
f"{'🔘 ' if icons else ''}Toggle Continue From History (Current: {ctx.config.stream.continue_from_watch_history})": _toggle_config_state(
|
||||
ctx, state, "CONTINUE_FROM_HISTORY"
|
||||
),
|
||||
f"{'🔘 ' if icons else ''}Toggle Translation Type (Current: {ctx.config.stream.translation_type.upper()})": _toggle_config_state(
|
||||
ctx, state, "TRANSLATION_TYPE"
|
||||
),
|
||||
f"{'🔙 ' if icons else ''}Back to Results": lambda: InternalDirective.BACK,
|
||||
f"{'❌ ' if icons else ''}Exit": lambda: InternalDirective.EXIT,
|
||||
}
|
||||
)
|
||||
|
||||
choice = ctx.selector.choose(
|
||||
prompt="Select Action",
|
||||
choices=list(options.keys()),
|
||||
)
|
||||
|
||||
if choice and choice in options:
|
||||
return options[choice]()
|
||||
|
||||
return InternalDirective.BACK
|
||||
|
||||
|
||||
def _get_progress_string(ctx: Context, media_item: Optional[MediaItem]) -> str:
|
||||
if not media_item:
|
||||
return ""
|
||||
config = ctx.config
|
||||
|
||||
progress = "0"
|
||||
|
||||
if media_item.user_status:
|
||||
progress = str(media_item.user_status.progress or 0)
|
||||
|
||||
episodes_total = str(media_item.episodes or "??")
|
||||
display_title = f"({progress} of {episodes_total})"
|
||||
|
||||
# Add a visual indicator for new episodes if applicable
|
||||
if (
|
||||
media_item.status == MediaStatus.RELEASING
|
||||
and media_item.next_airing
|
||||
and media_item.user_status
|
||||
and media_item.user_status.status == UserMediaListStatus.WATCHING
|
||||
):
|
||||
last_aired = media_item.next_airing.episode - 1
|
||||
unwatched = last_aired - (media_item.user_status.progress or 0)
|
||||
if unwatched > 0:
|
||||
icon = "🔹" if config.general.icons else "!"
|
||||
display_title += f" {icon}{unwatched} new{icon}"
|
||||
|
||||
return display_title
|
||||
|
||||
|
||||
def _stream(ctx: Context, state: State, force_episodes_menu=False) -> MenuAction:
|
||||
def action():
|
||||
if force_episodes_menu:
|
||||
ctx.switch.force_episodes_menu()
|
||||
return State(menu_name=MenuName.PROVIDER_SEARCH, media_api=state.media_api)
|
||||
|
||||
return action
|
||||
|
||||
|
||||
def _stream_downloads(
|
||||
ctx: Context, state: State, force_episodes_menu=False
|
||||
) -> MenuAction:
|
||||
def action():
|
||||
if force_episodes_menu:
|
||||
ctx.switch.force_episodes_menu()
|
||||
return State(menu_name=MenuName.PLAY_DOWNLOADS, media_api=state.media_api)
|
||||
|
||||
return action
|
||||
|
||||
|
||||
def _download_episodes(ctx: Context, state: State) -> MenuAction:
|
||||
def action():
|
||||
return State(menu_name=MenuName.DOWNLOAD_EPISODES, media_api=state.media_api)
|
||||
|
||||
return action
|
||||
|
||||
|
||||
def _watch_trailer(ctx: Context, state: State) -> MenuAction:
|
||||
def action():
|
||||
feedback = ctx.feedback
|
||||
media_item = state.media_api.media_item
|
||||
|
||||
if not media_item:
|
||||
return InternalDirective.RELOAD
|
||||
|
||||
if not media_item.trailer or not media_item.trailer.id:
|
||||
feedback.warning(
|
||||
"No trailer available for this anime",
|
||||
"This anime doesn't have a trailer link in the database",
|
||||
)
|
||||
else:
|
||||
trailer_url = f"https://www.youtube.com/watch?v={media_item.trailer.id}"
|
||||
|
||||
ctx.player.play(
|
||||
PlayerParams(url=trailer_url, query="", episode="", title="")
|
||||
)
|
||||
|
||||
return InternalDirective.RELOAD
|
||||
|
||||
return action
|
||||
|
||||
|
||||
def _manage_user_media_list(ctx: Context, state: State) -> MenuAction:
|
||||
def action():
|
||||
feedback = ctx.feedback
|
||||
media_item = state.media_api.media_item
|
||||
|
||||
if not media_item:
|
||||
return InternalDirective.RELOAD
|
||||
|
||||
if not ctx.media_api.is_authenticated():
|
||||
feedback.warning(
|
||||
"You are not authenticated",
|
||||
)
|
||||
return InternalDirective.RELOAD
|
||||
|
||||
status = ctx.selector.choose(
|
||||
"Select list status", choices=[t.value for t in UserMediaListStatus]
|
||||
)
|
||||
if status:
|
||||
# local
|
||||
ctx.media_registry.update_media_index_entry(
|
||||
media_id=media_item.id,
|
||||
media_item=media_item,
|
||||
status=UserMediaListStatus(status),
|
||||
)
|
||||
# remote
|
||||
if not ctx.media_api.update_list_entry(
|
||||
UpdateUserMediaListEntryParams(
|
||||
media_item.id, status=UserMediaListStatus(status)
|
||||
)
|
||||
):
|
||||
print(f"Failed to update {media_item.title.english}")
|
||||
return InternalDirective.RELOAD
|
||||
|
||||
return action
|
||||
|
||||
|
||||
def _manage_user_media_list_in_bulk(ctx: Context, state: State) -> MenuAction:
|
||||
def action():
|
||||
feedback = ctx.feedback
|
||||
search_result = state.media_api.search_result
|
||||
|
||||
if not search_result:
|
||||
return InternalDirective.RELOAD
|
||||
|
||||
if not ctx.media_api.is_authenticated():
|
||||
feedback.warning(
|
||||
"You are not authenticated",
|
||||
)
|
||||
return InternalDirective.RELOAD
|
||||
|
||||
choice_map: Dict[str, MediaItem] = {
|
||||
item.title.english: item for item in search_result.values()
|
||||
}
|
||||
preview_command = None
|
||||
if ctx.config.general.preview != "none":
|
||||
from ....utils.preview import create_preview_context
|
||||
|
||||
with create_preview_context() as preview_ctx:
|
||||
preview_command = preview_ctx.get_anime_preview(
|
||||
list(choice_map.values()),
|
||||
list(choice_map.keys()),
|
||||
ctx.config,
|
||||
)
|
||||
selected_titles = ctx.selector.choose_multiple(
|
||||
"Select anime to manage",
|
||||
list(choice_map.keys()),
|
||||
preview=preview_command,
|
||||
)
|
||||
else:
|
||||
selected_titles = ctx.selector.choose_multiple(
|
||||
"Select anime to download",
|
||||
list(choice_map.keys()),
|
||||
)
|
||||
if not selected_titles:
|
||||
feedback.warning("No anime selected. Aborting download.")
|
||||
return InternalDirective.RELOAD
|
||||
anime_to_update_status = [choice_map[title] for title in selected_titles]
|
||||
|
||||
status = ctx.selector.choose(
|
||||
"Select list status", choices=[t.value for t in UserMediaListStatus]
|
||||
)
|
||||
if not status:
|
||||
feedback.warning("No status selected. Aborting bulk action.")
|
||||
return InternalDirective.RELOAD
|
||||
with feedback.progress(
|
||||
"Updating media list...", total=len(anime_to_update_status)
|
||||
) as (task_id, progress):
|
||||
for media_item in anime_to_update_status:
|
||||
feedback.info(f"Updating media status for {media_item.title.english}")
|
||||
ctx.media_registry.update_media_index_entry(
|
||||
media_id=media_item.id,
|
||||
media_item=media_item,
|
||||
status=UserMediaListStatus(status),
|
||||
)
|
||||
# remote
|
||||
|
||||
if not ctx.media_api.update_list_entry(
|
||||
UpdateUserMediaListEntryParams(
|
||||
media_item.id, status=UserMediaListStatus(status)
|
||||
)
|
||||
):
|
||||
print(f"Failed to update {media_item.title.english}")
|
||||
|
||||
progress.update(task_id, advance=1) # type: ignore
|
||||
return InternalDirective.RELOAD
|
||||
|
||||
return action
|
||||
|
||||
|
||||
def _change_provider(ctx: Context, state: State) -> MenuAction:
|
||||
def action():
|
||||
from .....libs.provider.anime.types import ProviderName
|
||||
|
||||
new_provider = ctx.selector.choose(
|
||||
"Select Provider", [provider.value for provider in ProviderName]
|
||||
)
|
||||
ctx.config.general.provider = ProviderName(new_provider)
|
||||
return InternalDirective.RELOAD
|
||||
|
||||
return action
|
||||
|
||||
|
||||
def _toggle_config_state(
|
||||
ctx: Context,
|
||||
state: State,
|
||||
config_state: Literal[
|
||||
"AUTO_ANIME", "AUTO_EPISODE", "CONTINUE_FROM_HISTORY", "TRANSLATION_TYPE"
|
||||
],
|
||||
) -> MenuAction:
|
||||
def action():
|
||||
match config_state:
|
||||
case "AUTO_ANIME":
|
||||
ctx.config.general.auto_select_anime_result = (
|
||||
not ctx.config.general.auto_select_anime_result
|
||||
)
|
||||
case "AUTO_EPISODE":
|
||||
ctx.config.stream.auto_next = not ctx.config.stream.auto_next
|
||||
case "CONTINUE_FROM_HISTORY":
|
||||
ctx.config.stream.continue_from_watch_history = (
|
||||
not ctx.config.stream.continue_from_watch_history
|
||||
)
|
||||
case "TRANSLATION_TYPE":
|
||||
ctx.config.stream.translation_type = (
|
||||
"sub" if ctx.config.stream.translation_type == "dub" else "dub"
|
||||
)
|
||||
return InternalDirective.RELOAD
|
||||
|
||||
return action
|
||||
|
||||
|
||||
def _score_anime(ctx: Context, state: State) -> MenuAction:
|
||||
def action():
|
||||
feedback = ctx.feedback
|
||||
media_item = state.media_api.media_item
|
||||
|
||||
if not media_item:
|
||||
return InternalDirective.RELOAD
|
||||
|
||||
if not ctx.media_api.is_authenticated():
|
||||
return InternalDirective.RELOAD
|
||||
|
||||
score_str = ctx.selector.ask("Enter score (0.0 - 10.0):")
|
||||
try:
|
||||
score = float(score_str) if score_str else 0.0
|
||||
if not 0.0 <= score <= 10.0:
|
||||
raise ValueError("Score out of range.")
|
||||
# local
|
||||
ctx.media_registry.update_media_index_entry(
|
||||
media_id=media_item.id, media_item=media_item, score=score
|
||||
)
|
||||
# remote
|
||||
ctx.media_api.update_list_entry(
|
||||
UpdateUserMediaListEntryParams(media_id=media_item.id, score=score)
|
||||
)
|
||||
except (ValueError, TypeError):
|
||||
feedback.error(
|
||||
"Invalid score entered", "Please enter a number between 0.0 and 10.0"
|
||||
)
|
||||
return InternalDirective.RELOAD
|
||||
|
||||
return action
|
||||
|
||||
|
||||
def _view_info(ctx: Context, state: State) -> MenuAction:
|
||||
def action():
|
||||
media_item = state.media_api.media_item
|
||||
|
||||
if not media_item:
|
||||
return InternalDirective.RELOAD
|
||||
|
||||
import re
|
||||
|
||||
from rich import box
|
||||
from rich.columns import Columns
|
||||
from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
from rich.table import Table
|
||||
from rich.text import Text
|
||||
|
||||
from ....utils import image
|
||||
|
||||
console = Console()
|
||||
console.clear()
|
||||
|
||||
# Display cover image if available
|
||||
if cover_image := media_item.cover_image:
|
||||
image.render(cover_image.large)
|
||||
|
||||
# Create main title
|
||||
main_title = (
|
||||
media_item.title.english or media_item.title.romaji or "Unknown Title"
|
||||
)
|
||||
title_text = Text(main_title, style="bold cyan")
|
||||
|
||||
# Create info table
|
||||
info_table = Table(show_header=False, box=box.SIMPLE, pad_edge=False)
|
||||
info_table.add_column("Field", style="bold yellow", min_width=15)
|
||||
info_table.add_column("Value", style="white")
|
||||
|
||||
# Add basic information
|
||||
info_table.add_row("English Title", media_item.title.english or "N/A")
|
||||
info_table.add_row("Romaji Title", media_item.title.romaji or "N/A")
|
||||
info_table.add_row("Native Title", media_item.title.native or "N/A")
|
||||
|
||||
if media_item.synonymns:
|
||||
synonyms = ", ".join(media_item.synonymns[:3]) # Show first 3 synonyms
|
||||
if len(media_item.synonymns) > 3:
|
||||
synonyms += f" (+{len(media_item.synonymns) - 3} more)"
|
||||
info_table.add_row("Synonyms", synonyms)
|
||||
|
||||
info_table.add_row("Type", media_item.type.value if media_item.type else "N/A")
|
||||
info_table.add_row(
|
||||
"Format", media_item.format.value if media_item.format else "N/A"
|
||||
)
|
||||
info_table.add_row(
|
||||
"Status", media_item.status.value if media_item.status else "N/A"
|
||||
)
|
||||
info_table.add_row(
|
||||
"Episodes", str(media_item.episodes) if media_item.episodes else "Unknown"
|
||||
)
|
||||
info_table.add_row(
|
||||
"Duration",
|
||||
f"{media_item.duration} min" if media_item.duration else "Unknown",
|
||||
)
|
||||
|
||||
# Add dates
|
||||
if media_item.start_date:
|
||||
start_date = media_item.start_date.strftime("%Y-%m-%d")
|
||||
info_table.add_row("Start Date", start_date)
|
||||
if media_item.end_date:
|
||||
end_date = media_item.end_date.strftime("%Y-%m-%d")
|
||||
info_table.add_row("End Date", end_date)
|
||||
|
||||
# Add scores and popularity
|
||||
if media_item.average_score:
|
||||
info_table.add_row("Average Score", f"{media_item.average_score}/100")
|
||||
if media_item.popularity:
|
||||
info_table.add_row("Popularity", f"#{media_item.popularity:,}")
|
||||
if media_item.favourites:
|
||||
info_table.add_row("Favorites", f"{media_item.favourites:,}")
|
||||
|
||||
# Add MAL ID if available
|
||||
if media_item.id_mal:
|
||||
info_table.add_row("MyAnimeList ID", str(media_item.id_mal))
|
||||
|
||||
# Create genres panel
|
||||
if media_item.genres:
|
||||
genres_text = ", ".join([genre.value for genre in media_item.genres])
|
||||
genres_panel = Panel(
|
||||
Text(genres_text, style="green"),
|
||||
title="[bold]Genres[/bold]",
|
||||
border_style="green",
|
||||
box=box.ROUNDED,
|
||||
)
|
||||
else:
|
||||
genres_panel = Panel(
|
||||
Text("No genres available", style="dim"),
|
||||
title="[bold]Genres[/bold]",
|
||||
border_style="green",
|
||||
box=box.ROUNDED,
|
||||
)
|
||||
|
||||
# Create tags panel (show top tags)
|
||||
if media_item.tags:
|
||||
top_tags = sorted(media_item.tags, key=lambda x: x.rank or 0, reverse=True)[
|
||||
:10
|
||||
]
|
||||
tags_text = ", ".join([tag.name.value for tag in top_tags])
|
||||
tags_panel = Panel(
|
||||
Text(tags_text, style="yellow"),
|
||||
title="[bold]Tags[/bold]",
|
||||
border_style="yellow",
|
||||
box=box.ROUNDED,
|
||||
)
|
||||
else:
|
||||
tags_panel = Panel(
|
||||
Text("No tags available", style="dim"),
|
||||
title="[bold]Tags[/bold]",
|
||||
border_style="yellow",
|
||||
box=box.ROUNDED,
|
||||
)
|
||||
|
||||
# Create studios panel
|
||||
if media_item.studios:
|
||||
studios_text = ", ".join(
|
||||
[studio.name for studio in media_item.studios if studio.name]
|
||||
)
|
||||
studios_panel = Panel(
|
||||
Text(studios_text, style="blue"),
|
||||
title="[bold]Studios[/bold]",
|
||||
border_style="blue",
|
||||
box=box.ROUNDED,
|
||||
)
|
||||
else:
|
||||
studios_panel = Panel(
|
||||
Text("No studio information", style="dim"),
|
||||
title="[bold]Studios[/bold]",
|
||||
border_style="blue",
|
||||
box=box.ROUNDED,
|
||||
)
|
||||
|
||||
# Create description panel
|
||||
description = media_item.description or "No description available"
|
||||
# Clean HTML tags from description
|
||||
clean_description = re.sub(r"<[^>]+>", "", description)
|
||||
# Replace common HTML entities
|
||||
clean_description = (
|
||||
clean_description.replace(""", '"')
|
||||
.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
)
|
||||
|
||||
description_panel = Panel(
|
||||
Text(clean_description, style="white"),
|
||||
title="[bold]Description[/bold]",
|
||||
border_style="cyan",
|
||||
box=box.ROUNDED,
|
||||
)
|
||||
|
||||
# Create user status panel if available
|
||||
if media_item.user_status:
|
||||
user_info_table = Table(show_header=False, box=box.SIMPLE)
|
||||
user_info_table.add_column("Field", style="bold magenta")
|
||||
user_info_table.add_column("Value", style="white")
|
||||
|
||||
if media_item.user_status.status:
|
||||
user_info_table.add_row(
|
||||
"Status", media_item.user_status.status.value.title()
|
||||
)
|
||||
if media_item.user_status.progress is not None:
|
||||
progress = (
|
||||
f"{media_item.user_status.progress}/{media_item.episodes or '?'}"
|
||||
)
|
||||
user_info_table.add_row("Progress", progress)
|
||||
if media_item.user_status.score:
|
||||
user_info_table.add_row(
|
||||
"Your Score", f"{media_item.user_status.score}/10"
|
||||
)
|
||||
if media_item.user_status.repeat:
|
||||
user_info_table.add_row(
|
||||
"Rewatched", f"{media_item.user_status.repeat} times"
|
||||
)
|
||||
|
||||
user_panel = Panel(
|
||||
user_info_table,
|
||||
title="[bold]Your List Status[/bold]",
|
||||
border_style="magenta",
|
||||
box=box.ROUNDED,
|
||||
)
|
||||
else:
|
||||
user_panel = None
|
||||
|
||||
# Create next airing panel if available
|
||||
if media_item.next_airing:
|
||||
airing_info_table = Table(show_header=False, box=box.SIMPLE)
|
||||
airing_info_table.add_column("Field", style="bold red")
|
||||
airing_info_table.add_column("Value", style="white")
|
||||
|
||||
airing_info_table.add_row(
|
||||
"Next Episode", str(media_item.next_airing.episode)
|
||||
)
|
||||
|
||||
if media_item.next_airing.airing_at:
|
||||
air_date = media_item.next_airing.airing_at.strftime("%Y-%m-%d %H:%M")
|
||||
airing_info_table.add_row("Air Date", air_date)
|
||||
|
||||
airing_panel = Panel(
|
||||
airing_info_table,
|
||||
title="[bold]Next Airing[/bold]",
|
||||
border_style="red",
|
||||
box=box.ROUNDED,
|
||||
)
|
||||
else:
|
||||
airing_panel = None
|
||||
|
||||
# Create main info panel
|
||||
info_panel = Panel(
|
||||
info_table,
|
||||
title="[bold]Basic Information[/bold]",
|
||||
border_style="cyan",
|
||||
box=box.ROUNDED,
|
||||
)
|
||||
|
||||
# Display everything
|
||||
console.print(Panel(title_text, box=box.DOUBLE, border_style="bright_cyan"))
|
||||
console.print()
|
||||
|
||||
# Create columns for better layout
|
||||
panels_row1 = [info_panel, genres_panel]
|
||||
if user_panel:
|
||||
panels_row1.append(user_panel)
|
||||
|
||||
console.print(Columns(panels_row1, equal=True, expand=True))
|
||||
console.print()
|
||||
|
||||
panels_row2 = [tags_panel, studios_panel]
|
||||
if airing_panel:
|
||||
panels_row2.append(airing_panel)
|
||||
|
||||
console.print(Columns(panels_row2, equal=True, expand=True))
|
||||
console.print()
|
||||
|
||||
console.print(description_panel)
|
||||
|
||||
ctx.selector.ask("Press Enter to continue...")
|
||||
return InternalDirective.RELOAD
|
||||
|
||||
return action
|
||||
|
||||
|
||||
def _view_recommendations(ctx: Context, state: State) -> MenuAction:
|
||||
def action():
|
||||
feedback = ctx.feedback
|
||||
media_item = state.media_api.media_item
|
||||
|
||||
if not media_item:
|
||||
feedback.error("Media item is not in state")
|
||||
return InternalDirective.RELOAD
|
||||
|
||||
loading_message = "Fetching recommendations..."
|
||||
recommendations = None
|
||||
|
||||
with feedback.progress(loading_message):
|
||||
recommendations = ctx.media_api.get_recommendation_for(
|
||||
MediaRecommendationParams(id=media_item.id, page=1)
|
||||
)
|
||||
|
||||
if not recommendations:
|
||||
feedback.warning(
|
||||
"No recommendations found",
|
||||
"This anime doesn't have any recommendations available",
|
||||
)
|
||||
return InternalDirective.RELOAD
|
||||
|
||||
# Convert list of MediaItem to search result format
|
||||
search_result = {item.id: item for item in recommendations}
|
||||
|
||||
# Create a fake page info since recommendations don't have pagination
|
||||
from .....libs.media_api.types import PageInfo
|
||||
|
||||
page_info = PageInfo(
|
||||
total=len(recommendations),
|
||||
current_page=1,
|
||||
has_next_page=False,
|
||||
per_page=len(recommendations),
|
||||
)
|
||||
|
||||
return State(
|
||||
menu_name=MenuName.RESULTS,
|
||||
media_api=MediaApiState(
|
||||
search_result=search_result,
|
||||
page_info=page_info,
|
||||
search_params=None, # No search params for recommendations
|
||||
),
|
||||
)
|
||||
|
||||
return action
|
||||
|
||||
|
||||
def _view_relations(ctx: Context, state: State) -> MenuAction:
|
||||
def action():
|
||||
feedback = ctx.feedback
|
||||
media_item = state.media_api.media_item
|
||||
|
||||
if not media_item:
|
||||
feedback.error("Media item is not in state")
|
||||
return InternalDirective.RELOAD
|
||||
|
||||
loading_message = "Fetching related anime..."
|
||||
relations = None
|
||||
|
||||
with feedback.progress(loading_message):
|
||||
relations = ctx.media_api.get_related_anime_for(
|
||||
MediaRelationsParams(id=media_item.id)
|
||||
)
|
||||
|
||||
if not relations:
|
||||
feedback.warning(
|
||||
"No related anime found",
|
||||
"This anime doesn't have any related anime available",
|
||||
)
|
||||
return InternalDirective.RELOAD
|
||||
|
||||
# Convert list of MediaItem to search result format
|
||||
search_result = {item.id: item for item in relations}
|
||||
|
||||
# Create a fake page info since relations don't have pagination
|
||||
from .....libs.media_api.types import PageInfo
|
||||
|
||||
page_info = PageInfo(
|
||||
total=len(relations),
|
||||
current_page=1,
|
||||
has_next_page=False,
|
||||
per_page=len(relations),
|
||||
)
|
||||
|
||||
return State(
|
||||
menu_name=MenuName.RESULTS,
|
||||
media_api=MediaApiState(
|
||||
search_result=search_result,
|
||||
page_info=page_info,
|
||||
search_params=None, # No search params for relations
|
||||
),
|
||||
)
|
||||
|
||||
return action
|
||||
|
||||
|
||||
def _view_characters(ctx: Context, state: State) -> MenuAction:
|
||||
"""Action to transition to the character selection menu."""
|
||||
|
||||
def action() -> State | InternalDirective:
|
||||
return State(menu_name=MenuName.MEDIA_CHARACTERS, media_api=state.media_api)
|
||||
|
||||
return action
|
||||
|
||||
|
||||
def _view_airing_schedule(ctx: Context, state: State) -> MenuAction:
|
||||
"""Action to transition to the airing schedule menu."""
|
||||
|
||||
def action() -> State | InternalDirective:
|
||||
return State(
|
||||
menu_name=MenuName.MEDIA_AIRING_SCHEDULE, media_api=state.media_api
|
||||
)
|
||||
|
||||
return action
|
||||
|
||||
|
||||
def _view_reviews(ctx: Context, state: State) -> MenuAction:
|
||||
"""Action to transition to the review selection menu."""
|
||||
|
||||
def action() -> State | InternalDirective:
|
||||
return State(menu_name=MenuName.MEDIA_REVIEW, media_api=state.media_api)
|
||||
|
||||
return action
|
||||
207
viu/cli/interactive/menu/media/media_airing_schedule.py
Normal file
207
viu/cli/interactive/menu/media/media_airing_schedule.py
Normal file
@@ -0,0 +1,207 @@
|
||||
from typing import Dict, Optional, Union
|
||||
|
||||
from .....libs.media_api.types import AiringScheduleItem, AiringScheduleResult
|
||||
from ...session import Context, session
|
||||
from ...state import InternalDirective, State
|
||||
|
||||
|
||||
@session.menu
|
||||
def media_airing_schedule(
|
||||
ctx: Context, state: State
|
||||
) -> Union[State, InternalDirective]:
|
||||
"""
|
||||
Fetches and displays the airing schedule for an anime.
|
||||
Shows upcoming episodes with air dates and countdown timers.
|
||||
"""
|
||||
from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
|
||||
feedback = ctx.feedback
|
||||
selector = ctx.selector
|
||||
console = Console()
|
||||
media_item = state.media_api.media_item
|
||||
|
||||
if not media_item:
|
||||
feedback.error("Media item is not in state.")
|
||||
return InternalDirective.BACK
|
||||
|
||||
from .....libs.media_api.params import MediaAiringScheduleParams
|
||||
|
||||
loading_message = f"Fetching airing schedule for {media_item.title.english or media_item.title.romaji}..."
|
||||
schedule_result: Optional[AiringScheduleResult] = None
|
||||
|
||||
with feedback.progress(loading_message):
|
||||
schedule_result = ctx.media_api.get_airing_schedule_for(
|
||||
MediaAiringScheduleParams(id=media_item.id)
|
||||
)
|
||||
|
||||
if not schedule_result or not schedule_result.schedule_items:
|
||||
feedback.warning(
|
||||
"No airing schedule found",
|
||||
"This anime doesn't have upcoming episodes or airing data",
|
||||
)
|
||||
return InternalDirective.BACK
|
||||
|
||||
# Create choices for each episode in the schedule
|
||||
choice_map: Dict[str, AiringScheduleItem] = {}
|
||||
for item in schedule_result.schedule_items:
|
||||
display_name = f"Episode {item.episode}"
|
||||
if item.airing_at:
|
||||
airing_time = item.airing_at
|
||||
display_name += f" - {airing_time.strftime('%Y-%m-%d %H:%M')}"
|
||||
if item.time_until_airing:
|
||||
display_name += f" (in {item.time_until_airing})"
|
||||
|
||||
choice_map[display_name] = item
|
||||
|
||||
choices = list(choice_map.keys()) + ["View Full Schedule", "Back"]
|
||||
|
||||
preview_command = None
|
||||
if ctx.config.general.preview != "none":
|
||||
from ....utils.preview import create_preview_context
|
||||
|
||||
anime_title = media_item.title.english or media_item.title.romaji or "Unknown"
|
||||
with create_preview_context() as preview_ctx:
|
||||
preview_command = preview_ctx.get_airing_schedule_preview(
|
||||
schedule_result, ctx.config, anime_title
|
||||
)
|
||||
|
||||
while True:
|
||||
chosen_title = selector.choose(
|
||||
prompt="Select an episode or view full schedule",
|
||||
choices=choices,
|
||||
preview=preview_command,
|
||||
)
|
||||
|
||||
if not chosen_title or chosen_title == "Back":
|
||||
return InternalDirective.BACK
|
||||
|
||||
if chosen_title == "View Full Schedule":
|
||||
console.clear()
|
||||
# Display airing schedule
|
||||
anime_title = (
|
||||
media_item.title.english or media_item.title.romaji or "Unknown"
|
||||
)
|
||||
_display_airing_schedule(console, schedule_result, anime_title)
|
||||
selector.ask("\nPress Enter to return...")
|
||||
continue
|
||||
|
||||
# Show individual episode details
|
||||
selected_item = choice_map[chosen_title]
|
||||
console.clear()
|
||||
|
||||
episode_info = []
|
||||
episode_info.append(f"[bold cyan]Episode {selected_item.episode}[/bold cyan]")
|
||||
|
||||
if selected_item.airing_at:
|
||||
airing_time = selected_item.airing_at
|
||||
episode_info.append(
|
||||
f"[green]Airs at:[/green] {airing_time.strftime('%Y-%m-%d %H:%M:%S')}"
|
||||
)
|
||||
|
||||
if selected_item.time_until_airing:
|
||||
episode_info.append(
|
||||
f"[yellow]Time until airing:[/yellow] {selected_item.time_until_airing}"
|
||||
)
|
||||
|
||||
episode_content = "\n".join(episode_info)
|
||||
|
||||
console.print(
|
||||
Panel(
|
||||
episode_content,
|
||||
title=f"Episode Details - {media_item.title.english or media_item.title.romaji}",
|
||||
border_style="blue",
|
||||
expand=True,
|
||||
)
|
||||
)
|
||||
|
||||
selector.ask("\nPress Enter to return to the schedule list...")
|
||||
|
||||
return InternalDirective.BACK
|
||||
|
||||
|
||||
def _display_airing_schedule(
|
||||
console, schedule_result: AiringScheduleResult, anime_title: str
|
||||
):
|
||||
"""Display the airing schedule in a formatted table."""
|
||||
from datetime import datetime
|
||||
from rich.panel import Panel
|
||||
from rich.table import Table
|
||||
from rich.text import Text
|
||||
|
||||
# Create title
|
||||
title = Text(f"Airing Schedule for {anime_title}", style="bold cyan")
|
||||
|
||||
# Create table for episodes
|
||||
table = Table(show_header=True, header_style="bold magenta", expand=True)
|
||||
table.add_column("Episode", style="cyan", justify="center", min_width=8)
|
||||
table.add_column("Air Date", style="green", min_width=20)
|
||||
table.add_column("Time Until Airing", style="yellow", min_width=15)
|
||||
table.add_column("Status", style="white", min_width=10)
|
||||
|
||||
# Sort episodes by episode number
|
||||
sorted_episodes = sorted(schedule_result.schedule_items, key=lambda x: x.episode)
|
||||
|
||||
for episode in sorted_episodes[:15]: # Show next 15 episodes
|
||||
ep_num = str(episode.episode)
|
||||
|
||||
# Format air date
|
||||
if episode.airing_at:
|
||||
formatted_date = episode.airing_at.strftime("%Y-%m-%d %H:%M")
|
||||
|
||||
# Check if episode has already aired
|
||||
now = datetime.now()
|
||||
if episode.airing_at < now:
|
||||
status = "[dim]Aired[/dim]"
|
||||
else:
|
||||
status = "[green]Upcoming[/green]"
|
||||
else:
|
||||
formatted_date = "[dim]Unknown[/dim]"
|
||||
status = "[dim]TBA[/dim]"
|
||||
|
||||
# Format time until airing
|
||||
if episode.time_until_airing and episode.time_until_airing > 0:
|
||||
time_until = episode.time_until_airing
|
||||
days = time_until // 86400
|
||||
hours = (time_until % 86400) // 3600
|
||||
minutes = (time_until % 3600) // 60
|
||||
|
||||
if days > 0:
|
||||
time_str = f"{days}d {hours}h"
|
||||
elif hours > 0:
|
||||
time_str = f"{hours}h {minutes}m"
|
||||
else:
|
||||
time_str = f"{minutes}m"
|
||||
elif episode.airing_at and episode.airing_at < datetime.now():
|
||||
time_str = "[dim]Aired[/dim]"
|
||||
else:
|
||||
time_str = "[dim]Unknown[/dim]"
|
||||
|
||||
table.add_row(ep_num, formatted_date, time_str, status)
|
||||
|
||||
# Display in a panel
|
||||
panel = Panel(table, title=title, border_style="blue", expand=True)
|
||||
console.print(panel)
|
||||
|
||||
# Add summary information
|
||||
total_episodes = len(schedule_result.schedule_items)
|
||||
upcoming_episodes = sum(
|
||||
1
|
||||
for ep in schedule_result.schedule_items
|
||||
if ep.airing_at and ep.airing_at > datetime.now()
|
||||
)
|
||||
|
||||
summary_text = Text()
|
||||
summary_text.append("Total episodes in schedule: ", style="bold")
|
||||
summary_text.append(f"{total_episodes}", style="cyan")
|
||||
summary_text.append("\nUpcoming episodes: ", style="bold")
|
||||
summary_text.append(f"{upcoming_episodes}", style="green")
|
||||
|
||||
summary_panel = Panel(
|
||||
summary_text,
|
||||
title="[bold]Summary[/bold]",
|
||||
border_style="yellow",
|
||||
expand=False,
|
||||
)
|
||||
console.print()
|
||||
console.print(summary_panel)
|
||||
166
viu/cli/interactive/menu/media/media_characters.py
Normal file
166
viu/cli/interactive/menu/media/media_characters.py
Normal file
@@ -0,0 +1,166 @@
|
||||
import re
|
||||
from typing import Dict, Optional, Union
|
||||
|
||||
from .....libs.media_api.types import Character, CharacterSearchResult
|
||||
from ...session import Context, session
|
||||
from ...state import InternalDirective, State
|
||||
|
||||
|
||||
@session.menu
|
||||
def media_characters(ctx: Context, state: State) -> Union[State, InternalDirective]:
|
||||
"""
|
||||
Fetches and displays a list of characters for the user to select from.
|
||||
Shows character details upon selection or in the preview pane.
|
||||
"""
|
||||
from rich.console import Console
|
||||
|
||||
feedback = ctx.feedback
|
||||
selector = ctx.selector
|
||||
console = Console()
|
||||
config = ctx.config
|
||||
media_item = state.media_api.media_item
|
||||
|
||||
if not media_item:
|
||||
feedback.error("Media item is not in state.")
|
||||
return InternalDirective.BACK
|
||||
|
||||
from .....libs.media_api.params import MediaCharactersParams
|
||||
|
||||
loading_message = f"Fetching characters for {media_item.title.english or media_item.title.romaji}..."
|
||||
characters_result: Optional[CharacterSearchResult] = None
|
||||
|
||||
with feedback.progress(loading_message):
|
||||
characters_result = ctx.media_api.get_characters_of(
|
||||
MediaCharactersParams(id=media_item.id)
|
||||
)
|
||||
|
||||
if not characters_result or not characters_result.characters:
|
||||
feedback.error("No characters found for this anime.")
|
||||
return InternalDirective.BACK
|
||||
|
||||
characters = characters_result.characters
|
||||
choice_map: Dict[str, Character] = {}
|
||||
|
||||
# Create display names for characters
|
||||
for character in characters:
|
||||
display_name = character.name.full or character.name.first or "Unknown"
|
||||
if character.gender:
|
||||
display_name += f" ({character.gender})"
|
||||
if character.age:
|
||||
display_name += f" - Age {character.age}"
|
||||
|
||||
choice_map[display_name] = character
|
||||
|
||||
choices = list(choice_map.keys()) + ["Back"]
|
||||
|
||||
preview_command = None
|
||||
if config.general.preview != "none":
|
||||
from ....utils.preview import create_preview_context
|
||||
|
||||
with create_preview_context() as preview_ctx:
|
||||
preview_command = preview_ctx.get_character_preview(choice_map, ctx.config)
|
||||
|
||||
while True:
|
||||
chosen_title = selector.choose(
|
||||
prompt="Select a character to view details",
|
||||
choices=choices,
|
||||
preview=preview_command,
|
||||
)
|
||||
|
||||
if not chosen_title or chosen_title == "Back":
|
||||
return InternalDirective.BACK
|
||||
|
||||
selected_character = choice_map[chosen_title]
|
||||
console.clear()
|
||||
|
||||
# Display character details
|
||||
anime_title = media_item.title.english or media_item.title.romaji or "Unknown"
|
||||
_display_character_details(console, selected_character, anime_title)
|
||||
|
||||
selector.ask("\nPress Enter to return to the character list...")
|
||||
|
||||
|
||||
def _display_character_details(console, character: Character, anime_title: str):
|
||||
"""Display detailed character information in a formatted panel."""
|
||||
from rich.columns import Columns
|
||||
from rich.panel import Panel
|
||||
from rich.table import Table
|
||||
from rich.text import Text
|
||||
|
||||
# Character name panel
|
||||
name_text = Text()
|
||||
if character.name.full:
|
||||
name_text.append(character.name.full, style="bold cyan")
|
||||
elif character.name.first:
|
||||
full_name = character.name.first
|
||||
if character.name.last:
|
||||
full_name += f" {character.name.last}"
|
||||
name_text.append(full_name, style="bold cyan")
|
||||
else:
|
||||
name_text.append("Unknown Character", style="bold dim")
|
||||
|
||||
if character.name.native:
|
||||
name_text.append(f"\n{character.name.native}", style="green")
|
||||
|
||||
name_panel = Panel(
|
||||
name_text,
|
||||
title=f"[bold]Character from {anime_title}[/bold]",
|
||||
border_style="cyan",
|
||||
expand=False,
|
||||
)
|
||||
|
||||
# Basic info table
|
||||
info_table = Table(show_header=False, box=None, padding=(0, 1))
|
||||
info_table.add_column("Field", style="bold yellow", min_width=12)
|
||||
info_table.add_column("Value", style="white")
|
||||
|
||||
if character.gender:
|
||||
info_table.add_row("Gender", character.gender)
|
||||
if character.age:
|
||||
info_table.add_row("Age", str(character.age))
|
||||
if character.blood_type:
|
||||
info_table.add_row("Blood Type", character.blood_type)
|
||||
if character.favourites:
|
||||
info_table.add_row("Favorites", f"{character.favourites:,}")
|
||||
if character.date_of_birth:
|
||||
birth_date = character.date_of_birth.strftime("%B %d, %Y")
|
||||
info_table.add_row("Birthday", birth_date)
|
||||
|
||||
info_panel = Panel(
|
||||
info_table,
|
||||
title="[bold]Basic Information[/bold]",
|
||||
border_style="blue",
|
||||
)
|
||||
|
||||
# Description panel
|
||||
description = character.description or "No description available"
|
||||
# Clean HTML tags from description
|
||||
clean_description = re.sub(r"<[^>]+>", "", description)
|
||||
# Replace common HTML entities
|
||||
clean_description = (
|
||||
clean_description.replace(""", '"')
|
||||
.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace("'", "'")
|
||||
.replace(" ", " ")
|
||||
)
|
||||
# Limit length for display
|
||||
if len(clean_description) > 500:
|
||||
clean_description = clean_description[:497] + "..."
|
||||
|
||||
description_panel = Panel(
|
||||
Text(clean_description, style="white"),
|
||||
title="[bold]Description[/bold]",
|
||||
border_style="green",
|
||||
)
|
||||
|
||||
# Display everything
|
||||
console.print(name_panel)
|
||||
console.print()
|
||||
|
||||
# Show panels side by side if there's basic info
|
||||
if info_table.rows:
|
||||
console.print(Columns([info_panel, description_panel], equal=True, expand=True))
|
||||
else:
|
||||
console.print(description_panel)
|
||||
82
viu/cli/interactive/menu/media/media_review.py
Normal file
82
viu/cli/interactive/menu/media/media_review.py
Normal file
@@ -0,0 +1,82 @@
|
||||
from typing import Dict, List, Optional, Union
|
||||
|
||||
from .....libs.media_api.types import MediaReview
|
||||
from ...session import Context, session
|
||||
from ...state import InternalDirective, State
|
||||
|
||||
|
||||
@session.menu
|
||||
def media_review(ctx: Context, state: State) -> Union[State, InternalDirective]:
|
||||
"""
|
||||
Fetches and displays a list of reviews for the user to select from.
|
||||
Shows the full review body upon selection or in the preview pane.
|
||||
"""
|
||||
from rich.console import Console
|
||||
from rich.markdown import Markdown
|
||||
from rich.panel import Panel
|
||||
|
||||
feedback = ctx.feedback
|
||||
selector = ctx.selector
|
||||
console = Console()
|
||||
config = ctx.config
|
||||
media_item = state.media_api.media_item
|
||||
|
||||
if not media_item:
|
||||
feedback.error("Media item is not in state.")
|
||||
return InternalDirective.BACK
|
||||
|
||||
from .....libs.media_api.params import MediaReviewsParams
|
||||
|
||||
loading_message = (
|
||||
f"Fetching reviews for {media_item.title.english or media_item.title.romaji}..."
|
||||
)
|
||||
reviews: Optional[List[MediaReview]] = None
|
||||
|
||||
with feedback.progress(loading_message):
|
||||
reviews = ctx.media_api.get_reviews_for(
|
||||
MediaReviewsParams(id=media_item.id, per_page=15)
|
||||
)
|
||||
|
||||
if not reviews:
|
||||
feedback.error("No reviews found for this anime.")
|
||||
return InternalDirective.BACK
|
||||
|
||||
choice_map: Dict[str, MediaReview] = {
|
||||
f"By {review.user.name}: {(review.summary or 'No summary')[:80]}": review
|
||||
for review in reviews
|
||||
}
|
||||
choices = list(choice_map.keys()) + ["Back"]
|
||||
|
||||
preview_command = None
|
||||
if config.general.preview != "none":
|
||||
from ....utils.preview import create_preview_context
|
||||
|
||||
with create_preview_context() as preview_ctx:
|
||||
preview_command = preview_ctx.get_review_preview(choice_map, ctx.config)
|
||||
|
||||
while True:
|
||||
chosen_title = selector.choose(
|
||||
prompt="Select a review to read",
|
||||
choices=choices,
|
||||
preview=preview_command,
|
||||
)
|
||||
|
||||
if not chosen_title or chosen_title == "Back":
|
||||
return InternalDirective.BACK
|
||||
|
||||
selected_review = choice_map[chosen_title]
|
||||
console.clear()
|
||||
|
||||
reviewer_name = f"[bold magenta]{selected_review.user.name}[/bold magenta]"
|
||||
review_summary = (
|
||||
f"[italic green]'{selected_review.summary}'[/italic green]"
|
||||
if selected_review.summary
|
||||
else ""
|
||||
)
|
||||
panel_title = f"Review by {reviewer_name} - {review_summary}"
|
||||
review_body = Markdown(selected_review.body)
|
||||
|
||||
console.print(
|
||||
Panel(review_body, title=panel_title, border_style="blue", expand=True)
|
||||
)
|
||||
selector.ask("\nPress Enter to return to the review list...")
|
||||
332
viu/cli/interactive/menu/media/play_downloads.py
Normal file
332
viu/cli/interactive/menu/media/play_downloads.py
Normal file
@@ -0,0 +1,332 @@
|
||||
from typing import Callable, Dict, Literal, Union
|
||||
|
||||
from .....libs.player.params import PlayerParams
|
||||
from ...session import Context, session
|
||||
from ...state import InternalDirective, MenuName, State
|
||||
|
||||
MenuAction = Callable[[], Union[State, InternalDirective]]
|
||||
|
||||
|
||||
@session.menu
|
||||
def play_downloads(ctx: Context, state: State) -> State | InternalDirective:
|
||||
"""Menu to select and play locally downloaded episodes."""
|
||||
from ....service.registry.models import DownloadStatus
|
||||
|
||||
feedback = ctx.feedback
|
||||
media_item = state.media_api.media_item
|
||||
current_episode_num = state.provider.episode
|
||||
if not media_item:
|
||||
feedback.error("No media item selected.")
|
||||
return InternalDirective.BACK
|
||||
|
||||
record = ctx.media_registry.get_media_record(media_item.id)
|
||||
if not record or not record.media_episodes:
|
||||
feedback.warning("No downloaded episodes found for this anime.")
|
||||
return InternalDirective.BACK
|
||||
|
||||
downloaded_episodes = {
|
||||
ep.episode_number: ep.file_path
|
||||
for ep in record.media_episodes
|
||||
if ep.download_status == DownloadStatus.COMPLETED
|
||||
and ep.file_path
|
||||
and ep.file_path.exists()
|
||||
}
|
||||
|
||||
if not downloaded_episodes:
|
||||
feedback.warning("No complete downloaded episodes found.")
|
||||
return InternalDirective.BACK
|
||||
|
||||
chosen_episode: str | None = current_episode_num
|
||||
start_time: str | None = None
|
||||
|
||||
if not chosen_episode and ctx.config.stream.continue_from_watch_history:
|
||||
_chosen_episode, _start_time = ctx.watch_history.get_episode(media_item)
|
||||
if _chosen_episode in downloaded_episodes:
|
||||
chosen_episode = _chosen_episode
|
||||
start_time = _start_time
|
||||
|
||||
if not chosen_episode or ctx.switch.show_episodes_menu:
|
||||
choices = [*list(sorted(downloaded_episodes.keys(), key=float)), "Back"]
|
||||
|
||||
preview_command = None
|
||||
if ctx.config.general.preview != "none":
|
||||
from ....utils.preview import create_preview_context
|
||||
|
||||
with create_preview_context() as preview_ctx:
|
||||
preview_command = preview_ctx.get_episode_preview(
|
||||
list(downloaded_episodes.keys()), media_item, ctx.config
|
||||
)
|
||||
|
||||
chosen_episode_str = ctx.selector.choose(
|
||||
prompt="Select Episode", choices=choices, preview=preview_command
|
||||
)
|
||||
|
||||
if not chosen_episode_str or chosen_episode_str == "Back":
|
||||
return InternalDirective.BACK
|
||||
|
||||
chosen_episode = chosen_episode_str
|
||||
# Workers are automatically cleaned up when exiting the context
|
||||
else:
|
||||
# No preview mode
|
||||
chosen_episode_str = ctx.selector.choose(
|
||||
prompt="Select Episode", choices=choices, preview=None
|
||||
)
|
||||
|
||||
if not chosen_episode_str or chosen_episode_str == "Back":
|
||||
return InternalDirective.BACK
|
||||
|
||||
chosen_episode = chosen_episode_str
|
||||
|
||||
if not chosen_episode or chosen_episode == "Back":
|
||||
return InternalDirective.BACK
|
||||
|
||||
return State(
|
||||
menu_name=MenuName.DOWNLOADS_PLAYER_CONTROLS,
|
||||
media_api=state.media_api,
|
||||
provider=state.provider.model_copy(
|
||||
update={"episode": chosen_episode, "start_time": start_time}
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# TODO: figure out the best way to implement this logic for next episode ...
|
||||
@session.menu
|
||||
def downloads_player_controls(
|
||||
ctx: Context, state: State
|
||||
) -> Union[State, InternalDirective]:
|
||||
from ....service.registry.models import DownloadStatus
|
||||
|
||||
feedback = ctx.feedback
|
||||
feedback.clear_console()
|
||||
|
||||
config = ctx.config
|
||||
selector = ctx.selector
|
||||
|
||||
media_item = state.media_api.media_item
|
||||
current_episode_num = state.provider.episode
|
||||
current_start_time = state.provider.start_time
|
||||
|
||||
if not media_item or not current_episode_num:
|
||||
feedback.error("Player state is incomplete. Returning.")
|
||||
return InternalDirective.BACK
|
||||
record = ctx.media_registry.get_media_record(media_item.id)
|
||||
if not record or not record.media_episodes:
|
||||
feedback.warning("No downloaded episodes found for this anime.")
|
||||
return InternalDirective.BACK
|
||||
|
||||
downloaded_episodes = {
|
||||
ep.episode_number: ep.file_path
|
||||
for ep in record.media_episodes
|
||||
if ep.download_status == DownloadStatus.COMPLETED
|
||||
and ep.file_path
|
||||
and ep.file_path.exists()
|
||||
}
|
||||
available_episodes = list(sorted(downloaded_episodes.keys(), key=float))
|
||||
current_index = available_episodes.index(current_episode_num)
|
||||
|
||||
if not ctx.switch.dont_play:
|
||||
file_path = downloaded_episodes[current_episode_num]
|
||||
|
||||
# Use the player service to play the local file
|
||||
title = f"{media_item.title.english or media_item.title.romaji}; Episode {current_episode_num}"
|
||||
if media_item.streaming_episodes:
|
||||
streaming_episode = media_item.streaming_episodes.get(current_episode_num)
|
||||
title = streaming_episode.title if streaming_episode else title
|
||||
player_result = ctx.player.play(
|
||||
PlayerParams(
|
||||
url=str(file_path),
|
||||
title=title,
|
||||
query=media_item.title.english or media_item.title.romaji or "",
|
||||
episode=current_episode_num,
|
||||
start_time=current_start_time,
|
||||
),
|
||||
media_item=media_item,
|
||||
local=True,
|
||||
)
|
||||
|
||||
# Track watch history after playing
|
||||
ctx.watch_history.track(media_item, player_result)
|
||||
|
||||
if config.stream.auto_next and current_index < len(available_episodes) - 1:
|
||||
feedback.info("Auto-playing next episode...")
|
||||
next_episode_num = available_episodes[current_index + 1]
|
||||
|
||||
return State(
|
||||
menu_name=MenuName.DOWNLOADS_PLAYER_CONTROLS,
|
||||
media_api=state.media_api,
|
||||
provider=state.provider.model_copy(
|
||||
update={"episode": next_episode_num, "start_time": None}
|
||||
),
|
||||
)
|
||||
|
||||
# --- Menu Options ---
|
||||
icons = config.general.icons
|
||||
options: Dict[str, Callable[[], Union[State, InternalDirective]]] = {}
|
||||
|
||||
if current_index < len(available_episodes) - 1:
|
||||
options[f"{'⏭️ ' if icons else ''}Next Episode"] = _next_episode(ctx, state)
|
||||
if current_index:
|
||||
options[f"{'⏪ ' if icons else ''}Previous Episode"] = _previous_episode(
|
||||
ctx, state
|
||||
)
|
||||
|
||||
options.update(
|
||||
{
|
||||
f"{'🔂 ' if icons else ''}Replay": _replay(ctx, state),
|
||||
f"{'🎞️ ' if icons else ''}Episode List": _episodes_list(ctx, state),
|
||||
f"{'🔘 ' if icons else ''}Toggle Auto Next Episode (Current: {ctx.config.stream.auto_next})": _toggle_config_state(
|
||||
ctx, state, "AUTO_EPISODE"
|
||||
),
|
||||
f"{'🎥 ' if icons else ''}Media Actions Menu": lambda: InternalDirective.BACKX2,
|
||||
f"{'🏠 ' if icons else ''}Main Menu": lambda: InternalDirective.MAIN,
|
||||
f"{'❌ ' if icons else ''}Exit": lambda: InternalDirective.EXIT,
|
||||
}
|
||||
)
|
||||
|
||||
choice = selector.choose(prompt="What's next?", choices=list(options.keys()))
|
||||
|
||||
if choice and choice in options:
|
||||
return options[choice]()
|
||||
else:
|
||||
return InternalDirective.RELOAD
|
||||
|
||||
|
||||
def _next_episode(ctx: Context, state: State) -> MenuAction:
|
||||
def action():
|
||||
from ....service.registry.models import DownloadStatus
|
||||
|
||||
feedback = ctx.feedback
|
||||
|
||||
media_item = state.media_api.media_item
|
||||
current_episode_num = state.provider.episode
|
||||
|
||||
if not media_item or not current_episode_num:
|
||||
feedback.error(
|
||||
"Player state is incomplete. not going to next episode. Returning."
|
||||
)
|
||||
ctx.switch.force_dont_play()
|
||||
return InternalDirective.RELOAD
|
||||
|
||||
record = ctx.media_registry.get_media_record(media_item.id)
|
||||
if not record or not record.media_episodes:
|
||||
feedback.warning("No downloaded episodes found for this anime.")
|
||||
ctx.switch.force_dont_play()
|
||||
return InternalDirective.RELOAD
|
||||
|
||||
downloaded_episodes = {
|
||||
ep.episode_number: ep.file_path
|
||||
for ep in record.media_episodes
|
||||
if ep.download_status == DownloadStatus.COMPLETED
|
||||
and ep.file_path
|
||||
and ep.file_path.exists()
|
||||
}
|
||||
available_episodes = list(sorted(downloaded_episodes.keys(), key=float))
|
||||
current_index = available_episodes.index(current_episode_num)
|
||||
|
||||
if current_index < len(available_episodes) - 1:
|
||||
next_episode_num = available_episodes[current_index + 1]
|
||||
|
||||
return State(
|
||||
menu_name=MenuName.DOWNLOADS_PLAYER_CONTROLS,
|
||||
media_api=state.media_api,
|
||||
provider=state.provider.model_copy(
|
||||
update={"episode": next_episode_num, "start_time": None}
|
||||
),
|
||||
)
|
||||
feedback.warning("This is the last available episode.")
|
||||
ctx.switch.force_dont_play()
|
||||
return InternalDirective.RELOAD
|
||||
|
||||
return action
|
||||
|
||||
|
||||
def _previous_episode(ctx: Context, state: State) -> MenuAction:
|
||||
def action():
|
||||
from ....service.registry.models import DownloadStatus
|
||||
|
||||
feedback = ctx.feedback
|
||||
|
||||
media_item = state.media_api.media_item
|
||||
current_episode_num = state.provider.episode
|
||||
|
||||
if not media_item or not current_episode_num:
|
||||
feedback.error(
|
||||
"Player state is incomplete not going to previous episode. Returning."
|
||||
)
|
||||
ctx.switch.force_dont_play()
|
||||
return InternalDirective.RELOAD
|
||||
|
||||
record = ctx.media_registry.get_media_record(media_item.id)
|
||||
if not record or not record.media_episodes:
|
||||
feedback.warning("No downloaded episodes found for this anime.")
|
||||
ctx.switch.force_dont_play()
|
||||
return InternalDirective.RELOAD
|
||||
|
||||
downloaded_episodes = {
|
||||
ep.episode_number: ep.file_path
|
||||
for ep in record.media_episodes
|
||||
if ep.download_status == DownloadStatus.COMPLETED
|
||||
and ep.file_path
|
||||
and ep.file_path.exists()
|
||||
}
|
||||
available_episodes = list(sorted(downloaded_episodes.keys(), key=float))
|
||||
current_index = available_episodes.index(current_episode_num)
|
||||
|
||||
if current_index:
|
||||
prev_episode_num = available_episodes[current_index - 1]
|
||||
|
||||
return State(
|
||||
menu_name=MenuName.DOWNLOADS_PLAYER_CONTROLS,
|
||||
media_api=state.media_api,
|
||||
provider=state.provider.model_copy(
|
||||
update={"episode": prev_episode_num, "start_time": None}
|
||||
),
|
||||
)
|
||||
feedback.warning("This is the last available episode.")
|
||||
ctx.switch.force_dont_play()
|
||||
return InternalDirective.RELOAD
|
||||
|
||||
return action
|
||||
|
||||
|
||||
def _replay(ctx: Context, state: State) -> MenuAction:
|
||||
def action():
|
||||
return InternalDirective.RELOAD
|
||||
|
||||
return action
|
||||
|
||||
|
||||
def _toggle_config_state(
|
||||
ctx: Context,
|
||||
state: State,
|
||||
config_state: Literal[
|
||||
"AUTO_ANIME", "AUTO_EPISODE", "CONTINUE_FROM_HISTORY", "TRANSLATION_TYPE"
|
||||
],
|
||||
) -> MenuAction:
|
||||
def action():
|
||||
match config_state:
|
||||
case "AUTO_ANIME":
|
||||
ctx.config.general.auto_select_anime_result = (
|
||||
not ctx.config.general.auto_select_anime_result
|
||||
)
|
||||
case "AUTO_EPISODE":
|
||||
ctx.config.stream.auto_next = not ctx.config.stream.auto_next
|
||||
case "CONTINUE_FROM_HISTORY":
|
||||
ctx.config.stream.continue_from_watch_history = (
|
||||
not ctx.config.stream.continue_from_watch_history
|
||||
)
|
||||
case "TRANSLATION_TYPE":
|
||||
ctx.config.stream.translation_type = (
|
||||
"sub" if ctx.config.stream.translation_type == "dub" else "dub"
|
||||
)
|
||||
return InternalDirective.RELOAD
|
||||
|
||||
return action
|
||||
|
||||
|
||||
def _episodes_list(ctx: Context, state: State) -> MenuAction:
|
||||
def action():
|
||||
ctx.switch.force_episodes_menu()
|
||||
return InternalDirective.BACK
|
||||
|
||||
return action
|
||||
258
viu/cli/interactive/menu/media/player_controls.py
Normal file
258
viu/cli/interactive/menu/media/player_controls.py
Normal file
@@ -0,0 +1,258 @@
|
||||
from typing import Callable, Dict, Literal, Union
|
||||
|
||||
from ...session import Context, session
|
||||
from ...state import InternalDirective, MenuName, State
|
||||
|
||||
MenuAction = Callable[[], Union[State, InternalDirective]]
|
||||
|
||||
|
||||
@session.menu
|
||||
def player_controls(ctx: Context, state: State) -> Union[State, InternalDirective]:
|
||||
feedback = ctx.feedback
|
||||
feedback.clear_console()
|
||||
|
||||
config = ctx.config
|
||||
selector = ctx.selector
|
||||
|
||||
provider_anime = state.provider.anime
|
||||
media_item = state.media_api.media_item
|
||||
current_episode_num = state.provider.episode
|
||||
selected_server = state.provider.server
|
||||
server_map = state.provider.servers
|
||||
|
||||
if (
|
||||
not provider_anime
|
||||
or not media_item
|
||||
or not current_episode_num
|
||||
or not selected_server
|
||||
or not server_map
|
||||
):
|
||||
feedback.error("Player state is incomplete. Returning.")
|
||||
return InternalDirective.BACK
|
||||
|
||||
available_episodes = getattr(
|
||||
provider_anime.episodes, config.stream.translation_type, []
|
||||
)
|
||||
current_index = available_episodes.index(current_episode_num)
|
||||
|
||||
if config.stream.auto_next and current_index < len(available_episodes) - 1:
|
||||
feedback.info("Auto-playing next episode...")
|
||||
next_episode_num = available_episodes[current_index + 1]
|
||||
|
||||
return State(
|
||||
menu_name=MenuName.SERVERS,
|
||||
media_api=state.media_api,
|
||||
provider=state.provider.model_copy(update={"episode": next_episode_num}),
|
||||
)
|
||||
|
||||
# --- Menu Options ---
|
||||
icons = config.general.icons
|
||||
options: Dict[str, Callable[[], Union[State, InternalDirective]]] = {}
|
||||
|
||||
if current_index < len(available_episodes) - 1:
|
||||
options[f"{'⏭️ ' if icons else ''}Next Episode"] = _next_episode(ctx, state)
|
||||
if current_index:
|
||||
options[f"{'⏪ ' if icons else ''}Previous Episode"] = _previous_episode(
|
||||
ctx, state
|
||||
)
|
||||
|
||||
options.update(
|
||||
{
|
||||
f"{'🔂 ' if icons else ''}Replay": _replay(ctx, state),
|
||||
f"{'💽 ' if icons else ''}Change Server": _change_server(ctx, state),
|
||||
f"{'📀 ' if icons else ''}Change Quality": _change_quality(ctx, state),
|
||||
f"{'🎞️ ' if icons else ''}Episode List": _episodes_list(ctx, state),
|
||||
f"{'🔘 ' if icons else ''}Toggle Auto Next Episode (Current: {ctx.config.stream.auto_next})": _toggle_config_state(
|
||||
ctx, state, "AUTO_EPISODE"
|
||||
),
|
||||
f"{'🔘 ' if icons else ''}Toggle Translation Type (Current: {ctx.config.stream.translation_type.upper()})": _toggle_config_state(
|
||||
ctx, state, "TRANSLATION_TYPE"
|
||||
),
|
||||
f"{'🎥 ' if icons else ''}Media Actions Menu": lambda: InternalDirective.BACKX4,
|
||||
f"{'🏠 ' if icons else ''}Main Menu": lambda: InternalDirective.MAIN,
|
||||
f"{'❌ ' if icons else ''}Exit": lambda: InternalDirective.EXIT,
|
||||
}
|
||||
)
|
||||
|
||||
choice = selector.choose(prompt="What's next?", choices=list(options.keys()))
|
||||
|
||||
if choice and choice in options:
|
||||
return options[choice]()
|
||||
else:
|
||||
return InternalDirective.RELOAD
|
||||
|
||||
|
||||
def _next_episode(ctx: Context, state: State) -> MenuAction:
|
||||
def action():
|
||||
feedback = ctx.feedback
|
||||
|
||||
config = ctx.config
|
||||
|
||||
provider_anime = state.provider.anime
|
||||
media_item = state.media_api.media_item
|
||||
current_episode_num = state.provider.episode
|
||||
selected_server = state.provider.server
|
||||
server_map = state.provider.servers
|
||||
|
||||
if (
|
||||
not provider_anime
|
||||
or not media_item
|
||||
or not current_episode_num
|
||||
or not selected_server
|
||||
or not server_map
|
||||
):
|
||||
feedback.error("Player state is incomplete. Returning.")
|
||||
return InternalDirective.BACK
|
||||
|
||||
available_episodes = getattr(
|
||||
provider_anime.episodes, config.stream.translation_type, []
|
||||
)
|
||||
current_index = available_episodes.index(current_episode_num)
|
||||
|
||||
if current_index < len(available_episodes) - 1:
|
||||
next_episode_num = available_episodes[current_index + 1]
|
||||
|
||||
return State(
|
||||
menu_name=MenuName.SERVERS,
|
||||
media_api=state.media_api,
|
||||
provider=state.provider.model_copy(
|
||||
update={"episode": next_episode_num}
|
||||
),
|
||||
)
|
||||
feedback.warning("This is the last available episode.")
|
||||
return InternalDirective.RELOAD
|
||||
|
||||
return action
|
||||
|
||||
|
||||
def _previous_episode(ctx: Context, state: State) -> MenuAction:
|
||||
def action():
|
||||
feedback = ctx.feedback
|
||||
|
||||
config = ctx.config
|
||||
|
||||
provider_anime = state.provider.anime
|
||||
current_episode_num = state.provider.episode
|
||||
|
||||
if not provider_anime or not current_episode_num:
|
||||
feedback.error("Player state is incomplete. Returning.")
|
||||
return InternalDirective.BACK
|
||||
|
||||
available_episodes = getattr(
|
||||
provider_anime.episodes, config.stream.translation_type, []
|
||||
)
|
||||
current_index = available_episodes.index(current_episode_num)
|
||||
|
||||
if current_index:
|
||||
prev_episode_num = available_episodes[current_index - 1]
|
||||
|
||||
return State(
|
||||
menu_name=MenuName.SERVERS,
|
||||
media_api=state.media_api,
|
||||
provider=state.provider.model_copy(
|
||||
update={"episode": prev_episode_num}
|
||||
),
|
||||
)
|
||||
feedback.warning("This is the last available episode.")
|
||||
return InternalDirective.RELOAD
|
||||
|
||||
return action
|
||||
|
||||
|
||||
def _replay(ctx: Context, state: State) -> MenuAction:
|
||||
def action():
|
||||
return InternalDirective.BACK
|
||||
|
||||
return action
|
||||
|
||||
|
||||
def _toggle_config_state(
|
||||
ctx: Context,
|
||||
state: State,
|
||||
config_state: Literal[
|
||||
"AUTO_ANIME", "AUTO_EPISODE", "CONTINUE_FROM_HISTORY", "TRANSLATION_TYPE"
|
||||
],
|
||||
) -> MenuAction:
|
||||
def action():
|
||||
match config_state:
|
||||
case "AUTO_ANIME":
|
||||
ctx.config.general.auto_select_anime_result = (
|
||||
not ctx.config.general.auto_select_anime_result
|
||||
)
|
||||
case "AUTO_EPISODE":
|
||||
ctx.config.stream.auto_next = not ctx.config.stream.auto_next
|
||||
case "CONTINUE_FROM_HISTORY":
|
||||
ctx.config.stream.continue_from_watch_history = (
|
||||
not ctx.config.stream.continue_from_watch_history
|
||||
)
|
||||
case "TRANSLATION_TYPE":
|
||||
ctx.config.stream.translation_type = (
|
||||
"sub" if ctx.config.stream.translation_type == "dub" else "dub"
|
||||
)
|
||||
return InternalDirective.RELOAD
|
||||
|
||||
return action
|
||||
|
||||
|
||||
def _change_server(ctx: Context, state: State) -> MenuAction:
|
||||
def action():
|
||||
from .....libs.provider.anime.types import ProviderServer
|
||||
|
||||
feedback = ctx.feedback
|
||||
|
||||
selector = ctx.selector
|
||||
|
||||
provider_anime = state.provider.anime
|
||||
media_item = state.media_api.media_item
|
||||
current_episode_num = state.provider.episode
|
||||
selected_server = state.provider.server
|
||||
server_map = state.provider.servers
|
||||
|
||||
if (
|
||||
not provider_anime
|
||||
or not media_item
|
||||
or not current_episode_num
|
||||
or not selected_server
|
||||
or not server_map
|
||||
):
|
||||
feedback.error("Player state is incomplete. Returning.")
|
||||
return InternalDirective.BACK
|
||||
|
||||
new_server_name = selector.choose(
|
||||
"Select a different server:", list(server_map.keys())
|
||||
)
|
||||
if new_server_name:
|
||||
ctx.config.stream.server = ProviderServer(new_server_name)
|
||||
return InternalDirective.RELOAD
|
||||
|
||||
return action
|
||||
|
||||
|
||||
def _episodes_list(ctx: Context, state: State) -> MenuAction:
|
||||
def action():
|
||||
ctx.switch.force_episodes_menu()
|
||||
return InternalDirective.BACKX2
|
||||
|
||||
return action
|
||||
|
||||
|
||||
def _change_quality(ctx: Context, state: State) -> MenuAction:
|
||||
def action():
|
||||
feedback = ctx.feedback
|
||||
|
||||
selector = ctx.selector
|
||||
|
||||
server_map = state.provider.servers
|
||||
|
||||
if not server_map:
|
||||
feedback.error("Player state is incomplete. Returning.")
|
||||
return InternalDirective.BACK
|
||||
|
||||
new_quality = selector.choose(
|
||||
"Select a different server:", list(["360", "480", "720", "1080"])
|
||||
)
|
||||
if new_quality:
|
||||
ctx.config.stream.quality = new_quality # type:ignore
|
||||
return InternalDirective.RELOAD
|
||||
|
||||
return action
|
||||
102
viu/cli/interactive/menu/media/provider_search.py
Normal file
102
viu/cli/interactive/menu/media/provider_search.py
Normal file
@@ -0,0 +1,102 @@
|
||||
from .....libs.provider.anime.params import AnimeParams, SearchParams
|
||||
from .....libs.provider.anime.types import SearchResult
|
||||
from ...session import Context, session
|
||||
from ...state import InternalDirective, MenuName, ProviderState, State
|
||||
|
||||
|
||||
@session.menu
|
||||
def provider_search(ctx: Context, state: State) -> State | InternalDirective:
|
||||
from .....core.utils.fuzzy import fuzz
|
||||
from .....core.utils.normalizer import normalize_title, update_user_normalizer_json
|
||||
|
||||
feedback = ctx.feedback
|
||||
media_item = state.media_api.media_item
|
||||
if not media_item:
|
||||
feedback.error("No AniList anime to search for", "Please select an anime first")
|
||||
return InternalDirective.BACK
|
||||
|
||||
provider = ctx.provider
|
||||
selector = ctx.selector
|
||||
config = ctx.config
|
||||
feedback.clear_console()
|
||||
|
||||
media_title = media_item.title.english or media_item.title.romaji
|
||||
if not media_title:
|
||||
feedback.error(
|
||||
"Selected anime has no searchable title",
|
||||
"This anime entry is missing required title information",
|
||||
)
|
||||
return InternalDirective.BACK
|
||||
|
||||
provider_search_results = provider.search(
|
||||
SearchParams(
|
||||
query=normalize_title(media_title, config.general.provider.value, True),
|
||||
translation_type=config.stream.translation_type,
|
||||
)
|
||||
)
|
||||
|
||||
if not provider_search_results or not provider_search_results.results:
|
||||
feedback.warning(
|
||||
f"Could not find '{media_title}' on {provider.__class__.__name__}",
|
||||
"Try another provider from the config or go back to search again",
|
||||
)
|
||||
return InternalDirective.BACK
|
||||
|
||||
provider_results_map: dict[str, SearchResult] = {
|
||||
result.title: result for result in provider_search_results.results
|
||||
}
|
||||
|
||||
selected_provider_anime: SearchResult | None = None
|
||||
|
||||
# --- Auto-Select or Prompt ---
|
||||
if config.general.auto_select_anime_result:
|
||||
# Use fuzzy matching to find the best title
|
||||
best_match_title = max(
|
||||
provider_results_map.keys(),
|
||||
key=lambda p_title: fuzz.ratio(
|
||||
normalize_title(p_title, config.general.provider.value).lower(),
|
||||
media_title.lower(),
|
||||
),
|
||||
)
|
||||
feedback.info(f"Auto-selecting best match: {best_match_title}")
|
||||
selected_provider_anime = provider_results_map[best_match_title]
|
||||
else:
|
||||
choices = list(provider_results_map.keys())
|
||||
choices.append("Back")
|
||||
|
||||
chosen_title = selector.choose(
|
||||
prompt=f"Confirm match for '{media_title}'", choices=choices
|
||||
)
|
||||
|
||||
if not chosen_title or chosen_title == "Back":
|
||||
return InternalDirective.BACK
|
||||
|
||||
if selector.confirm(
|
||||
f"Would you like to update your local normalizer json with: {chosen_title} for {media_title}"
|
||||
):
|
||||
update_user_normalizer_json(
|
||||
chosen_title, media_title, config.general.provider.value
|
||||
)
|
||||
selected_provider_anime = provider_results_map[chosen_title]
|
||||
|
||||
with feedback.progress(
|
||||
f"[cyan]Fetching full details for '{selected_provider_anime.title}'"
|
||||
):
|
||||
full_provider_anime = provider.get(
|
||||
AnimeParams(id=selected_provider_anime.id, query=media_title.lower())
|
||||
)
|
||||
|
||||
if not full_provider_anime:
|
||||
feedback.warning(
|
||||
f"Failed to fetch details for '{selected_provider_anime.title}'."
|
||||
)
|
||||
return InternalDirective.BACK
|
||||
|
||||
return State(
|
||||
menu_name=MenuName.EPISODES,
|
||||
media_api=state.media_api,
|
||||
provider=ProviderState(
|
||||
search_results=provider_search_results,
|
||||
anime=full_provider_anime,
|
||||
),
|
||||
)
|
||||
207
viu/cli/interactive/menu/media/results.py
Normal file
207
viu/cli/interactive/menu/media/results.py
Normal file
@@ -0,0 +1,207 @@
|
||||
from dataclasses import asdict
|
||||
from typing import Callable, Dict, Union
|
||||
|
||||
from .....libs.media_api.params import MediaSearchParams, UserMediaListSearchParams
|
||||
from .....libs.media_api.types import MediaItem, MediaStatus, UserMediaListStatus
|
||||
from ...session import Context, session
|
||||
from ...state import InternalDirective, MediaApiState, MenuName, State
|
||||
|
||||
|
||||
@session.menu
|
||||
def results(ctx: Context, state: State) -> State | InternalDirective:
|
||||
feedback = ctx.feedback
|
||||
feedback.clear_console()
|
||||
|
||||
search_result = state.media_api.search_result
|
||||
page_info = state.media_api.page_info
|
||||
|
||||
if not search_result:
|
||||
feedback.info("No anime found for the given criteria")
|
||||
return InternalDirective.BACK
|
||||
|
||||
search_result_dict = {
|
||||
_format_title(ctx, media_item): media_item
|
||||
for media_item in search_result.values()
|
||||
}
|
||||
choices: Dict[str, Callable[[], Union[int, State, InternalDirective]]] = {
|
||||
title: lambda media_id=item.id: media_id
|
||||
for title, item in search_result_dict.items()
|
||||
}
|
||||
if page_info:
|
||||
if page_info.has_next_page:
|
||||
choices.update(
|
||||
{
|
||||
f"Next Page (Page {page_info.current_page + 1})": lambda: _handle_pagination(
|
||||
ctx, state, 1
|
||||
)
|
||||
}
|
||||
)
|
||||
if page_info.current_page > 1:
|
||||
choices.update(
|
||||
{
|
||||
f"Previous Page (Page {page_info.current_page - 1})": lambda: _handle_pagination(
|
||||
ctx, state, -1
|
||||
)
|
||||
}
|
||||
)
|
||||
choices.update(
|
||||
{
|
||||
"Back": lambda: InternalDirective.BACK
|
||||
if page_info and page_info.current_page == 1
|
||||
else InternalDirective.MAIN,
|
||||
"Exit": lambda: InternalDirective.EXIT,
|
||||
}
|
||||
)
|
||||
|
||||
preview_command = None
|
||||
if ctx.config.general.preview != "none":
|
||||
from ....utils.preview import create_preview_context
|
||||
|
||||
with create_preview_context() as preview_ctx:
|
||||
preview_command = preview_ctx.get_anime_preview(
|
||||
list(search_result_dict.values()),
|
||||
list(search_result_dict.keys()),
|
||||
ctx.config,
|
||||
)
|
||||
|
||||
choice = ctx.selector.choose(
|
||||
prompt="Select Anime",
|
||||
choices=list(choices),
|
||||
preview=preview_command,
|
||||
)
|
||||
|
||||
else:
|
||||
# No preview mode
|
||||
|
||||
choice = ctx.selector.choose(
|
||||
prompt="Select Anime",
|
||||
choices=list(choices),
|
||||
preview=None,
|
||||
)
|
||||
|
||||
if not choice:
|
||||
return InternalDirective.RELOAD
|
||||
|
||||
next_step = choices[choice]()
|
||||
if isinstance(next_step, State) or isinstance(next_step, InternalDirective):
|
||||
return next_step
|
||||
else:
|
||||
return State(
|
||||
menu_name=MenuName.MEDIA_ACTIONS,
|
||||
media_api=MediaApiState(
|
||||
media_id=next_step,
|
||||
search_result=state.media_api.search_result,
|
||||
page_info=state.media_api.page_info,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _format_title(ctx: Context, media_item: MediaItem) -> str:
|
||||
config = ctx.config
|
||||
|
||||
title = media_item.title.english or media_item.title.romaji
|
||||
progress = "0"
|
||||
|
||||
if media_item.user_status:
|
||||
progress = str(media_item.user_status.progress or 0)
|
||||
|
||||
episodes_total = str(media_item.episodes or "??")
|
||||
display_title = f"{title} ({progress} of {episodes_total})"
|
||||
|
||||
# Add a visual indicator for new episodes if applicable
|
||||
if (
|
||||
media_item.status == MediaStatus.RELEASING
|
||||
and media_item.next_airing
|
||||
and media_item.user_status
|
||||
and media_item.user_status.status == UserMediaListStatus.WATCHING
|
||||
):
|
||||
last_aired = media_item.next_airing.episode - 1
|
||||
unwatched = last_aired - (media_item.user_status.progress or 0)
|
||||
if unwatched > 0:
|
||||
icon = "🔹" if config.general.icons else "!"
|
||||
display_title += f" {icon}{unwatched} new{icon}"
|
||||
|
||||
return display_title
|
||||
|
||||
|
||||
def _handle_pagination(
|
||||
ctx: Context, state: State, page_delta: int
|
||||
) -> State | InternalDirective:
|
||||
feedback = ctx.feedback
|
||||
|
||||
search_params = state.media_api.search_params
|
||||
|
||||
if (
|
||||
not state.media_api.search_result
|
||||
or not state.media_api.page_info
|
||||
or not search_params
|
||||
):
|
||||
feedback.error("No search results available for pagination")
|
||||
return InternalDirective.RELOAD
|
||||
|
||||
current_page = state.media_api.page_info.current_page
|
||||
new_page = current_page + page_delta
|
||||
|
||||
# Validate page bounds
|
||||
if new_page < 1:
|
||||
feedback.warning("Already at the first page")
|
||||
return InternalDirective.RELOAD
|
||||
|
||||
if page_delta == -1:
|
||||
return InternalDirective.BACK
|
||||
if page_delta > 0 and not state.media_api.page_info.has_next_page:
|
||||
feedback.warning("No more pages available")
|
||||
return InternalDirective.RELOAD
|
||||
|
||||
# Determine which type of search to perform based on stored parameters
|
||||
if isinstance(search_params, UserMediaListSearchParams):
|
||||
if not ctx.media_api.is_authenticated():
|
||||
feedback.error("You haven't logged in")
|
||||
return InternalDirective.RELOAD
|
||||
|
||||
search_params_dict = asdict(search_params)
|
||||
search_params_dict.pop("page")
|
||||
|
||||
loading_message = "Fetching media list"
|
||||
result = None
|
||||
new_search_params = UserMediaListSearchParams(
|
||||
**search_params_dict, page=new_page
|
||||
)
|
||||
with feedback.progress(loading_message):
|
||||
result = ctx.media_api.search_media_list(new_search_params)
|
||||
|
||||
if result:
|
||||
return State(
|
||||
menu_name=MenuName.RESULTS,
|
||||
media_api=MediaApiState(
|
||||
search_result={
|
||||
media_item.id: media_item for media_item in result.media
|
||||
},
|
||||
search_params=new_search_params,
|
||||
page_info=result.page_info,
|
||||
),
|
||||
)
|
||||
else:
|
||||
search_params_dict = asdict(search_params)
|
||||
search_params_dict.pop("page")
|
||||
|
||||
loading_message = "Fetching media list"
|
||||
result = None
|
||||
new_search_params = MediaSearchParams(**search_params_dict, page=new_page)
|
||||
with feedback.progress(loading_message):
|
||||
result = ctx.media_api.search_media(new_search_params)
|
||||
|
||||
if result:
|
||||
return State(
|
||||
menu_name=MenuName.RESULTS,
|
||||
media_api=MediaApiState(
|
||||
search_result={
|
||||
media_item.id: media_item for media_item in result.media
|
||||
},
|
||||
search_params=new_search_params,
|
||||
page_info=result.page_info,
|
||||
),
|
||||
)
|
||||
|
||||
feedback.warning("Failed to load page")
|
||||
return InternalDirective.RELOAD
|
||||
121
viu/cli/interactive/menu/media/servers.py
Normal file
121
viu/cli/interactive/menu/media/servers.py
Normal file
@@ -0,0 +1,121 @@
|
||||
from typing import Dict, List
|
||||
|
||||
from .....libs.player.params import PlayerParams
|
||||
from .....libs.provider.anime.params import EpisodeStreamsParams
|
||||
from .....libs.provider.anime.types import ProviderServer, Server
|
||||
from ...session import Context, session
|
||||
from ...state import InternalDirective, MenuName, State
|
||||
|
||||
|
||||
@session.menu
|
||||
def servers(ctx: Context, state: State) -> State | InternalDirective:
|
||||
feedback = ctx.feedback
|
||||
|
||||
config = ctx.config
|
||||
provider = ctx.provider
|
||||
selector = ctx.selector
|
||||
|
||||
provider_anime = state.provider.anime
|
||||
media_item = state.media_api.media_item
|
||||
|
||||
if not media_item:
|
||||
return InternalDirective.BACK
|
||||
anime_title = media_item.title.romaji or media_item.title.english
|
||||
episode_number = state.provider.episode
|
||||
|
||||
if not provider_anime or not episode_number:
|
||||
feedback.error("Anime or episode details are missing")
|
||||
return InternalDirective.BACK
|
||||
|
||||
with feedback.progress("Fetching Servers"):
|
||||
server_iterator = provider.episode_streams(
|
||||
EpisodeStreamsParams(
|
||||
anime_id=provider_anime.id,
|
||||
query=anime_title,
|
||||
episode=episode_number,
|
||||
translation_type=config.stream.translation_type,
|
||||
)
|
||||
)
|
||||
# Consume the iterator to get a list of all servers
|
||||
if config.stream.server == ProviderServer.TOP and server_iterator:
|
||||
try:
|
||||
all_servers = [next(server_iterator)]
|
||||
except Exception:
|
||||
all_servers = []
|
||||
else:
|
||||
all_servers: List[Server] = list(server_iterator) if server_iterator else []
|
||||
|
||||
if not all_servers:
|
||||
feedback.error(f"o streaming servers found for episode {episode_number}")
|
||||
return InternalDirective.BACKX3
|
||||
|
||||
server_map: Dict[str, Server] = {s.name: s for s in all_servers}
|
||||
selected_server: Server | None = None
|
||||
|
||||
preferred_server = config.stream.server.value
|
||||
if preferred_server == "TOP":
|
||||
selected_server = all_servers[0]
|
||||
feedback.info(f"Auto-selecting top server: {selected_server.name}")
|
||||
elif preferred_server in server_map:
|
||||
selected_server = server_map[preferred_server]
|
||||
feedback.info(f"Auto-selecting preferred server: {selected_server.name}")
|
||||
else:
|
||||
choices = [*server_map.keys(), "Back"]
|
||||
chosen_name = selector.choose("Select Server", choices)
|
||||
if not chosen_name or chosen_name == "Back":
|
||||
return InternalDirective.BACK
|
||||
selected_server = server_map[chosen_name]
|
||||
|
||||
stream_link_obj = _filter_by_quality(selected_server.links, config.stream.quality)
|
||||
if not stream_link_obj:
|
||||
feedback.error(
|
||||
f"No stream of quality '{config.stream.quality}' found on server '{selected_server.name}'."
|
||||
)
|
||||
return InternalDirective.RELOAD
|
||||
|
||||
final_title = (
|
||||
media_item.streaming_episodes[episode_number].title
|
||||
if media_item.streaming_episodes.get(episode_number)
|
||||
else f"{media_item.title.english}; Episode {episode_number}"
|
||||
)
|
||||
feedback.info(f"[bold green]Launching player for:[/] {final_title}")
|
||||
|
||||
if not state.media_api.media_item or not state.provider.anime:
|
||||
return InternalDirective.BACKX3
|
||||
player_result = ctx.player.play(
|
||||
PlayerParams(
|
||||
url=stream_link_obj.link,
|
||||
title=final_title,
|
||||
query=(
|
||||
state.media_api.media_item.title.romaji
|
||||
or state.media_api.media_item.title.english
|
||||
),
|
||||
episode=episode_number,
|
||||
subtitles=[sub.url for sub in selected_server.subtitles],
|
||||
headers=selected_server.headers,
|
||||
start_time=state.provider.start_time,
|
||||
),
|
||||
state.provider.anime,
|
||||
state.media_api.media_item,
|
||||
)
|
||||
if media_item and episode_number:
|
||||
ctx.watch_history.track(media_item, player_result)
|
||||
|
||||
return State(
|
||||
menu_name=MenuName.PLAYER_CONTROLS,
|
||||
media_api=state.media_api,
|
||||
provider=state.provider.model_copy(
|
||||
update={
|
||||
"servers": server_map,
|
||||
"server_name": selected_server.name,
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _filter_by_quality(links, quality):
|
||||
# Simplified version of your filter_by_quality for brevity
|
||||
for link in links:
|
||||
if str(link.quality) == quality:
|
||||
return link
|
||||
return links[0] if links else None
|
||||
331
viu/cli/interactive/session.py
Normal file
331
viu/cli/interactive/session.py
Normal file
@@ -0,0 +1,331 @@
|
||||
import importlib.util
|
||||
import logging
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING, Callable, List, Optional, Union
|
||||
|
||||
import click
|
||||
|
||||
from ...core.config import AppConfig
|
||||
from ...core.constants import APP_DIR, USER_CONFIG
|
||||
from .state import InternalDirective, MenuName, State
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ...libs.media_api.base import BaseApiClient
|
||||
from ...libs.provider.anime.base import BaseAnimeProvider
|
||||
from ...libs.selectors.base import BaseSelector
|
||||
from ..service.auth import AuthService
|
||||
from ..service.feedback import FeedbackService
|
||||
from ..service.player import PlayerService
|
||||
from ..service.registry import MediaRegistryService
|
||||
from ..service.session import SessionsService
|
||||
from ..service.watch_history import WatchHistoryService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
MENUS_DIR = APP_DIR / "cli" / "interactive" / "menu"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Switch:
|
||||
"Forces menus to show selector and not just pass through,once viewed it auto sets back to false"
|
||||
|
||||
_provider_results: bool = False
|
||||
_episodes: bool = False
|
||||
_servers: bool = False
|
||||
_dont_play: bool = False
|
||||
|
||||
@property
|
||||
def show_provider_results_menu(self):
|
||||
if self._provider_results:
|
||||
self._provider_results = False
|
||||
return True
|
||||
return False
|
||||
|
||||
def force_provider_results_menu(self):
|
||||
self._provider_results = True
|
||||
|
||||
@property
|
||||
def dont_play(self):
|
||||
if self._dont_play:
|
||||
self._dont_play = False
|
||||
return True
|
||||
return False
|
||||
|
||||
def force_dont_play(self):
|
||||
self._dont_play = True
|
||||
|
||||
@property
|
||||
def show_episodes_menu(self):
|
||||
if self._episodes:
|
||||
self._episodes = False
|
||||
return True
|
||||
return False
|
||||
|
||||
def force_episodes_menu(self):
|
||||
self._episodes = True
|
||||
|
||||
@property
|
||||
def servers(self):
|
||||
if self._servers:
|
||||
self._servers = False
|
||||
return True
|
||||
return False
|
||||
|
||||
def force_servers_menu(self):
|
||||
self._servers = True
|
||||
|
||||
|
||||
@dataclass
|
||||
class Context:
|
||||
config: "AppConfig"
|
||||
switch: Switch = field(default_factory=Switch)
|
||||
_provider: Optional["BaseAnimeProvider"] = None
|
||||
_selector: Optional["BaseSelector"] = None
|
||||
_media_api: Optional["BaseApiClient"] = None
|
||||
|
||||
_feedback: Optional["FeedbackService"] = None
|
||||
_media_registry: Optional["MediaRegistryService"] = None
|
||||
_watch_history: Optional["WatchHistoryService"] = None
|
||||
_session: Optional["SessionsService"] = None
|
||||
_auth: Optional["AuthService"] = None
|
||||
_player: Optional["PlayerService"] = None
|
||||
|
||||
@property
|
||||
def provider(self) -> "BaseAnimeProvider":
|
||||
if not self._provider:
|
||||
from ...libs.provider.anime.provider import create_provider
|
||||
|
||||
self._provider = create_provider(self.config.general.provider)
|
||||
return self._provider
|
||||
|
||||
@property
|
||||
def selector(self) -> "BaseSelector":
|
||||
if not self._selector:
|
||||
from ...libs.selectors.selector import create_selector
|
||||
|
||||
self._selector = create_selector(self.config)
|
||||
return self._selector
|
||||
|
||||
@property
|
||||
def media_api(self) -> "BaseApiClient":
|
||||
if not self._media_api:
|
||||
from ...libs.media_api.api import create_api_client
|
||||
|
||||
media_api = create_api_client(self.config.general.media_api, self.config)
|
||||
|
||||
auth = self.auth
|
||||
if auth_profile := auth.get_auth():
|
||||
p = media_api.authenticate(auth_profile.token)
|
||||
if p:
|
||||
logger.debug(f"Authenticated as {p.name}")
|
||||
else:
|
||||
logger.warning(f"Failed to authenticate with {auth_profile.token}")
|
||||
else:
|
||||
logger.debug("Not authenticated")
|
||||
self._media_api = media_api
|
||||
|
||||
return self._media_api
|
||||
|
||||
@property
|
||||
def player(self) -> "PlayerService":
|
||||
if not self._player:
|
||||
from ..service.player import PlayerService
|
||||
|
||||
self._player = PlayerService(
|
||||
self.config, self.provider, self.media_registry
|
||||
)
|
||||
return self._player
|
||||
|
||||
@property
|
||||
def feedback(self) -> "FeedbackService":
|
||||
if not self._feedback:
|
||||
from ..service.feedback.service import FeedbackService
|
||||
|
||||
self._feedback = FeedbackService(self.config)
|
||||
return self._feedback
|
||||
|
||||
@property
|
||||
def media_registry(self) -> "MediaRegistryService":
|
||||
if not self._media_registry:
|
||||
from ..service.registry.service import MediaRegistryService
|
||||
|
||||
self._media_registry = MediaRegistryService(
|
||||
self.config.general.media_api, self.config.media_registry
|
||||
)
|
||||
return self._media_registry
|
||||
|
||||
@property
|
||||
def watch_history(self) -> "WatchHistoryService":
|
||||
if not self._watch_history:
|
||||
from ..service.watch_history.service import WatchHistoryService
|
||||
|
||||
self._watch_history = WatchHistoryService(
|
||||
self.config, self.media_registry, self.media_api
|
||||
)
|
||||
return self._watch_history
|
||||
|
||||
@property
|
||||
def session(self) -> "SessionsService":
|
||||
if not self._session:
|
||||
from ..service.session.service import SessionsService
|
||||
|
||||
self._session = SessionsService(self.config.sessions)
|
||||
return self._session
|
||||
|
||||
@property
|
||||
def auth(self) -> "AuthService":
|
||||
if not self._auth:
|
||||
from ..service.auth.service import AuthService
|
||||
|
||||
self._auth = AuthService(self.config.general.media_api)
|
||||
return self._auth
|
||||
|
||||
|
||||
MenuFunction = Callable[[Context, State], Union[State, InternalDirective]]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Menu:
|
||||
name: MenuName
|
||||
execute: MenuFunction
|
||||
|
||||
|
||||
class Session:
|
||||
_context: Context
|
||||
_history: List[State] = []
|
||||
_menus: dict[MenuName, Menu] = {}
|
||||
|
||||
def _load_context(self, config: AppConfig):
|
||||
self._context = Context(config)
|
||||
logger.info("Application context reloaded.")
|
||||
|
||||
def _edit_config(self):
|
||||
from ..config import ConfigLoader
|
||||
|
||||
click.edit(filename=str(USER_CONFIG))
|
||||
logger.debug("Config changed; Reloading context")
|
||||
loader = ConfigLoader()
|
||||
config = loader.load()
|
||||
self._load_context(config)
|
||||
|
||||
def run(
|
||||
self,
|
||||
config: AppConfig,
|
||||
resume: bool = False,
|
||||
history: Optional[List[State]] = None,
|
||||
):
|
||||
self._load_context(config)
|
||||
if resume:
|
||||
if history := self._context.session.get_default_session_history():
|
||||
self._history = history
|
||||
else:
|
||||
logger.warning("Failed to continue from history. No sessions found")
|
||||
|
||||
if history:
|
||||
self._history = history
|
||||
else:
|
||||
self._history.append(State(menu_name=MenuName.MAIN))
|
||||
|
||||
try:
|
||||
self._run_main_loop()
|
||||
except Exception:
|
||||
self._context.session.create_crash_backup(self._history)
|
||||
raise
|
||||
finally:
|
||||
# Clean up preview workers when session ends
|
||||
self._cleanup_preview_workers()
|
||||
self._context.session.save_session(self._history)
|
||||
|
||||
def _cleanup_preview_workers(self):
|
||||
"""Clean up preview workers when session ends."""
|
||||
try:
|
||||
from ..utils.preview import shutdown_preview_workers
|
||||
|
||||
shutdown_preview_workers(wait=False, timeout=5.0)
|
||||
logger.debug("Preview workers cleaned up successfully")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to cleanup preview workers: {e}")
|
||||
|
||||
def _run_main_loop(self):
|
||||
"""Run the main session loop."""
|
||||
while self._history:
|
||||
current_state = self._history[-1]
|
||||
|
||||
next_step = self._menus[current_state.menu_name].execute(
|
||||
self._context, current_state
|
||||
)
|
||||
|
||||
if isinstance(next_step, InternalDirective):
|
||||
if next_step == InternalDirective.MAIN:
|
||||
self._history = [self._history[0]]
|
||||
elif next_step == InternalDirective.RELOAD:
|
||||
continue
|
||||
elif next_step == InternalDirective.CONFIG_EDIT:
|
||||
self._edit_config()
|
||||
elif next_step == InternalDirective.BACK:
|
||||
if len(self._history) > 1:
|
||||
self._history.pop()
|
||||
elif next_step == InternalDirective.BACKX2:
|
||||
if len(self._history) > 2:
|
||||
self._history.pop()
|
||||
self._history.pop()
|
||||
elif next_step == InternalDirective.BACKX3:
|
||||
if len(self._history) > 3:
|
||||
self._history.pop()
|
||||
self._history.pop()
|
||||
self._history.pop()
|
||||
elif next_step == InternalDirective.BACKX4:
|
||||
if len(self._history) > 4:
|
||||
self._history.pop()
|
||||
self._history.pop()
|
||||
self._history.pop()
|
||||
self._history.pop()
|
||||
elif next_step == InternalDirective.EXIT:
|
||||
break
|
||||
else:
|
||||
self._history.append(next_step)
|
||||
|
||||
@property
|
||||
def menu(self) -> Callable[[MenuFunction], MenuFunction]:
|
||||
"""A decorator to register a function as a menu."""
|
||||
|
||||
def decorator(func: MenuFunction) -> MenuFunction:
|
||||
menu_name = MenuName(func.__name__.upper())
|
||||
if menu_name in self._menus:
|
||||
logger.warning(f"Menu '{menu_name}' is being redefined.")
|
||||
self._menus[menu_name] = Menu(name=menu_name, execute=func)
|
||||
return func
|
||||
|
||||
return decorator
|
||||
|
||||
def load_menus_from_folder(self, package: str):
|
||||
package_path = MENUS_DIR / package
|
||||
package_name = package_path.name
|
||||
logger.debug(f"Loading menus from '{package_path}'...")
|
||||
|
||||
for filename in os.listdir(package_path):
|
||||
if filename.endswith(".py") and not filename.startswith("__"):
|
||||
module_name = filename[:-3]
|
||||
full_module_name = (
|
||||
f"viu.cli.interactive.menu.{package_name}.{module_name}"
|
||||
)
|
||||
file_path = package_path / filename
|
||||
|
||||
try:
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
full_module_name, file_path
|
||||
)
|
||||
if spec and spec.loader:
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
# The act of executing the module runs the @session.menu decorators
|
||||
spec.loader.exec_module(module)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to load menu module '{full_module_name}': {e}"
|
||||
)
|
||||
|
||||
|
||||
# Create a single, global instance of the Session to be imported by menu modules.
|
||||
session = Session()
|
||||
85
viu/cli/interactive/state.py
Normal file
85
viu/cli/interactive/state.py
Normal file
@@ -0,0 +1,85 @@
|
||||
from enum import Enum
|
||||
from typing import Dict, Optional, Union
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from ...libs.media_api.params import MediaSearchParams, UserMediaListSearchParams
|
||||
from ...libs.media_api.types import MediaItem, PageInfo
|
||||
from ...libs.provider.anime.types import Anime, SearchResults, Server
|
||||
|
||||
|
||||
# TODO: is internal directive a good name
|
||||
class InternalDirective(Enum):
|
||||
MAIN = "MAIN"
|
||||
|
||||
BACK = "BACK"
|
||||
|
||||
BACKX2 = "BACKX2"
|
||||
|
||||
BACKX3 = "BACKX3"
|
||||
|
||||
BACKX4 = "BACKX4"
|
||||
|
||||
EXIT = "EXIT"
|
||||
|
||||
CONFIG_EDIT = "CONFIG_EDIT"
|
||||
|
||||
RELOAD = "RELOAD"
|
||||
|
||||
|
||||
class MenuName(Enum):
|
||||
MAIN = "MAIN"
|
||||
AUTH = "AUTH"
|
||||
EPISODES = "EPISODES"
|
||||
RESULTS = "RESULTS"
|
||||
SERVERS = "SERVERS"
|
||||
WATCH_HISTORY = "WATCH_HISTORY"
|
||||
PROVIDER_SEARCH = "PROVIDER_SEARCH"
|
||||
PLAYER_CONTROLS = "PLAYER_CONTROLS"
|
||||
USER_MEDIA_LIST = "USER_MEDIA_LIST"
|
||||
SESSION_MANAGEMENT = "SESSION_MANAGEMENT"
|
||||
MEDIA_ACTIONS = "MEDIA_ACTIONS"
|
||||
DOWNLOADS = "DOWNLOADS"
|
||||
DYNAMIC_SEARCH = "DYNAMIC_SEARCH"
|
||||
MEDIA_REVIEW = "MEDIA_REVIEW"
|
||||
MEDIA_CHARACTERS = "MEDIA_CHARACTERS"
|
||||
MEDIA_AIRING_SCHEDULE = "MEDIA_AIRING_SCHEDULE"
|
||||
PLAY_DOWNLOADS = "PLAY_DOWNLOADS"
|
||||
DOWNLOADS_PLAYER_CONTROLS = "DOWNLOADS_PLAYER_CONTROLS"
|
||||
DOWNLOAD_EPISODES = "DOWNLOAD_EPISODES"
|
||||
|
||||
|
||||
class StateModel(BaseModel):
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
|
||||
class MediaApiState(StateModel):
|
||||
search_result: Optional[Dict[int, MediaItem]] = None
|
||||
search_params: Optional[Union[MediaSearchParams, UserMediaListSearchParams]] = None
|
||||
page_info: Optional[PageInfo] = None
|
||||
media_id: Optional[int] = None
|
||||
|
||||
@property
|
||||
def media_item(self) -> Optional[MediaItem]:
|
||||
if self.search_result and self.media_id:
|
||||
return self.search_result[self.media_id]
|
||||
|
||||
|
||||
class ProviderState(StateModel):
|
||||
search_results: Optional[SearchResults] = None
|
||||
anime: Optional[Anime] = None
|
||||
episode: Optional[str] = None
|
||||
servers: Optional[Dict[str, Server]] = None
|
||||
server_name: Optional[str] = None
|
||||
start_time: Optional[str] = None
|
||||
|
||||
@property
|
||||
def server(self) -> Optional[Server]:
|
||||
if self.servers and self.server_name:
|
||||
return self.servers[self.server_name]
|
||||
|
||||
|
||||
class State(StateModel):
|
||||
menu_name: MenuName
|
||||
provider: ProviderState = Field(default_factory=ProviderState)
|
||||
media_api: MediaApiState = Field(default_factory=MediaApiState)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user