refactor: rename to viu

This commit is contained in:
Benexl
2025-08-16 16:17:42 +03:00
parent e2407d4948
commit e49fb4898c
269 changed files with 694 additions and 702 deletions

12
viu/__init__.py Normal file
View 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
View 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()

View File

@@ -0,0 +1,7 @@
██╗░░░██╗██╗██╗░░░██╗
██║░░░██║██║██║░░░██║
╚██╗░██╔╝██║██║░░░██║
░╚████╔╝░██║██║░░░██║
░░╚██╔╝░░██║╚██████╔╝
░░░╚═╝░░░╚═╝░╚═════╝░

View 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

View 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;
}

View 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;
}

View 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 */
}

View 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 */
}

View 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

View File

@@ -0,0 +1,7 @@
query ($showId: String!) {
show(_id: $showId) {
_id
name
availableEpisodesDetail
}
}

View File

@@ -0,0 +1,15 @@
query (
$showId: String!
$translationType: VaildTranslationTypeEnumType!
$episodeString: String!
) {
episode(
showId: $showId
translationType: $translationType
episodeString: $episodeString
) {
episodeString
sourceUrls
notes
}
}

View 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
}
}
}

View File

@@ -0,0 +1,5 @@
mutation ($id: Int) {
DeleteMediaListEntry(id: $id) {
deleted
}
}

View File

@@ -0,0 +1,5 @@
mutation {
UpdateUser {
unreadNotificationCount
}
}

View 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
}
}
}

View File

@@ -0,0 +1,11 @@
query {
Viewer {
id
name
bannerImage
avatar {
large
medium
}
}
}

View 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
}
}
}
}
}

View 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
}
}
}
}
}

View File

@@ -0,0 +1,5 @@
query ($mediaId: Int) {
MediaList(mediaId: $mediaId) {
id
}
}

View 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
}
}
}

View 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
}
}
}
}
}

View 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
}
}
}
}
}

View 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
}
}
}
}
}
}

View File

@@ -0,0 +1,18 @@
query ($id: Int) {
Page {
pageInfo {
total
}
reviews(mediaId: $id) {
summary
user {
name
avatar {
large
medium
}
}
body
}
}
}

View 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
}
}
}
}

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

BIN
viu/assets/icons/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 276 KiB

View 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"
}
}

View 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

View 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

View 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

View 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

View 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/&lt;/</g' | sed 's/&gt;/>/g' | sed 's/&amp;/\&/g' | sed 's/&quot;/"/g' | sed "s/&#39;/'/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

View 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

View 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"

View 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

View 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

View 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

View 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
View File

@@ -0,0 +1,3 @@
from .cli import cli as run_cli
__all__ = ["run_cli"]

108
viu/cli/cli.py Normal file
View 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

View File

View File

@@ -0,0 +1,3 @@
from .cmd import anilist
__all__ = ["anilist"]

View 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)

View 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.")

View 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))

View 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

View 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.",
)

View 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()

View 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()

View 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
"""

View 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
View 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()}")

View 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,
)
)

View 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
View 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))

View File

@@ -0,0 +1,3 @@
from .cmd import queue
__all__ = ["queue"]

View 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

View 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))

View 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).")

View 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)

View 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.")

View File

@@ -0,0 +1,3 @@
from .cmd import registry
__all__ = ["registry"]

View 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}")

View File

@@ -0,0 +1 @@
# Registry commands package

View 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"

View 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.",
)

View 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"

View 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}",
)

View 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")

View 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]"
)

View 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"

View 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",
)

View 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
View 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
View 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)

View 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()

View 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
View 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
View 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
View 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)

View 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

View 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

View 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,
),
)

View 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}
),
)

View 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

View 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("&quot;", '"')
.replace("&amp;", "&")
.replace("&lt;", "<")
.replace("&gt;", ">")
)
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

View 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)

View 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("&quot;", '"')
.replace("&amp;", "&")
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&#039;", "'")
.replace("&nbsp;", " ")
)
# 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)

View 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...")

View 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

View 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

View 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,
),
)

View 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

View 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

View 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()

View 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