Compare commits
7
Commits
bad7b7d3b3
...
d81ec1b057
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d81ec1b057 | ||
|
|
5f5d24cb2d | ||
|
|
4a52320d4e | ||
|
|
90705bc3e2 | ||
|
|
7ef3a29ece | ||
|
|
af73bb5b26 | ||
|
|
30656cb580 |
No files matched your search
@@ -32,4 +32,5 @@ blogs/
|
|||||||
|
|
||||||
# Content tool
|
# Content tool
|
||||||
tools/content/.venv/
|
tools/content/.venv/
|
||||||
|
tools/content/.cache/
|
||||||
__pycache__/
|
__pycache__/
|
||||||
+2
-6
@@ -4,11 +4,7 @@
|
|||||||
"setup": {
|
"setup": {
|
||||||
"user": "blob",
|
"user": "blob",
|
||||||
"host": "blomarchy",
|
"host": "blomarchy",
|
||||||
"steam": [
|
"steam": [" ( ) (", " ) ( ) )", " ( ) ( ("],
|
||||||
" ( ) (",
|
|
||||||
" ) ( ) )",
|
|
||||||
" ( ) ( ("
|
|
||||||
],
|
|
||||||
"art": [
|
"art": [
|
||||||
" ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄",
|
" ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄",
|
||||||
" ██ ██▄▄▄▄▄",
|
" ██ ██▄▄▄▄▄",
|
||||||
@@ -56,7 +52,7 @@
|
|||||||
{ "name": "Severance", "rating": 9, "url": "https://www.imdb.com/title/tt11280740/" },
|
{ "name": "Severance", "rating": 9, "url": "https://www.imdb.com/title/tt11280740/" },
|
||||||
{ "name": "The Mandalorian", "rating": 7, "url": "https://www.imdb.com/title/tt8111088/" },
|
{ "name": "The Mandalorian", "rating": 7, "url": "https://www.imdb.com/title/tt8111088/" },
|
||||||
{ "name": "BEEF", "rating": 9, "url": "https://www.imdb.com/title/tt14403178/" },
|
{ "name": "BEEF", "rating": 9, "url": "https://www.imdb.com/title/tt14403178/" },
|
||||||
{ "name": "Loki", "rating": 10, "url": "https://www.imdb.com/title/tt9140554/" }
|
{ "name": "Loki", "rating": 10, "url": "https://www.imdb.com/title/tt9140554/", "status": "watching" }
|
||||||
],
|
],
|
||||||
"movies": [
|
"movies": [
|
||||||
{ "name": "Interstellar", "rating": 10, "url": "https://www.imdb.com/title/tt0816692/" },
|
{ "name": "Interstellar", "rating": 10, "url": "https://www.imdb.com/title/tt0816692/" },
|
||||||
|
|||||||
+376
-1
@@ -1,4 +1,379 @@
|
|||||||
[
|
[
|
||||||
{ "goodreadsId": "170448", "url": "https://www.goodreads.com/book/show/170448", "title": "Animal Farm", "authors": ["George Orwell"], "cover": "https://m.media-amazon.com/images/S/compressed.photo.goodreads.com/books/1325861570i/170448.jpg", "description": "Animal Farm is the most famous by far of all twentieth-century political allegories. Its account of a group of barnyard animals who revolt against their vicious human master, only to submit to a tyranny erected by their own kind, can fairly be said to have become a universal drama. Orwell is one of the very few modern satirists comparable to Jonathan Swift in power, artistry, and moral authority; in animal farm his spare prose and the logic of his dark comedy brilliantly highlight his stark message.\n \nTaking as his starting point the betrayed promise of the Russian Revolution, Orwell lays out...", "pages": 141, "published": "1996-04-01", "genres": ["Classics", "Fiction", "Dystopia", "Fantasy", "School"], "averageRating": 4.03, "ratingCount": 4853138, "shelf": "read", "rating": 10, "added": "2026-11-01" },
|
{ "goodreadsId": "170448", "url": "https://www.goodreads.com/book/show/170448", "title": "Animal Farm", "authors": ["George Orwell"], "cover": "https://m.media-amazon.com/images/S/compressed.photo.goodreads.com/books/1325861570i/170448.jpg", "description": "Animal Farm is the most famous by far of all twentieth-century political allegories. Its account of a group of barnyard animals who revolt against their vicious human master, only to submit to a tyranny erected by their own kind, can fairly be said to have become a universal drama. Orwell is one of the very few modern satirists comparable to Jonathan Swift in power, artistry, and moral authority; in animal farm his spare prose and the logic of his dark comedy brilliantly highlight his stark message.\n \nTaking as his starting point the betrayed promise of the Russian Revolution, Orwell lays out...", "pages": 141, "published": "1996-04-01", "genres": ["Classics", "Fiction", "Dystopia", "Fantasy", "School"], "averageRating": 4.03, "ratingCount": 4853138, "shelf": "read", "rating": 10, "added": "2026-11-01" },
|
||||||
{ "goodreadsId": "61439040", "url": "https://www.goodreads.com/book/show/61439040", "title": "1984", "authors": ["George Orwell"], "cover": "https://m.media-amazon.com/images/S/compressed.photo.goodreads.com/books/1657781256i/61439040.jpg", "description": "A masterpiece of rebellion and imprisonment where war is peace freedom is slavery and Big Brother is watching. Thought Police, Big Brother, Orwellian - these words have entered our vocabulary because of George Orwell's classic dystopian novel 1984. The story of one man's Nightmare Odyssey as he pursues a forbidden love affair through a world ruled by warring states and a power structure that controls not only information but also individual thought and memory 1984 is a prophetic haunting tale More relevant than ever before 1984 exposes the worst crimes imaginable the destruction of truth...", "pages": 368, "published": "2022-07-01", "genres": ["Classics", "Fiction", "Science Fiction", "Dystopia", "Literature"], "averageRating": 4.21, "ratingCount": 5815805, "shelf": "read", "rating": 10, "added": "2026-11-01" }
|
{ "goodreadsId": "61439040", "url": "https://www.goodreads.com/book/show/61439040", "title": "1984", "authors": ["George Orwell"], "cover": "https://m.media-amazon.com/images/S/compressed.photo.goodreads.com/books/1657781256i/61439040.jpg", "description": "A masterpiece of rebellion and imprisonment where war is peace freedom is slavery and Big Brother is watching. Thought Police, Big Brother, Orwellian - these words have entered our vocabulary because of George Orwell's classic dystopian novel 1984. The story of one man's Nightmare Odyssey as he pursues a forbidden love affair through a world ruled by warring states and a power structure that controls not only information but also individual thought and memory 1984 is a prophetic haunting tale More relevant than ever before 1984 exposes the worst crimes imaginable the destruction of truth...", "pages": 368, "published": "2022-07-01", "genres": ["Classics", "Fiction", "Science Fiction", "Dystopia", "Literature"], "averageRating": 4.21, "ratingCount": 5815805, "shelf": "read", "rating": 10, "added": "2026-11-01" },
|
||||||
|
{
|
||||||
|
"goodreadsId": "13228487",
|
||||||
|
"url": "https://www.goodreads.com/book/show/13228487",
|
||||||
|
"title": "The Dragonet Prophecy (Wings of Fire, #1)",
|
||||||
|
"authors": ["Tui T. Sutherland"],
|
||||||
|
"cover": "https://m.media-amazon.com/images/S/compressed.photo.goodreads.com/books/1339198070i/13228487.jpg",
|
||||||
|
"description": "A thrilling new series soars above the competition and redefines middle-grade fantasy fiction for a new generation!\n\nThe seven dragon tribes have been at war for generations, locked in an endless battle over an ancient, lost treasure. A secret movement called the Talons of Peace is determined to bring an end to the fighting, with the help of a prophecy -- a foretelling that calls for great sacrifice.\n\n\n\nFive dragonets are collected to fulfill the prophecy, raised in a hidden cave and enlisted, against their will, to end the terrible war.\n\n\n\nBut not every dragonet wants a destiny. And when the...",
|
||||||
|
"pages": 336,
|
||||||
|
"published": "2012-07-01",
|
||||||
|
"genres": ["Fantasy", "Dragons", "Middle Grade", "Fiction", "Childrens"],
|
||||||
|
"averageRating": 4.22,
|
||||||
|
"ratingCount": 88779,
|
||||||
|
"shelf": "read",
|
||||||
|
"added": "2026-11-01",
|
||||||
|
"series": { "name": "Wings of Fire", "url": "https://www.goodreads.com/series/92037", "position": "Book 1" },
|
||||||
|
"rating": 7
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"goodreadsId": "15809617",
|
||||||
|
"url": "https://www.goodreads.com/book/show/15809617",
|
||||||
|
"title": "The Lost Heir (Wings of Fire, #2)",
|
||||||
|
"authors": ["Tui T. Sutherland"],
|
||||||
|
"cover": "https://m.media-amazon.com/images/S/compressed.photo.goodreads.com/books/1358266376i/15809617.jpg",
|
||||||
|
"description": "The WINGS OF FIRE saga continues with a thrilling underwater adventure -- and a mystery that will change everything!The lost heir to the SeaWing throne is going home at last...She can’t believe it’s finally happening. Tsunami and her fellow dragonets of destiny are journeying under the water to the great SeaWing Kingdom. Stolen as an egg from the royal hatchery, Tsunami is eager to meet her future subjects and reunite with her mother, Queen Coral.But Tsunami’s triumphant return doesn’t go quite the way she’d imagined. Queen Coral welcomes her with open wings, but a mysterious assassin has...",
|
||||||
|
"pages": 296,
|
||||||
|
"published": "2013-01-01",
|
||||||
|
"genres": ["Fantasy", "Dragons", "Middle Grade", "Fiction", "Childrens"],
|
||||||
|
"averageRating": 4.4,
|
||||||
|
"ratingCount": 53440,
|
||||||
|
"shelf": "read",
|
||||||
|
"added": "2026-11-01",
|
||||||
|
"series": { "name": "Wings of Fire", "url": "https://www.goodreads.com/series/92037", "position": "Book 2" },
|
||||||
|
"rating": 8
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"goodreadsId": "16100976",
|
||||||
|
"url": "https://www.goodreads.com/book/show/16100976",
|
||||||
|
"title": "The Hidden Kingdom (Wings of Fire, #3)",
|
||||||
|
"authors": ["Tui T. Sutherland"],
|
||||||
|
"cover": "https://m.media-amazon.com/images/S/compressed.photo.goodreads.com/books/1364066230i/16100976.jpg",
|
||||||
|
"description": "The WINGS OF FIRE saga continues!\n\nDeep in the rain forest, danger awaits...\n\nGlory knows the dragon world is wrong about her tribe. After all, she isn't \"as lazy as a RainWing\" -- she isn't lazy at all! Maybe she wasn't meant to be one of the dragonets of destiny, as the older dragons constantly remind her, but Glory is sharp and her venom is deadly... except, of course, no one knows it.\n\nWhen the dragonets seek shelter in the rain forest, Glory is devastated to find that the treetops are full of RainWings that no dragon could ever call dangerous. They nap all day and know nothing of the...",
|
||||||
|
"pages": 336,
|
||||||
|
"published": "2013-05-28",
|
||||||
|
"genres": ["Fantasy", "Dragons", "Middle Grade", "Fiction", "Childrens"],
|
||||||
|
"averageRating": 4.48,
|
||||||
|
"ratingCount": 47654,
|
||||||
|
"shelf": "read",
|
||||||
|
"added": "2026-11-01",
|
||||||
|
"series": { "name": "Wings of Fire", "url": "https://www.goodreads.com/series/92037", "position": "Book 3" },
|
||||||
|
"rating": 9
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"goodreadsId": "17675467",
|
||||||
|
"url": "https://www.goodreads.com/book/show/17675467",
|
||||||
|
"title": "The Dark Secret (Wings of Fire, #4)",
|
||||||
|
"authors": ["Tui T. Sutherland"],
|
||||||
|
"cover": "https://m.media-amazon.com/images/S/compressed.photo.goodreads.com/books/1368813111i/17675467.jpg",
|
||||||
|
"description": "The WINGS OF FIRE saga continues with a visit to the mysterious land of the NightWings, where Starflight must face a terrible choice---his home, or his friends?\n\nLike all the dragonets of destiny, Starflight has always wanted to see his home -- but he's also been afraid of his fellow NightWings. Starflight doesn't have mindreading powers like his tribe, and he doesn't understand why they're so secretive. No one has ever even seen the NightWing queen.\n\nBut now Starflight is the dragonets' only hope -- he must find a way to negotiate with his fellow NightWings to free the RainWing dragons...",
|
||||||
|
"pages": 336,
|
||||||
|
"published": "2013-10-29",
|
||||||
|
"genres": ["Fantasy", "Dragons", "Middle Grade", "Fiction", "Childrens"],
|
||||||
|
"averageRating": 4.36,
|
||||||
|
"ratingCount": 43095,
|
||||||
|
"shelf": "read",
|
||||||
|
"added": "2026-11-01",
|
||||||
|
"series": { "name": "Wings of Fire", "url": "https://www.goodreads.com/series/92037", "position": "Book 4" },
|
||||||
|
"rating": 8
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"goodreadsId": "18527497",
|
||||||
|
"url": "https://www.goodreads.com/book/show/18527497",
|
||||||
|
"title": "The Brightest Night (Wings of Fire, #5)",
|
||||||
|
"authors": ["Tui T. Sutherland"],
|
||||||
|
"cover": "https://m.media-amazon.com/images/S/compressed.photo.goodreads.com/books/1389053585i/18527497.jpg",
|
||||||
|
"description": "The dragonets struggle to fulfill the prophecy and -- somehow -- end the war in this thrilling new installment of the bestselling WINGS OF FIRE series!\n\nIt all comes down to this: The Dragonets of Destiny must finally bring the epic war to an end, reconcile the seven tribes, and choose the next queen of Pyrrhia... and make it out alive.",
|
||||||
|
"pages": 336,
|
||||||
|
"published": "2014-03-25",
|
||||||
|
"genres": ["Fantasy", "Dragons", "Middle Grade", "Fiction", "Childrens"],
|
||||||
|
"averageRating": 4.52,
|
||||||
|
"ratingCount": 40874,
|
||||||
|
"shelf": "read",
|
||||||
|
"added": "2026-11-01",
|
||||||
|
"series": { "name": "Wings of Fire", "url": "https://www.goodreads.com/series/92037", "position": "Book 5" },
|
||||||
|
"rating": 10
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"goodreadsId": "22323659",
|
||||||
|
"url": "https://www.goodreads.com/book/show/22323659",
|
||||||
|
"title": "Moon Rising (Wings of Fire, #6)",
|
||||||
|
"authors": ["Tui T. Sutherland"],
|
||||||
|
"cover": "https://m.media-amazon.com/images/S/compressed.photo.goodreads.com/books/1421370732i/22323659.jpg",
|
||||||
|
"description": "The New York Times-bestselling series soars to even greater heights with a new prophecy and five new dragonets ready to claim their destiny!\n\nPeace has come to Pyrrhia . . . for now.The war between the tribes is finally over, and now the dragonets of the prophecy have a plan for lasting Jade Mountain Academy, a school that will gather dragonets from all the tribes and teach them to live together, perhaps even as friends.Moonwatcher isn’t sure how she feels about school, however. Hidden in the rainforest for most of her life, the young NightWing has an awful secret. She can read minds, and...",
|
||||||
|
"pages": 336,
|
||||||
|
"published": "2014-12-30",
|
||||||
|
"genres": ["Fantasy", "Dragons", "Middle Grade", "Fiction", "Childrens"],
|
||||||
|
"averageRating": 4.55,
|
||||||
|
"ratingCount": 36854,
|
||||||
|
"shelf": "read",
|
||||||
|
"added": "2026-11-01",
|
||||||
|
"series": { "name": "Wings of Fire", "url": "https://www.goodreads.com/series/92037", "position": "Book 6" },
|
||||||
|
"rating": 7
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"goodreadsId": "23604435",
|
||||||
|
"url": "https://www.goodreads.com/book/show/23604435",
|
||||||
|
"title": "Winter Turning (Wings of Fire, #7)",
|
||||||
|
"authors": ["Tui T. Sutherland"],
|
||||||
|
"cover": "https://m.media-amazon.com/images/S/compressed.photo.goodreads.com/books/1419320915i/23604435.jpg",
|
||||||
|
"description": "Daring mission... or deadly mistake?\n\nWinter has been a disappointment to his royal IceWing family his whole life. When his sister, Icicle, runs away from Jade Mountain Academy, fleeing terrible crimes and possibly planning to commit more, Winter knows that they both need a second chance to make things right -- if only he can find her. \n\nWinter’s new clawmates, Moon, Qibli, and Kinkajou, won’t let him make this dangerous journey alone. They don’t seem to understand that IceWings, the most superior of all dragon tribes, can fix their own problems. When their search leads the dragonets straight...",
|
||||||
|
"pages": 336,
|
||||||
|
"published": "2015-06-30",
|
||||||
|
"genres": ["Fantasy", "Dragons", "Middle Grade", "Fiction", "Childrens"],
|
||||||
|
"averageRating": 4.44,
|
||||||
|
"ratingCount": 34278,
|
||||||
|
"shelf": "read",
|
||||||
|
"added": "2026-11-01",
|
||||||
|
"series": { "name": "Wings of Fire", "url": "https://www.goodreads.com/series/92037", "position": "Book 7" },
|
||||||
|
"rating": 7
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"goodreadsId": "25711360",
|
||||||
|
"url": "https://www.goodreads.com/book/show/25711360",
|
||||||
|
"title": "Escaping Peril (Wings of Fire, #8)",
|
||||||
|
"authors": ["Tui T. Sutherland"],
|
||||||
|
"cover": "https://m.media-amazon.com/images/S/compressed.photo.goodreads.com/books/1437899850i/25711360.jpg",
|
||||||
|
"description": "The New York Times and USA Today bestselling series soars to even greater heights with a new prophecy and five new dragonets ready to claim their destiny!Peril has been loyal to Queen Scarlet, who used her fatal firescales to kill countless dragons in the SkyWing arena. Now, Peril is loyal to Clay, the only dragonet who has ever been her friend. So when Scarlet threatens Jade Mountain Academy, Peril sets off to find her former queen, stop her, and save the day, no matter what it takes.There's just one a strangely persistent SeaWing, Turtle, insists on coming along, too. Turtle is worried...",
|
||||||
|
"pages": 260,
|
||||||
|
"published": "2015-12-29",
|
||||||
|
"genres": ["Fantasy", "Dragons", "Middle Grade", "Fiction", "Childrens"],
|
||||||
|
"averageRating": 4.46,
|
||||||
|
"ratingCount": 32456,
|
||||||
|
"shelf": "read",
|
||||||
|
"added": "2026-11-01",
|
||||||
|
"series": { "name": "Wings of Fire", "url": "https://www.goodreads.com/series/92037", "position": "Book 8" },
|
||||||
|
"rating": 8
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"goodreadsId": "29550388",
|
||||||
|
"url": "https://www.goodreads.com/book/show/29550388",
|
||||||
|
"title": "Talons of Power (Wings of Fire, #9)",
|
||||||
|
"authors": ["Tui T. Sutherland"],
|
||||||
|
"cover": "https://m.media-amazon.com/images/S/compressed.photo.goodreads.com/books/1468226830i/29550388.jpg",
|
||||||
|
"description": "The war is over. The false prophecy has been fulfilled. But the dragonets still have enemies. A dark evil, buried for centuries, is stirring.\n\nAnd a young NightWing may have had the first true prophecy in generations . . .\n\nSomething is coming to shake the earth\nSomething is coming to scorch the ground\nJade Mountain will fall beneath thunder and ice\nUnless the lost city of night can be found.\n\nDon't miss the next chapter in the epic, bestselling Wings of Fire series!",
|
||||||
|
"pages": 336,
|
||||||
|
"published": "2016-12-27",
|
||||||
|
"genres": ["Fantasy", "Dragons", "Middle Grade", "Fiction", "Childrens"],
|
||||||
|
"averageRating": 4.48,
|
||||||
|
"ratingCount": 29715,
|
||||||
|
"shelf": "read",
|
||||||
|
"added": "2026-11-01",
|
||||||
|
"series": { "name": "Wings of Fire", "url": "https://www.goodreads.com/series/92037", "position": "Book 9" },
|
||||||
|
"rating": 7
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"goodreadsId": "33198183",
|
||||||
|
"url": "https://www.goodreads.com/book/show/33198183",
|
||||||
|
"title": "Darkness of Dragons (Wings of Fire, #10)",
|
||||||
|
"authors": ["Tui T. Sutherland"],
|
||||||
|
"cover": "https://m.media-amazon.com/images/S/compressed.photo.goodreads.com/books/1481665982i/33198183.jpg",
|
||||||
|
"description": "An ancient evil, a terrible prophecy -- and five dragonets who could save the world...Don't miss the most thrilling adventure yet in the New York Times bestselling Wings of Fire series! Qibli knows Darkstalker must be stopped. And he knows he could stop him -- if he had magic. With even a sliver of the ancient dragon's all-powerful scroll, Qibli could rewrite history the right end war forever; make every dragon happy; perhaps even cast a very small spell so that everyone would like him...Instead, as Darkstalker's dangerous influence spreads across Pyrrhia, entrancing or killing every dragon...",
|
||||||
|
"pages": 336,
|
||||||
|
"published": "2017-07-25",
|
||||||
|
"genres": ["Fantasy", "Dragons", "Middle Grade", "Fiction", "Childrens"],
|
||||||
|
"averageRating": 4.53,
|
||||||
|
"ratingCount": 27543,
|
||||||
|
"shelf": "read",
|
||||||
|
"added": "2026-11-01",
|
||||||
|
"series": { "name": "Wings of Fire", "url": "https://www.goodreads.com/series/92037", "position": "Book 10" },
|
||||||
|
"rating": 9
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"goodreadsId": "35965782",
|
||||||
|
"url": "https://www.goodreads.com/book/show/35965782",
|
||||||
|
"title": "The Lost Continent (Wings of Fire, #11)",
|
||||||
|
"authors": ["Tui T. Sutherland"],
|
||||||
|
"cover": "https://m.media-amazon.com/images/S/compressed.photo.goodreads.com/books/1518644206i/35965782.jpg",
|
||||||
|
"description": "For centuries there have been rumors of another continent on the dragons' planet -- another land far across the ocean, populated by tribes of dragons very different from those we know. But there's never been any evidence, and most dragons dismissed the rumors as fairy tales. Until now. Because it turns out the stories are true. And the other tribes are coming.",
|
||||||
|
"pages": null,
|
||||||
|
"published": null,
|
||||||
|
"genres": ["Fantasy", "Dragons", "Middle Grade", "Fiction", "Childrens"],
|
||||||
|
"averageRating": 4.41,
|
||||||
|
"ratingCount": 23048,
|
||||||
|
"shelf": "read",
|
||||||
|
"added": "2026-11-01",
|
||||||
|
"series": { "name": "Wings of Fire", "url": "https://www.goodreads.com/series/92037", "position": "Book 11" },
|
||||||
|
"rating": 7
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"goodreadsId": "39983543",
|
||||||
|
"url": "https://www.goodreads.com/book/show/39983543",
|
||||||
|
"title": "The Hive Queen (Wings of Fire, #12)",
|
||||||
|
"authors": ["Tui T. Sutherland"],
|
||||||
|
"cover": "https://m.media-amazon.com/images/S/compressed.photo.goodreads.com/books/1537623438i/39983543.jpg",
|
||||||
|
"description": "Growing up in the hives, Cricket has always had a million questions. Why are trees forbidden, even in art? Why do her parents seem to hate her? And the biggest, most dangerous and secret question of all: why is Cricket immune to Queen Wasp's powers? Whenever the queen takes control of all the HiveWings, speaking through their mouths and seeing through their eyes, Cricket has to hide, terrified of being discovered. Now she's hiding again, wanted for stealing the Book of Clearsight along with her new SilkWing friends, Blue and Swordtail, and the fierce LeafWing, Sundew. The fugitives need...",
|
||||||
|
"pages": 286,
|
||||||
|
"published": "2018-12-26",
|
||||||
|
"genres": ["Fantasy", "Dragons", "Middle Grade", "Fiction", "Audiobook"],
|
||||||
|
"averageRating": 4.43,
|
||||||
|
"ratingCount": 20949,
|
||||||
|
"shelf": "read",
|
||||||
|
"added": "2026-11-01",
|
||||||
|
"series": { "name": "Wings of Fire", "url": "https://www.goodreads.com/series/92037", "position": "Book 12" },
|
||||||
|
"rating": 8
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"goodreadsId": "35965828",
|
||||||
|
"url": "https://www.goodreads.com/book/show/35965828",
|
||||||
|
"title": "The Poison Jungle (Wings of Fire, #13)",
|
||||||
|
"authors": ["Tui T. Sutherland"],
|
||||||
|
"cover": "https://m.media-amazon.com/images/S/compressed.photo.goodreads.com/books/1549002227i/35965828.jpg",
|
||||||
|
"description": "Some secrets are deadly.\n\nIt's no secret that Sundew wants to destroy the HiveWings. It's her life's mission to exact revenge on the tribe that tried to wipe out the LeafWings and ripped every tree from the surface of Pantala.\n\nEvery tree, that is, except the wild and dangerous Poison Jungle, where the surviving LeafWings have been hiding since the war. Hiding, plotting, and waiting for a dragon like Sundew, who is uniquely qualified to bring down the Hives.\n\nThere are dark secrets in the jungle, though-some that Sundew is keeping, and some that she's only just beginning to discover. And now...",
|
||||||
|
"pages": 336,
|
||||||
|
"published": "2019-06-25",
|
||||||
|
"genres": ["Fantasy", "Middle Grade", "Dragons", "Fiction", "Young Adult"],
|
||||||
|
"averageRating": 4.38,
|
||||||
|
"ratingCount": 23462,
|
||||||
|
"shelf": "read",
|
||||||
|
"added": "2026-11-01",
|
||||||
|
"series": { "name": "Wings of Fire", "url": "https://www.goodreads.com/series/92037", "position": "Book 13" },
|
||||||
|
"rating": 10
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"goodreadsId": "49090511",
|
||||||
|
"url": "https://www.goodreads.com/book/show/49090511",
|
||||||
|
"title": "The Dangerous Gift (Wings of Fire, #14)",
|
||||||
|
"authors": ["Tui T. Sutherland"],
|
||||||
|
"cover": "https://m.media-amazon.com/images/S/compressed.photo.goodreads.com/books/1602613751i/49090511.jpg",
|
||||||
|
"description": "The #1 New York Times bestselling series continues!\n\nSnowfall didn't expect to be queen of the IceWings at such a young age, but now that she is, she's going to be the best queen ever. All she has to do is keep her tribe within IceWing territory, where it's safe -- while keeping every other tribe out, where they belong.\n\nIt's a perfect and simple plan, backed up by all the IceWing magic Snowfall can find. That is, until a storm of unidentified dragons arrives on her shore, looking for asylum.\n\nThe foreigners are completely strange and, Snowfall is certain, utterly untrustworthy. But as she...",
|
||||||
|
"pages": 316,
|
||||||
|
"published": "2021-03-02",
|
||||||
|
"genres": ["Fantasy", "Dragons", "Middle Grade", "Fiction", "Childrens"],
|
||||||
|
"averageRating": 4.42,
|
||||||
|
"ratingCount": 16385,
|
||||||
|
"shelf": "want-to-read",
|
||||||
|
"added": "2026-11-01",
|
||||||
|
"series": { "name": "Wings of Fire", "url": "https://www.goodreads.com/series/92037", "position": "Book 14" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"goodreadsId": "57468679",
|
||||||
|
"url": "https://www.goodreads.com/book/show/57468679",
|
||||||
|
"title": "The Flames of Hope (Wings of Fire, #15)",
|
||||||
|
"authors": ["Tui T. Sutherland"],
|
||||||
|
"cover": "https://m.media-amazon.com/images/S/compressed.photo.goodreads.com/books/1648003520i/57468679.jpg",
|
||||||
|
"description": "The #1 New York Times bestselling series is hotter than ever, and this thrilling conclusion to the Lost Continent Prophecy arc is a must-read!\n\nIn the last installment of the third arc of the Wings of Fire series, tensions are higher than ever as we prepare for a fight for the survival of all dragonkind!",
|
||||||
|
"pages": 330,
|
||||||
|
"published": "2022-04-05",
|
||||||
|
"genres": ["Fantasy", "Dragons", "Middle Grade", "Fiction", "Audiobook"],
|
||||||
|
"averageRating": 4.29,
|
||||||
|
"ratingCount": 13974,
|
||||||
|
"shelf": "want-to-read",
|
||||||
|
"added": "2026-11-01",
|
||||||
|
"series": { "name": "Wings of Fire", "url": "https://www.goodreads.com/series/92037", "position": "Book 15" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"goodreadsId": "232700898",
|
||||||
|
"url": "https://www.goodreads.com/book/show/232700898",
|
||||||
|
"title": "The Hybrid Prince (Wings of Fire #16)",
|
||||||
|
"authors": ["Tui T. Sutherland"],
|
||||||
|
"cover": "https://m.media-amazon.com/images/S/compressed.photo.goodreads.com/books/1777635896i/232700898.jpg",
|
||||||
|
"description": "The #1 New York Times bestselling series is back with a brand new arc! Discover what happens next in this thrilling addition to the Wings of Fire saga.Umber was never supposed to be a hero . . . .As the youngest sibling of his MudWing hatching, Umber doesn't have the responsibilities of his bigwings, Reed, nor the heroic destiny prophesied for his brother, Clay. He's always been content with his role as the cheerful, goofy, little brother. But when his sister, Sora, causes a tragedy at Jade Mountain Academy, Umber finds himself on the run and thrown into a whole new role--that of...",
|
||||||
|
"pages": 341,
|
||||||
|
"published": "2026-03-03",
|
||||||
|
"genres": ["Fantasy", "Dragons", "Middle Grade", "Fiction", "Audiobook"],
|
||||||
|
"averageRating": 4.36,
|
||||||
|
"ratingCount": 3484,
|
||||||
|
"shelf": "want-to-read",
|
||||||
|
"added": "2026-11-01",
|
||||||
|
"series": { "name": "Wings of Fire", "url": "https://www.goodreads.com/series/92037", "position": "Book 16" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"goodreadsId": "13453029",
|
||||||
|
"url": "https://www.goodreads.com/book/show/13453029",
|
||||||
|
"title": "Wool Omnibus (Silo, #1)",
|
||||||
|
"authors": ["Hugh Howey"],
|
||||||
|
"cover": "https://m.media-amazon.com/images/S/compressed.photo.goodreads.com/books/1349614200i/13453029.jpg",
|
||||||
|
"description": "This Omnibus Edition collects the five Wool books into a single volume. \n\nThe first Wool story was released as a standalone short in July of 2011. Due to reviewer demand, the rest of the story was released over the next six months. \n\nThis is the story of mankind clawing for survival, of mankind on the edge. The world outside has grown unkind, the view of it limited, talk of it forbidden. But there are always those who hope, who dream. These are the dangerous people, the residents who infect others with their optimism. Their punishment is simple. They are given the very thing they profess to...",
|
||||||
|
"pages": 509,
|
||||||
|
"published": "2012-01-25",
|
||||||
|
"genres": ["Science Fiction", "Fiction", "Dystopia", "Post Apocalyptic", "Audiobook"],
|
||||||
|
"averageRating": 4.22,
|
||||||
|
"ratingCount": 297608,
|
||||||
|
"shelf": "want-to-read",
|
||||||
|
"added": "2026-11-01",
|
||||||
|
"series": { "name": "Silo", "url": "https://www.goodreads.com/series/70647", "position": "Book 1" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"goodreadsId": "17306293",
|
||||||
|
"url": "https://www.goodreads.com/book/show/17306293",
|
||||||
|
"title": "Shift (Silo, #2)",
|
||||||
|
"authors": ["Hugh Howey"],
|
||||||
|
"cover": "https://m.media-amazon.com/images/S/compressed.photo.goodreads.com/books/1379003176i/17306293.jpg",
|
||||||
|
"description": "Shift goes back to show the first days of the Silo, and the beginning of the end In 2007, the Center for Automation in Nanobiotech (CAN) outlined the hardware and software platforms that would one day allow robots smaller than human cells to make medical diagnoses, conduct repairs, and even self-propagate. In the same year, the CBS network re-aired a program about the effects of propranolol on sufferers of extreme trauma. A simple pill, it had been discovered, could wipe out the memory of any traumatic event. At almost the same moment in humanity’s broad history, humankind discovered the...",
|
||||||
|
"pages": 579,
|
||||||
|
"published": "2013-01-28",
|
||||||
|
"genres": ["Science Fiction", "Fiction", "Dystopia", "Audiobook", "Post Apocalyptic"],
|
||||||
|
"averageRating": 4.12,
|
||||||
|
"ratingCount": 171992,
|
||||||
|
"shelf": "want-to-read",
|
||||||
|
"added": "2026-11-01",
|
||||||
|
"series": { "name": "Silo", "url": "https://www.goodreads.com/series/70647", "position": "Book 2" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"goodreadsId": "18866705",
|
||||||
|
"url": "https://www.goodreads.com/book/show/18866705",
|
||||||
|
"title": "Dust (Silo, #3)",
|
||||||
|
"authors": ["Hugh Howey"],
|
||||||
|
"cover": "https://m.media-amazon.com/images/S/compressed.photo.goodreads.com/books/1436806122i/18866705.jpg",
|
||||||
|
"description": "The much-anticipated final instalment of the Wool trilogy.\n\nIn the aftermath of the uprising, the people of Silo 18 are coming to terms with a new order. \n\nSome embrace the change, others fear the unknown; none have control of their fate.\n\nThe Silo is still in danger.\n\nThere are those set on its destruction.\n\nJules knows they must be stopped.\n\nThe battle has been won.\n\nThe war is just beginning.",
|
||||||
|
"pages": 416,
|
||||||
|
"published": "2013-08-17",
|
||||||
|
"genres": ["Science Fiction", "Fiction", "Dystopia", "Post Apocalyptic", "Audiobook"],
|
||||||
|
"averageRating": 4.22,
|
||||||
|
"ratingCount": 153493,
|
||||||
|
"shelf": "want-to-read",
|
||||||
|
"added": "2026-11-01",
|
||||||
|
"series": { "name": "Silo", "url": "https://www.goodreads.com/series/70647", "position": "Book 3" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"goodreadsId": "37781",
|
||||||
|
"url": "https://www.goodreads.com/book/show/37781",
|
||||||
|
"title": "Things Fall Apart (The African Trilogy, #1)",
|
||||||
|
"authors": ["Chinua Achebe"],
|
||||||
|
"cover": "https://m.media-amazon.com/images/S/compressed.photo.goodreads.com/books/1708114013i/37781.jpg",
|
||||||
|
"description": "“A true classic of world literature . . . A masterpiece that has inspired generations of writers in Nigeria, across Africa, and around the world.” —Barack Obama\n\n“African literature is incomplete and unthinkable without the works of Chinua Achebe.” —Toni Morrison\n\n\"A magical writer—one of the greats of the twentieth century.\" —Margaret Atwood\n\nNamed one of America's most-loved novels by PBS’s The Great American Read\n\nThings Fall Apart is the first of three novels in Chinua Achebe's critically acclaimed African Trilogy. It is a classic narrative about Africa's cataclysmic encounter with Europe...",
|
||||||
|
"pages": 215,
|
||||||
|
"published": "1994-10-01",
|
||||||
|
"genres": ["Fiction", "Classics", "Historical Fiction", "Africa", "School"],
|
||||||
|
"averageRating": 3.75,
|
||||||
|
"ratingCount": 428943,
|
||||||
|
"shelf": "read",
|
||||||
|
"added": "2026-11-01",
|
||||||
|
"series": { "name": "The African Trilogy", "url": "https://www.goodreads.com/series/168203", "position": "Book 1" },
|
||||||
|
"rating": 9
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"goodreadsId": "37774",
|
||||||
|
"url": "https://www.goodreads.com/book/show/37774",
|
||||||
|
"title": "Arrow of God (The African Trilogy, #2)",
|
||||||
|
"authors": ["Chinua Achebe"],
|
||||||
|
"cover": "https://m.media-amazon.com/images/S/compressed.photo.goodreads.com/books/1708114068i/37774.jpg",
|
||||||
|
"description": "Set in the Igbo heartland of eastern Nigeria, one of Africa's best-known writers describes the conflict between old and new in its most poignant aspect: the personal struggle between father and son.\n\nEzeulu, the headstrong chief priest of the god Ulu, is worshipped by the six villages of Umuaro. But his authority is increasingly under threat—from rivals within his tribe, from functionaries of the colonial government, and even from his own family members. Yet he believes himself to be untouchable: surely he is an arrow in the bow of his God? Armed with this belief, he is prepared to lead his...",
|
||||||
|
"pages": 230,
|
||||||
|
"published": "1989-01-01",
|
||||||
|
"genres": ["Fiction", "Africa", "Classics", "Historical Fiction", "African Literature"],
|
||||||
|
"averageRating": 3.79,
|
||||||
|
"ratingCount": 9927,
|
||||||
|
"shelf": "read",
|
||||||
|
"added": "2026-11-01",
|
||||||
|
"series": { "name": "The African Trilogy", "url": "https://www.goodreads.com/series/168203", "position": "Book 2" },
|
||||||
|
"rating": 8
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"goodreadsId": "111085",
|
||||||
|
"url": "https://www.goodreads.com/book/show/111085",
|
||||||
|
"title": "No Longer at Ease (The African Trilogy, #3)",
|
||||||
|
"authors": ["Chinua Achebe"],
|
||||||
|
"cover": "https://m.media-amazon.com/images/S/compressed.photo.goodreads.com/books/1708114041i/111085.jpg",
|
||||||
|
"description": "An alternate cover for this ISBN can be found here.\n\nObi Okonkwo is an idealistic young man who has now returned to Nigeria for a job in the civil service. However in his new role he finds that the way of government seems to be corruption. Obi manages to resist the bribes offered to him, but when he falls in love with an unsuitable girl, he sinks further into emotional and financial turmoil.",
|
||||||
|
"pages": 196,
|
||||||
|
"published": "1994-09-16",
|
||||||
|
"genres": ["Fiction", "Africa", "Classics", "Historical Fiction", "Nigeria"],
|
||||||
|
"averageRating": 3.87,
|
||||||
|
"ratingCount": 13698,
|
||||||
|
"shelf": "read",
|
||||||
|
"added": "2026-11-01",
|
||||||
|
"series": { "name": "The African Trilogy", "url": "https://www.goodreads.com/series/168203", "position": "Book 3" },
|
||||||
|
"rating": 8
|
||||||
|
},
|
||||||
|
{ "goodreadsId": "22875069", "url": "https://www.goodreads.com/book/show/22875069", "title": "I Will Always Write Back: How One Letter Changed Two Lives", "authors": ["Caitlin Alifirenka", "Liz Welch", "Martin Ganda"], "cover": "https://m.media-amazon.com/images/S/compressed.photo.goodreads.com/books/1415206347i/22875069.jpg", "description": "The true story of an all-American girl and a boy from an impoverished city in Zimbabwe and the letter that changed both of their lives forever.\n\nIt started as an assignment. Everyone in Caitlin's class wrote to an unknown student somewhere in a distant place. All the other kids picked countries like France or Germany, but when Caitlin saw Zimbabwe written on the board, it sounded like the most exotic place she had ever heard of--so she chose it. \nMartin was lucky to even receive a pen pal letter. There were only ten letters, and forty kids in his class. But he was the top student, so he got...", "pages": 392, "published": "2015-04-14", "genres": ["Nonfiction", "Memoir", "Book Club", "Audiobook", "Biography"], "averageRating": 4.38, "ratingCount": 31650, "shelf": "read", "rating": 9, "added": "2026-11-01" },
|
||||||
|
{ "goodreadsId": "33118312", "url": "https://www.goodreads.com/book/show/33118312", "title": "Refugee", "authors": ["Alan Gratz"], "cover": "https://m.media-amazon.com/images/S/compressed.photo.goodreads.com/books/1487620933i/33118312.jpg", "description": "JOSEF is a Jewish boy living in 1930s Nazi Germany. With the threat of concentration camps looming, he and his family board a ship bound for the other side of the world...\n\nISABEL is a Cuban girl in 1994. With riots and unrest plaguing her country, she and her family set out on a raft, hoping to find safety in America...\n\nMAHMOUD is a Syrian boy in 2015. With his homeland torn apart by violence and destruction, he and his family begin a long trek toward Europe...\n\nAll three kids go on harrowing journeys in search of refuge. All will face unimaginable dangers -- from drownings to bombings to...", "pages": 352, "published": "2017-07-25", "genres": ["Historical Fiction", "Young Adult", "Middle Grade", "Fiction", "Historical"], "averageRating": 4.35, "ratingCount": 87957, "shelf": "read", "rating": 7, "added": "2026-11-01" },
|
||||||
|
{ "goodreadsId": "1381", "url": "https://www.goodreads.com/book/show/1381", "title": "The Odyssey", "authors": ["Homer"], "cover": "https://m.media-amazon.com/images/S/compressed.photo.goodreads.com/books/1711957706i/1381.jpg", "description": "Sing to me of the man, Muse, the man of twists and turns\ndriven time and again off course, once he had plundered\nthe hallowed heights of Troy.\n\nSo begins Robert Fagles's magnificent translation of the Odyssey.\n\nIf The Iliad is the world's greatest war epic, The Odyssey is literature's grandest evocation of the everyman's journey through life. During his ten-year voyage home to Ithaca after the Trojan War, Odysseus relies on his wit and wiles for survival in his encounters with divine and natural forces.\n\nIn the myths and legends retold here, Fagles has captured the energy and poetry of...", "pages": 541, "published": "2006-10-31", "genres": ["Classics", "Fiction", "Poetry", "Mythology", "Fantasy"], "averageRating": 3.86, "ratingCount": 1299443, "shelf": "read", "rating": 9, "added": "2026-11-01" },
|
||||||
|
{ "goodreadsId": "38631641", "url": "https://www.goodreads.com/book/show/38631641", "title": "The lliad", "authors": ["Homer"], "cover": "https://m.media-amazon.com/images/S/compressed.photo.goodreads.com/books/1519449125i/38631641.jpg", "description": "The Iliad Ancient Greek: Ilias, pronounced [i?.li.as] in Classical Attic; sometimes referred to as the Song of Ilion or Song of Ilium) is an ancient Greek epic poem in dactylic hexameter, traditionally attributed to Homer. Set during the Trojan War, the ten-year siege of the city of Troy (Ilium) by a coalition of Greek states, it tells of the battles and events during the weeks of a quarrel between King Agamemnon and the warrior Achilles.", "pages": 734, "published": "2018-02-17", "genres": ["Classics", "Poetry", "Fiction", "Mythology", "Literature"], "averageRating": 3.94, "ratingCount": 542114, "shelf": "read", "rating": 7, "added": "2026-11-01" },
|
||||||
|
{ "goodreadsId": "35031085", "url": "https://www.goodreads.com/book/show/35031085", "title": "Frankenstein", "authors": ["Mary Wollstonecraft Shelley"], "cover": "https://m.media-amazon.com/images/S/compressed.photo.goodreads.com/books/1631088473i/35031085.jpg", "description": "Mary Shelley's seminal novel of the scientist whose creation becomes a monster. \n\nThis edition is the original 1818 text, which preserves the hard-hitting and politically charged aspects of Shelley's original writing, as well as her unflinching wit and strong female voice. This edition also includes a new introduction and suggestions for further reading by author and Shelley expert Charlotte Gordon, literary excerpts and reviews selected by Gordon and a chronology and essay by preeminent Shelley scholar Charles E. Robinson.", "pages": 260, "published": "2018-01-16", "genres": ["Classics", "Horror", "Fiction", "Science Fiction", "Gothic"], "averageRating": 3.93, "ratingCount": 2031053, "shelf": "read", "rating": 10, "added": "2026-11-01" },
|
||||||
|
{ "goodreadsId": "41733839", "url": "https://www.goodreads.com/book/show/41733839", "title": "The Great Gatsby", "authors": ["F. Scott Fitzgerald"], "cover": "https://m.media-amazon.com/images/S/compressed.photo.goodreads.com/books/1650033243i/41733839.jpg", "description": "Alternate covers of this ISBN can be found here, here and here.\n\nJames L. W. West III to include the author’s final revisions and features a note on the composition and text, a personal foreword by Fitzgerald’s granddaughter, Eleanor Lanahan—and a new introduction by two-time National Book Award winner Jesmyn Ward. \n\nThe Great Gatsby, F. Scott Fitzgerald’s third book, stands as the supreme achievement of his career. First published in 1925, this quintessential novel of the Jazz Age has been acclaimed by generations of readers. The story of the mysteriously wealthy Jay Gatsby and his love for...", "pages": 180, "published": "2018-04-01", "genres": ["Classics", "Fiction", "School", "Historical Fiction", "Romance"], "averageRating": 3.92, "ratingCount": 6178246, "shelf": "read", "rating": 6, "added": "2026-11-01" },
|
||||||
|
{ "goodreadsId": "192566704", "url": "https://www.goodreads.com/book/show/192566704", "title": "The Outsiders", "authors": ["S.E. Hinton"], "cover": "https://m.media-amazon.com/images/S/compressed.photo.goodreads.com/books/1689626520i/192566704.jpg", "description": "No one ever said life was easy. But Ponyboy is pretty sure that he's got things figured out. He knows that he can count on his brothers, Darry and Sodapop. And he knows that he can count on his friends - true friends who would do anything for him, like Johnny and Two-Bit. And when it comes to the beating up on \"greasers\" like him and his friends - he knows that he can count on them for trouble. But one night someone takes things too far, and Ponyboy's world is turned upside down...", "pages": 214, "published": "2021-01-01", "genres": ["Classics", "Young Adult", "Fiction", "School", "Read For School"], "averageRating": 4.15, "ratingCount": 1702819, "shelf": "read", "rating": 9, "added": "2026-11-01" }
|
||||||
]
|
]
|
||||||
+3
-1
@@ -8,6 +8,8 @@
|
|||||||
"https://music.youtube.com/watch?v=KfEEm4Zx-EU&si=V0b6oiP4P-fcE6VI",
|
"https://music.youtube.com/watch?v=KfEEm4Zx-EU&si=V0b6oiP4P-fcE6VI",
|
||||||
"https://music.youtube.com/watch?v=fixh0ixT-9U&si=dWLHv9bylwNAu-DD",
|
"https://music.youtube.com/watch?v=fixh0ixT-9U&si=dWLHv9bylwNAu-DD",
|
||||||
"https://music.youtube.com/watch?v=En_6WWOWh8E&si=KSUbzK7M3NFmbAUO",
|
"https://music.youtube.com/watch?v=En_6WWOWh8E&si=KSUbzK7M3NFmbAUO",
|
||||||
"https://music.youtube.com/watch?v=Kf5pXDhx5Vc&si=yUIlI-2md9uYjGu5"
|
"https://music.youtube.com/watch?v=Kf5pXDhx5Vc&si=yUIlI-2md9uYjGu5",
|
||||||
|
"https://music.youtube.com/watch?v=vH3ScuFjCyU",
|
||||||
|
"https://music.youtube.com/watch?v=NengL7n9tR8"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import type { Book, RatedTitle } from '#lib/content';
|
||||||
|
import type { AniListProfile } from '#lib/anilist/types';
|
||||||
|
import type { ListeningProfile } from '#lib/lastfm/types';
|
||||||
|
import type { AniListStats, BookStats, CountStat, ListeningStats, ScreenStats } from './types';
|
||||||
|
|
||||||
|
const recentDays = 30;
|
||||||
|
const topLimit = 5;
|
||||||
|
const statusColors: Record<string, string> = {
|
||||||
|
CURRENT: 'blue',
|
||||||
|
REPEATING: 'cyan',
|
||||||
|
COMPLETED: 'green',
|
||||||
|
PAUSED: 'yellow',
|
||||||
|
DROPPED: 'red',
|
||||||
|
PLANNING: 'magenta'
|
||||||
|
};
|
||||||
|
|
||||||
|
function average(values: number[]): string {
|
||||||
|
if (!values.length) return '-';
|
||||||
|
return `${(values.reduce((total, value) => total + value, 0) / values.length).toFixed(1)}/10`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ratingCounts(ratings: number[]): number[] {
|
||||||
|
const counts = Array<number>(11).fill(0);
|
||||||
|
ratings.forEach((rating) => counts[Math.round(rating)]++);
|
||||||
|
return counts;
|
||||||
|
}
|
||||||
|
|
||||||
|
function topCounts(values: string[], color: string): CountStat[] {
|
||||||
|
const counts = new Map<string, number>();
|
||||||
|
values.forEach((value) => counts.set(value, (counts.get(value) ?? 0) + 1));
|
||||||
|
return [...counts.entries()]
|
||||||
|
.sort((first, second) => second[1] - first[1])
|
||||||
|
.slice(0, topLimit)
|
||||||
|
.map(([label, count]) => ({ label, count, color }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bookStats(books: Book[]): BookStats | null {
|
||||||
|
if (!books.length) return null;
|
||||||
|
const read = books.filter((book) => book.shelf === 'read');
|
||||||
|
const ratings = read.flatMap((book) => (book.rating === undefined ? [] : [book.rating]));
|
||||||
|
const seriesNames = new Set(books.flatMap((book) => (book.series ? [book.series.name] : [])));
|
||||||
|
const pagesRead = read.reduce((total, book) => total + (book.pages ?? 0), 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
shelves: [
|
||||||
|
{ label: 'read', count: read.length, color: 'green' },
|
||||||
|
{ label: 'reading', count: books.filter((book) => book.shelf === 'reading').length, color: 'blue' },
|
||||||
|
{ label: 'want to read', count: books.filter((book) => book.shelf === 'want-to-read').length, color: 'magenta' }
|
||||||
|
],
|
||||||
|
rows: [
|
||||||
|
{ label: 'Pages read', value: pagesRead.toLocaleString('en-US') },
|
||||||
|
{ label: 'Authors', value: `${new Set(read.flatMap((book) => book.authors)).size}` },
|
||||||
|
{ label: 'Series', value: `${seriesNames.size}` },
|
||||||
|
{ label: 'Avg rating', value: average(ratings) }
|
||||||
|
],
|
||||||
|
topGenres: topCounts(read.flatMap((book) => book.genres), 'cyan'),
|
||||||
|
ratingCounts: ratingCounts(ratings)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function screenStats(titles: RatedTitle[]): ScreenStats {
|
||||||
|
const ratings = titles.flatMap((title) => (title.rating === undefined ? [] : [title.rating]));
|
||||||
|
const watching = titles.filter((title) => title.status === 'watching').map((title) => title.name);
|
||||||
|
const groups = new Set(titles.flatMap((title) => (title.collection ? [title.collection] : [])));
|
||||||
|
|
||||||
|
return {
|
||||||
|
rows: [
|
||||||
|
{ label: 'Titles', value: `${titles.length}` },
|
||||||
|
{ label: 'Watching', value: `${watching.length}` },
|
||||||
|
{ label: 'Groups', value: `${groups.size}` },
|
||||||
|
{ label: 'Avg rating', value: average(ratings) }
|
||||||
|
],
|
||||||
|
ratingCounts: ratingCounts(ratings),
|
||||||
|
watching
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listeningStats(profile: ListeningProfile): ListeningStats {
|
||||||
|
const artists = profile.top.month.artists.length ? profile.top.month.artists : profile.top.all.artists;
|
||||||
|
return {
|
||||||
|
profileUrl: profile.profileUrl,
|
||||||
|
rows: [
|
||||||
|
{ label: 'Scrobbles', value: profile.totals.scrobbles.toLocaleString('en-US') },
|
||||||
|
{ label: 'Artists', value: profile.totals.artists.toLocaleString('en-US') },
|
||||||
|
{ label: 'Per day', value: `${profile.averagePerDay}` },
|
||||||
|
{ label: 'Streak', value: `${profile.currentStreak}d (best ${profile.longestStreak}d)` }
|
||||||
|
],
|
||||||
|
daily: profile.daily.slice(-recentDays).map((bucket) => bucket.count),
|
||||||
|
hourly: profile.hourly.map((bucket) => bucket.count),
|
||||||
|
topArtists: artists.slice(0, topLimit).map((artist) => ({ label: artist.name, count: artist.count, color: 'red' }))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function aniListStats(profile: AniListProfile): AniListStats {
|
||||||
|
return {
|
||||||
|
profileUrl: profile.listUrl,
|
||||||
|
rows: profile.stats,
|
||||||
|
statuses: profile.statusCounts
|
||||||
|
.filter((status) => status.count > 0)
|
||||||
|
.map((status) => ({ label: status.label, count: status.count, color: statusColors[status.status] ?? 'foreground' }))
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -38,3 +38,50 @@ export interface BtopData {
|
|||||||
contributions: ContributionCalendar;
|
contributions: ContributionCalendar;
|
||||||
repos: RepoProcess[];
|
repos: RepoProcess[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CountStat {
|
||||||
|
label: string;
|
||||||
|
count: number;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StatRow {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BookStats {
|
||||||
|
shelves: CountStat[];
|
||||||
|
rows: StatRow[];
|
||||||
|
topGenres: CountStat[];
|
||||||
|
ratingCounts: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScreenStats {
|
||||||
|
rows: StatRow[];
|
||||||
|
ratingCounts: number[];
|
||||||
|
watching: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListeningStats {
|
||||||
|
profileUrl: string;
|
||||||
|
rows: StatRow[];
|
||||||
|
daily: number[];
|
||||||
|
hourly: number[];
|
||||||
|
topArtists: CountStat[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AniListStats {
|
||||||
|
profileUrl: string;
|
||||||
|
rows: StatRow[];
|
||||||
|
statuses: CountStat[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HobbyStats {
|
||||||
|
listening: ListeningStats | null;
|
||||||
|
books: BookStats | null;
|
||||||
|
shows: ScreenStats;
|
||||||
|
movies: ScreenStats;
|
||||||
|
anime: AniListStats | null;
|
||||||
|
manga: AniListStats | null;
|
||||||
|
}
|
||||||
@@ -17,10 +17,12 @@
|
|||||||
<span class="placeholder"><Icon icon={placeholderIcon} width="36" /></span>
|
<span class="placeholder"><Icon icon={placeholderIcon} width="36" /></span>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<span class="rating"><Icon icon="mdi:star" width="13" />{item.rating}/10</span>
|
{#if item.rating !== undefined}
|
||||||
|
<span class="rating"><Icon icon="mdi:star" width="13" />{item.rating}/10</span>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if details?.plot}
|
{#if item.status === 'watching'}
|
||||||
<p class="plot">{details.plot}</p>
|
<span class="watching"><Icon icon="mdi:play" width="13" />watching</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -87,24 +89,18 @@
|
|||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.plot {
|
.watching {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 0;
|
bottom: 0.4rem;
|
||||||
bottom: 0;
|
left: 0.4rem;
|
||||||
left: 0;
|
display: inline-flex;
|
||||||
margin: 0;
|
align-items: center;
|
||||||
padding: 0.6rem;
|
gap: 0.2rem;
|
||||||
background: color-mix(in srgb, var(--background) 92%, transparent);
|
padding: 0 0.4rem;
|
||||||
border-top: 1px solid var(--accent);
|
background: var(--blue);
|
||||||
|
color: var(--background);
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
line-height: 1.45;
|
font-weight: 700;
|
||||||
transform: translateY(100%);
|
|
||||||
transition: transform 0.25s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.media-card:hover .plot,
|
|
||||||
.media-card:focus-visible .plot {
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.text {
|
.text {
|
||||||
@@ -132,8 +128,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.poster img,
|
.poster img {
|
||||||
.plot {
|
|
||||||
transition: none;
|
transition: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,10 +31,6 @@
|
|||||||
{#if showShelf}
|
{#if showShelf}
|
||||||
<span class="shelf {book.shelf}">{shelfLabels[book.shelf]}</span>
|
<span class="shelf {book.shelf}">{shelfLabels[book.shelf]}</span>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if book.description}
|
|
||||||
<p class="description">{book.description}</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<span class="title">{book.title}</span>
|
<span class="title">{book.title}</span>
|
||||||
@@ -144,31 +140,6 @@
|
|||||||
color: var(--foreground);
|
color: var(--foreground);
|
||||||
}
|
}
|
||||||
|
|
||||||
.description {
|
|
||||||
position: absolute;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
left: 0;
|
|
||||||
display: -webkit-box;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0.55rem;
|
|
||||||
overflow: hidden;
|
|
||||||
background: color-mix(in srgb, var(--background) 94%, transparent);
|
|
||||||
border-top: 1px solid var(--accent);
|
|
||||||
font-size: 0.72rem;
|
|
||||||
line-height: 1.45;
|
|
||||||
-webkit-line-clamp: 8;
|
|
||||||
line-clamp: 8;
|
|
||||||
-webkit-box-orient: vertical;
|
|
||||||
transform: translateY(100%);
|
|
||||||
transition: transform 0.25s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.book-card:hover .description,
|
|
||||||
.book-card:focus-visible .description {
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
.title {
|
.title {
|
||||||
display: -webkit-box;
|
display: -webkit-box;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -191,8 +162,7 @@
|
|||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.cover,
|
.cover,
|
||||||
.cover img,
|
.cover img {
|
||||||
.description {
|
|
||||||
transition: none;
|
transition: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,147 +1,70 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Icon from '@iconify/svelte';
|
|
||||||
import type { BookSeriesGroup } from '#lib/books/series';
|
import type { BookSeriesGroup } from '#lib/books/series';
|
||||||
|
import CollectionGroup from '#lib/components/ui/CollectionGroup.svelte';
|
||||||
import BookCard from './BookCard.svelte';
|
import BookCard from './BookCard.svelte';
|
||||||
|
|
||||||
let { series }: { series: BookSeriesGroup } = $props();
|
let { series }: { series: BookSeriesGroup } = $props();
|
||||||
|
|
||||||
const collapsedCount = 8;
|
const shelfTones = { read: 'green', reading: 'blue', 'want-to-read': 'magenta' };
|
||||||
|
|
||||||
let showAll = $state(false);
|
|
||||||
|
|
||||||
const shownBooks = $derived(showAll ? series.books : series.books.slice(0, collapsedCount));
|
|
||||||
const total = $derived(series.books.length);
|
const total = $derived(series.books.length);
|
||||||
const shelves = $derived([
|
const shelves = $derived([
|
||||||
{ id: 'read', label: 'read', count: series.counts.read },
|
{ id: 'read', label: 'read', count: series.counts.read },
|
||||||
{ id: 'reading', label: 'reading', count: series.counts.reading },
|
{ id: 'reading', label: 'reading', count: series.counts.reading },
|
||||||
{ id: 'want-to-read', label: 'want to read', count: series.counts['want-to-read'] }
|
{ id: 'want-to-read', label: 'want to read', count: series.counts['want-to-read'] }
|
||||||
]);
|
] as const);
|
||||||
|
const covers = $derived(
|
||||||
|
series.books.map((book) => ({
|
||||||
|
id: book.goodreadsId,
|
||||||
|
href: book.url,
|
||||||
|
title: `${book.series?.position ?? ''} ${book.title}`.trim(),
|
||||||
|
image: book.cover,
|
||||||
|
tone: shelfTones[book.shelf]
|
||||||
|
}))
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section class="series">
|
<CollectionGroup name={series.name} href={series.url} icon="mdi:bookshelf" fallbackIcon="mdi:book-outline" {covers}>
|
||||||
<header class="series-header">
|
{#snippet summary()}
|
||||||
<a class="series-name" href={series.url} target="_blank" rel="noopener noreferrer">
|
{#each shelves.filter((shelf) => shelf.count > 0) as shelf (shelf.id)}
|
||||||
<Icon icon="mdi:bookshelf" width="16" />
|
<span style:color="var(--{shelfTones[shelf.id]})">{shelf.count} {shelf.label}</span>
|
||||||
{series.name}
|
|
||||||
</a>
|
|
||||||
<span class="series-counts">
|
|
||||||
{#each shelves.filter((shelf) => shelf.count > 0) as shelf (shelf.id)}
|
|
||||||
<span class="count {shelf.id}">{shelf.count} {shelf.label}</span>
|
|
||||||
{/each}
|
|
||||||
</span>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div class="progress" role="img" aria-label="{series.counts.read} of {total} read">
|
|
||||||
{#each shelves as shelf (shelf.id)}
|
|
||||||
{#if shelf.count}
|
|
||||||
<span class="segment {shelf.id}" style:flex-grow={shelf.count}></span>
|
|
||||||
{/if}
|
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
{/snippet}
|
||||||
|
|
||||||
|
{#snippet details()}
|
||||||
|
<div class="progress" role="img" aria-label="{series.counts.read} of {total} read">
|
||||||
|
{#each shelves as shelf (shelf.id)}
|
||||||
|
{#if shelf.count}
|
||||||
|
<span class="segment" style:flex-grow={shelf.count} style:background="var(--{shelfTones[shelf.id]})"></span>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
<div class="book-grid">
|
<div class="book-grid">
|
||||||
{#each shownBooks as book (book.goodreadsId)}
|
{#each series.books as book (book.goodreadsId)}
|
||||||
<BookCard {book} showShelf />
|
<BookCard {book} showShelf />
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
</CollectionGroup>
|
||||||
{#if total > collapsedCount}
|
|
||||||
<button class="toggle" onclick={() => (showAll = !showAll)}>
|
|
||||||
<Icon icon={showAll ? 'mdi:chevron-up' : 'mdi:chevron-down'} width="16" />
|
|
||||||
{showAll ? 'Show less' : `Show all ${total}`}
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.series {
|
|
||||||
padding: 0.75rem;
|
|
||||||
border: 1px solid color-mix(in srgb, var(--foreground) 15%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.series-header {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
align-items: baseline;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 0.4rem 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.series-name {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.4rem;
|
|
||||||
color: var(--accent);
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.series-name:hover {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
.series-counts {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.75rem;
|
|
||||||
font-size: 0.78rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.count.read {
|
|
||||||
color: var(--green);
|
|
||||||
}
|
|
||||||
|
|
||||||
.count.reading {
|
|
||||||
color: var(--blue);
|
|
||||||
}
|
|
||||||
|
|
||||||
.count.want-to-read {
|
|
||||||
color: var(--magenta);
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress {
|
.progress {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
height: 4px;
|
height: 4px;
|
||||||
margin: 0.5rem 0 0.85rem;
|
margin-bottom: 0.35rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.segment {
|
.segment {
|
||||||
flex-basis: 0;
|
flex-basis: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.segment.read {
|
|
||||||
background: var(--green);
|
|
||||||
}
|
|
||||||
|
|
||||||
.segment.reading {
|
|
||||||
background: var(--blue);
|
|
||||||
}
|
|
||||||
|
|
||||||
.segment.want-to-read {
|
|
||||||
background: var(--magenta);
|
|
||||||
}
|
|
||||||
|
|
||||||
.book-grid {
|
.book-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||||
gap: 0.9rem 0.75rem;
|
gap: 0.9rem 0.75rem;
|
||||||
}
|
padding-top: 0.5rem;
|
||||||
|
|
||||||
.toggle {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.3rem;
|
|
||||||
margin-top: 0.75rem;
|
|
||||||
padding: 0.3rem 0.7rem;
|
|
||||||
background: none;
|
|
||||||
border: 1px solid color-mix(in srgb, var(--foreground) 30%, transparent);
|
|
||||||
color: var(--foreground);
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle:hover {
|
|
||||||
border-color: var(--accent);
|
|
||||||
color: var(--accent);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@container (max-width: 520px) {
|
@container (max-width: 520px) {
|
||||||
|
|||||||
@@ -112,7 +112,8 @@
|
|||||||
|
|
||||||
.series-list {
|
.series-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-wrap: wrap;
|
||||||
|
align-items: flex-start;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { MediaCollection } from '#lib/media/collections';
|
||||||
|
import { isWatching } from '#lib/media/collections';
|
||||||
|
import type { MediaDetails } from '#lib/media/types';
|
||||||
|
import CollectionGroup from '#lib/components/ui/CollectionGroup.svelte';
|
||||||
|
import MediaCard from '../MediaCard.svelte';
|
||||||
|
|
||||||
|
let {
|
||||||
|
collection,
|
||||||
|
mediaDetails,
|
||||||
|
kind
|
||||||
|
}: { collection: MediaCollection; mediaDetails: Record<string, MediaDetails>; kind: 'show' | 'movie' } = $props();
|
||||||
|
|
||||||
|
const icon = $derived(kind === 'show' ? 'mdi:television-classic' : 'mdi:movie-open-outline');
|
||||||
|
const watchingCount = $derived(collection.items.filter(isWatching).length);
|
||||||
|
const rated = $derived(collection.items.filter((item) => item.rating !== undefined));
|
||||||
|
const average = $derived(rated.length ? rated.reduce((total, item) => total + (item.rating ?? 0), 0) / rated.length : null);
|
||||||
|
const covers = $derived(
|
||||||
|
collection.items.map((item) => ({
|
||||||
|
id: item.url,
|
||||||
|
href: item.url,
|
||||||
|
title: item.name,
|
||||||
|
image: mediaDetails[item.url]?.poster ?? null,
|
||||||
|
tone: isWatching(item) ? 'blue' : 'green'
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<CollectionGroup name={collection.name} {icon} fallbackIcon={icon} {covers}>
|
||||||
|
{#snippet summary()}
|
||||||
|
<span class="muted">{collection.items.length} {kind === 'show' ? 'shows' : 'movies'}</span>
|
||||||
|
{#if watchingCount}
|
||||||
|
<span style:color="var(--blue)">{watchingCount} watching</span>
|
||||||
|
{/if}
|
||||||
|
{#if average !== null}
|
||||||
|
<span style:color="var(--yellow)">avg {average.toFixed(1)}/10</span>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
<div class="media-grid">
|
||||||
|
{#each collection.items as item (item.url)}
|
||||||
|
<MediaCard {item} details={mediaDetails[item.url]} {kind} />
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</CollectionGroup>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.media-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||||
|
gap: 1rem 0.85rem;
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@container (max-width: 520px) {
|
||||||
|
.media-grid {
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Icon from '@iconify/svelte';
|
||||||
|
import type { RatedTitle } from '#lib/content';
|
||||||
|
import type { MediaDetails } from '#lib/media/types';
|
||||||
|
import { groupMedia } from '#lib/media/collections';
|
||||||
|
import MediaGrid from '../MediaGrid.svelte';
|
||||||
|
import MediaCard from '../MediaCard.svelte';
|
||||||
|
import MediaCollectionGroup from './MediaCollectionGroup.svelte';
|
||||||
|
|
||||||
|
let {
|
||||||
|
items,
|
||||||
|
mediaDetails,
|
||||||
|
kind
|
||||||
|
}: { items: RatedTitle[]; mediaDetails: Record<string, MediaDetails>; kind: 'show' | 'movie' } = $props();
|
||||||
|
|
||||||
|
const grouped = $derived(groupMedia(items, mediaDetails));
|
||||||
|
const collectionLabel = $derived(kind === 'show' ? 'Series' : 'Collections');
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if grouped.watching.length}
|
||||||
|
<h3 class="subheading"><Icon icon="mdi:play-circle-outline" width="16" /> Currently watching <span class="muted">{grouped.watching.length}</span></h3>
|
||||||
|
<div class="watching-grid">
|
||||||
|
{#each grouped.watching as item (item.url)}
|
||||||
|
<MediaCard {item} details={mediaDetails[item.url]} {kind} />
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if grouped.collections.length}
|
||||||
|
<h3 class="subheading"><Icon icon="mdi:layers-outline" width="16" /> {collectionLabel} <span class="muted">{grouped.collections.length}</span></h3>
|
||||||
|
<div class="collection-list">
|
||||||
|
{#each grouped.collections as collection (collection.name)}
|
||||||
|
<MediaCollectionGroup {collection} {mediaDetails} {kind} />
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if grouped.standalone.length}
|
||||||
|
{#if grouped.watching.length || grouped.collections.length}
|
||||||
|
<h3 class="subheading"><Icon icon="mdi:check-circle-outline" width="16" /> Watched <span class="muted">{grouped.standalone.length}</span></h3>
|
||||||
|
{/if}
|
||||||
|
<MediaGrid items={grouped.standalone} {mediaDetails} {kind} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.subheading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
margin: 1.25rem 0 0.6rem;
|
||||||
|
color: var(--foreground);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subheading:first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.watching-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||||
|
gap: 1rem 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collection-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@container (max-width: 520px) {
|
||||||
|
.watching-grid {
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -5,11 +5,12 @@
|
|||||||
title,
|
title,
|
||||||
number,
|
number,
|
||||||
color = 'accent',
|
color = 'accent',
|
||||||
|
tall = false,
|
||||||
children
|
children
|
||||||
}: { title: string; number: number; color?: string; children: Snippet } = $props();
|
}: { title: string; number: number; color?: string; tall?: boolean; children: Snippet } = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section class="btop-box" style:--box-color="var(--{color})">
|
<section class="btop-box" class:tall style:--box-color="var(--{color})">
|
||||||
<span class="box-title"><sup>{number}</sup>{title}</span>
|
<span class="box-title"><sup>{number}</sup>{title}</span>
|
||||||
{@render children()}
|
{@render children()}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -52,9 +52,10 @@
|
|||||||
|
|
||||||
.graph {
|
.graph {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
gap: 1px;
|
gap: 1px;
|
||||||
height: 70px;
|
min-height: 70px;
|
||||||
margin-top: 0.4rem;
|
margin-top: 0.4rem;
|
||||||
border-bottom: 1px solid color-mix(in srgb, var(--foreground) 15%, transparent);
|
border-bottom: 1px solid color-mix(in srgb, var(--foreground) 15%, transparent);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import type { HobbyStats } from '#lib/btop/types';
|
||||||
|
import ListeningPanel from './hobbies/ListeningPanel.svelte';
|
||||||
|
import BooksPanel from './hobbies/BooksPanel.svelte';
|
||||||
|
import ScreenPanel from './hobbies/ScreenPanel.svelte';
|
||||||
|
import AniListPanel from './hobbies/AniListPanel.svelte';
|
||||||
|
|
||||||
|
let stats = $state<HobbyStats | null>(null);
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
fetch('/api/btop/hobbies')
|
||||||
|
.then((response) => (response.ok ? response.json() : null))
|
||||||
|
.then((result) => (stats = result))
|
||||||
|
.catch(() => (stats = null));
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if stats}
|
||||||
|
<div class="hobby-panels">
|
||||||
|
{#if stats.listening}
|
||||||
|
<ListeningPanel stats={stats.listening} number={5} />
|
||||||
|
{/if}
|
||||||
|
{#if stats.books}
|
||||||
|
<BooksPanel stats={stats.books} number={6} />
|
||||||
|
{/if}
|
||||||
|
<ScreenPanel stats={stats.shows} title="tv" number={7} color="yellow" />
|
||||||
|
<ScreenPanel stats={stats.movies} title="movies" number={8} color="green" />
|
||||||
|
{#if stats.anime}
|
||||||
|
<AniListPanel stats={stats.anime} title="anime" number={9} color="magenta" />
|
||||||
|
{/if}
|
||||||
|
{#if stats.manga}
|
||||||
|
<AniListPanel stats={stats.manga} title="manga" number={10} color="blue" />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.hobby-panels {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(min(100%, max(240px, 25% - 0.75rem)), 1fr));
|
||||||
|
grid-auto-flow: dense;
|
||||||
|
gap: 1rem 0.75rem;
|
||||||
|
padding-top: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hobby-panels > :global(.btop-box.tall) {
|
||||||
|
grid-row: span 2;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
import MemoryPanel from './MemoryPanel.svelte';
|
import MemoryPanel from './MemoryPanel.svelte';
|
||||||
import ContributionPanel from './ContributionPanel.svelte';
|
import ContributionPanel from './ContributionPanel.svelte';
|
||||||
import ProcessPanel from './ProcessPanel.svelte';
|
import ProcessPanel from './ProcessPanel.svelte';
|
||||||
|
import HobbyPanels from './HobbyPanels.svelte';
|
||||||
|
|
||||||
let data = $state<BtopData | null>(null);
|
let data = $state<BtopData | null>(null);
|
||||||
let clock = $state(new Date());
|
let clock = $state(new Date());
|
||||||
@@ -35,6 +36,8 @@
|
|||||||
<div class="mem"><MemoryPanel {data} /></div>
|
<div class="mem"><MemoryPanel {data} /></div>
|
||||||
<div class="proc"><ProcessPanel {data} /></div>
|
<div class="proc"><ProcessPanel {data} /></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<HobbyPanels />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -45,6 +48,7 @@
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
padding: 0.5rem 0.75rem 0.75rem;
|
padding: 0.5rem 0.75rem 0.75rem;
|
||||||
|
overflow-y: auto;
|
||||||
container-type: inline-size;
|
container-type: inline-size;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,14 +66,28 @@
|
|||||||
|
|
||||||
.panels {
|
.panels {
|
||||||
display: grid;
|
display: grid;
|
||||||
flex: 1;
|
flex: none;
|
||||||
grid-template-columns: minmax(0, 3fr) minmax(0, 2fr);
|
grid-template-columns: minmax(0, 3fr) minmax(0, 2fr);
|
||||||
grid-template-rows: auto minmax(0, 1fr);
|
grid-template-rows: auto auto;
|
||||||
grid-template-areas:
|
grid-template-areas:
|
||||||
'cpu net'
|
'cpu net'
|
||||||
'proc mem';
|
'proc mem';
|
||||||
gap: 1rem 0.75rem;
|
gap: 1rem 0.75rem;
|
||||||
min-height: 0;
|
}
|
||||||
|
|
||||||
|
.cpu,
|
||||||
|
.net,
|
||||||
|
.mem,
|
||||||
|
.proc {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cpu > :global(.btop-box),
|
||||||
|
.net > :global(.btop-box),
|
||||||
|
.mem > :global(.btop-box),
|
||||||
|
.proc > :global(.btop-box) {
|
||||||
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cpu {
|
.cpu {
|
||||||
@@ -86,12 +104,8 @@
|
|||||||
|
|
||||||
.proc {
|
.proc {
|
||||||
grid-area: proc;
|
grid-area: proc;
|
||||||
display: flex;
|
min-height: 220px;
|
||||||
min-height: 0;
|
contain: size;
|
||||||
}
|
|
||||||
|
|
||||||
.proc > :global(.btop-box) {
|
|
||||||
flex: 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@container (max-width: 760px) {
|
@container (max-width: 760px) {
|
||||||
@@ -106,7 +120,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.proc {
|
.proc {
|
||||||
min-height: 220px;
|
min-height: 260px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
let {
|
||||||
|
values,
|
||||||
|
color,
|
||||||
|
height = 56,
|
||||||
|
labels = [],
|
||||||
|
grow = false
|
||||||
|
}: { values: number[]; color: string; height?: number; labels?: string[]; grow?: boolean } = $props();
|
||||||
|
|
||||||
|
const minimumScale = 0.02;
|
||||||
|
|
||||||
|
const highest = $derived(Math.max(1, ...(values ?? [])));
|
||||||
|
|
||||||
|
function barScale(value: number): number {
|
||||||
|
return Math.max(minimumScale, value / highest);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="bar-graph" class:grow style:min-height="{height}px" style:height={grow ? null : `${height}px`} aria-hidden="true">
|
||||||
|
{#each values ?? [] as value, index (index)}
|
||||||
|
<span
|
||||||
|
class="bar"
|
||||||
|
class:empty={value === 0}
|
||||||
|
style:transform="scaleY({barScale(value)})"
|
||||||
|
style:background="var(--{color})"
|
||||||
|
title={labels[index] ? `${labels[index]}: ${value}` : `${value}`}
|
||||||
|
></span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.bar-graph {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 1px;
|
||||||
|
border-bottom: 1px solid color-mix(in srgb, var(--foreground) 15%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar-graph.grow {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar {
|
||||||
|
flex: 1;
|
||||||
|
align-self: stretch;
|
||||||
|
opacity: 0.85;
|
||||||
|
transform-origin: bottom;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar.empty {
|
||||||
|
opacity: 0.25;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { CountStat } from '#lib/btop/types';
|
||||||
|
|
||||||
|
let { items }: { items: CountStat[] } = $props();
|
||||||
|
|
||||||
|
const highest = $derived(Math.max(1, ...items.map((item) => item.count)));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="count-bars">
|
||||||
|
{#each items as item (item.label)}
|
||||||
|
<span class="label" title={item.label}>{item.label}</span>
|
||||||
|
<span class="track"><span style:width="{(item.count / highest) * 100}%" style:background="var(--{item.color})"></span></span>
|
||||||
|
<span class="count" style:color="var(--{item.color})">{item.count}</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.count-bars {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 7.5rem) minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem 0.5rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track {
|
||||||
|
display: block;
|
||||||
|
height: 6px;
|
||||||
|
background: color-mix(in srgb, var(--foreground) 10%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.track span {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.count {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
let { text, color }: { text: string; color: string } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<span class="section-label" style:color="var(--{color})">{text}</span>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.section-label {
|
||||||
|
display: block;
|
||||||
|
margin: 0.6rem 0 0.3rem;
|
||||||
|
padding-top: 0.45rem;
|
||||||
|
border-top: 1px dashed color-mix(in srgb, var(--foreground) 20%, transparent);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { CountStat } from '#lib/btop/types';
|
||||||
|
|
||||||
|
let { items }: { items: CountStat[] } = $props();
|
||||||
|
|
||||||
|
const shown = $derived(items.filter((item) => item.count > 0));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="stacked">
|
||||||
|
<div class="bar" role="img" aria-label={shown.map((item) => `${item.label}: ${item.count}`).join(', ')}>
|
||||||
|
{#each shown as item (item.label)}
|
||||||
|
<span style:flex-grow={item.count} style:background="var(--{item.color})"></span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<div class="legend">
|
||||||
|
{#each shown as item (item.label)}
|
||||||
|
<span style:color="var(--{item.color})">{item.count} {item.label}</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.bar {
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar span {
|
||||||
|
flex-basis: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.2rem 0.75rem;
|
||||||
|
margin-top: 0.35rem;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { StatRow } from '#lib/btop/types';
|
||||||
|
|
||||||
|
let { rows }: { rows: StatRow[] } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="stat-rows">
|
||||||
|
{#each rows as row (row.label)}
|
||||||
|
<div class="row"><span class="muted">{row.label}</span><span>{row.value}</span></div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.stat-rows {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.15rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { AniListStats } from '#lib/btop/types';
|
||||||
|
import BtopBox from '../BtopBox.svelte';
|
||||||
|
import CountBars from '../charts/CountBars.svelte';
|
||||||
|
import StatRows from '../charts/StatRows.svelte';
|
||||||
|
import SectionLabel from '../charts/SectionLabel.svelte';
|
||||||
|
|
||||||
|
let { stats, title, number, color }: { stats: AniListStats; title: string; number: number; color: string } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<BtopBox {title} {number} {color}>
|
||||||
|
<StatRows rows={stats.rows} />
|
||||||
|
|
||||||
|
{#if stats.statuses.length}
|
||||||
|
<SectionLabel text="lists" {color} />
|
||||||
|
<CountBars items={stats.statuses} />
|
||||||
|
{/if}
|
||||||
|
</BtopBox>
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { BookStats } from '#lib/btop/types';
|
||||||
|
import BtopBox from '../BtopBox.svelte';
|
||||||
|
import BarGraph from '../charts/BarGraph.svelte';
|
||||||
|
import CountBars from '../charts/CountBars.svelte';
|
||||||
|
import StackedBar from '../charts/StackedBar.svelte';
|
||||||
|
import StatRows from '../charts/StatRows.svelte';
|
||||||
|
import SectionLabel from '../charts/SectionLabel.svelte';
|
||||||
|
|
||||||
|
let { stats, number }: { stats: BookStats; number: number } = $props();
|
||||||
|
|
||||||
|
const ratingLabels = Array.from({ length: 11 }, (_, rating) => `${rating}/10`);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<BtopBox title="books" {number} color="cyan" tall>
|
||||||
|
<StackedBar items={stats.shelves} />
|
||||||
|
|
||||||
|
<SectionLabel text="reading" color="cyan" />
|
||||||
|
<StatRows rows={stats.rows} />
|
||||||
|
|
||||||
|
{#if stats.topGenres.length}
|
||||||
|
<SectionLabel text="top genres" color="cyan" />
|
||||||
|
<CountBars items={stats.topGenres} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<SectionLabel text="ratings" color="cyan" />
|
||||||
|
<BarGraph values={stats.ratingCounts} labels={ratingLabels} color="cyan" height={44} grow />
|
||||||
|
<div class="axis muted"><span>0</span><span>5</span><span>10</span></div>
|
||||||
|
</BtopBox>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.axis {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { ListeningStats } from '#lib/btop/types';
|
||||||
|
import BtopBox from '../BtopBox.svelte';
|
||||||
|
import BarGraph from '../charts/BarGraph.svelte';
|
||||||
|
import CountBars from '../charts/CountBars.svelte';
|
||||||
|
import StatRows from '../charts/StatRows.svelte';
|
||||||
|
import SectionLabel from '../charts/SectionLabel.svelte';
|
||||||
|
|
||||||
|
let { stats, number }: { stats: ListeningStats; number: number } = $props();
|
||||||
|
|
||||||
|
const hourLabels = Array.from({ length: 24 }, (_, hour) => `${hour % 12 || 12}${hour < 12 ? 'am' : 'pm'}`);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<BtopBox title="music" {number} color="red" tall>
|
||||||
|
<StatRows rows={stats.rows} />
|
||||||
|
|
||||||
|
<SectionLabel text="scrobbles, last {stats.daily.length} days" color="red" />
|
||||||
|
<BarGraph values={stats.daily} color="red" grow />
|
||||||
|
|
||||||
|
<SectionLabel text="by hour of day" color="red" />
|
||||||
|
<BarGraph values={stats.hourly} labels={hourLabels} color="magenta" height={36} />
|
||||||
|
|
||||||
|
{#if stats.topArtists.length}
|
||||||
|
<SectionLabel text="top artists" color="red" />
|
||||||
|
<CountBars items={stats.topArtists} />
|
||||||
|
{/if}
|
||||||
|
</BtopBox>
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { ScreenStats } from '#lib/btop/types';
|
||||||
|
import BtopBox from '../BtopBox.svelte';
|
||||||
|
import BarGraph from '../charts/BarGraph.svelte';
|
||||||
|
import StatRows from '../charts/StatRows.svelte';
|
||||||
|
import SectionLabel from '../charts/SectionLabel.svelte';
|
||||||
|
|
||||||
|
let { stats, title, number, color }: { stats: ScreenStats; title: string; number: number; color: string } = $props();
|
||||||
|
|
||||||
|
const ratingLabels = Array.from({ length: 11 }, (_, rating) => `${rating}/10`);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<BtopBox {title} {number} {color}>
|
||||||
|
<StatRows rows={stats.rows} />
|
||||||
|
|
||||||
|
<SectionLabel text="ratings" {color} />
|
||||||
|
<BarGraph values={stats.ratingCounts} labels={ratingLabels} {color} height={44} grow />
|
||||||
|
<div class="axis muted"><span>0</span><span>5</span><span>10</span></div>
|
||||||
|
|
||||||
|
{#if stats.watching.length}
|
||||||
|
<SectionLabel text="watching now" {color} />
|
||||||
|
<ul class="watching">
|
||||||
|
{#each stats.watching as name (name)}
|
||||||
|
<li><span style:color="var(--blue)">></span> {name}</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</BtopBox>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.axis {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.watching {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.watching li {
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
<script lang="ts" module>
|
||||||
|
export interface StackCover {
|
||||||
|
id: string;
|
||||||
|
href: string;
|
||||||
|
title: string;
|
||||||
|
image: string | null;
|
||||||
|
tone: string;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import Icon from '@iconify/svelte';
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
|
let {
|
||||||
|
name,
|
||||||
|
href,
|
||||||
|
icon,
|
||||||
|
fallbackIcon,
|
||||||
|
covers,
|
||||||
|
summary,
|
||||||
|
details,
|
||||||
|
children
|
||||||
|
}: {
|
||||||
|
name: string;
|
||||||
|
href?: string;
|
||||||
|
icon: string;
|
||||||
|
fallbackIcon: string;
|
||||||
|
covers: StackCover[];
|
||||||
|
summary?: Snippet;
|
||||||
|
details?: Snippet;
|
||||||
|
children: Snippet;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let expanded = $state(false);
|
||||||
|
|
||||||
|
const total = $derived(covers.length);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section class="collection" class:expanded>
|
||||||
|
<header class="collection-header">
|
||||||
|
{#if href}
|
||||||
|
<a class="collection-name" {href} target="_blank" rel="noopener noreferrer">
|
||||||
|
<Icon {icon} width="16" />
|
||||||
|
{name}
|
||||||
|
</a>
|
||||||
|
{:else}
|
||||||
|
<span class="collection-name"><Icon {icon} width="16" />{name}</span>
|
||||||
|
{/if}
|
||||||
|
{#if summary}
|
||||||
|
<span class="collection-summary">{@render summary()}</span>
|
||||||
|
{/if}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{@render details?.()}
|
||||||
|
|
||||||
|
{#if expanded}
|
||||||
|
{@render children()}
|
||||||
|
{:else}
|
||||||
|
<div class="cover-stack">
|
||||||
|
{#each covers as cover, index (cover.id)}
|
||||||
|
<a
|
||||||
|
class="stacked-cover"
|
||||||
|
style:z-index={total - index}
|
||||||
|
style:border-bottom-color="var(--{cover.tone})"
|
||||||
|
href={cover.href}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
title={cover.title}
|
||||||
|
>
|
||||||
|
{#if cover.image}
|
||||||
|
<img src={cover.image} alt={cover.title} loading="lazy" />
|
||||||
|
{:else}
|
||||||
|
<span class="cover-fallback"><Icon icon={fallbackIcon} width="20" /></span>
|
||||||
|
{/if}
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<button class="toggle" onclick={() => (expanded = !expanded)}>
|
||||||
|
<Icon icon={expanded ? 'mdi:chevron-up' : 'mdi:chevron-down'} width="16" />
|
||||||
|
{expanded ? 'Collapse' : `Expand (${total})`}
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.collection {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: min(100%, 280px);
|
||||||
|
max-width: 100%;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--foreground) 15%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.collection.expanded {
|
||||||
|
flex-basis: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collection-header {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.4rem 1rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collection-name {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
a.collection-name:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collection-summary {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.75rem;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-stack {
|
||||||
|
display: flex;
|
||||||
|
padding: 0.5rem 0 0.25rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stacked-cover {
|
||||||
|
position: relative;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 84px;
|
||||||
|
height: 126px;
|
||||||
|
border-bottom: 3px solid var(--muted);
|
||||||
|
box-shadow: 4px 0 10px color-mix(in srgb, black 45%, transparent);
|
||||||
|
transition: transform 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stacked-cover + .stacked-cover {
|
||||||
|
margin-left: -52px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stacked-cover:hover {
|
||||||
|
z-index: 1000 !important;
|
||||||
|
transform: translateY(-8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stacked-cover img,
|
||||||
|
.cover-fallback {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-fallback {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
background: color-mix(in srgb, var(--foreground) 8%, var(--background));
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
padding: 0.3rem 0.7rem;
|
||||||
|
background: none;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--foreground) 30%, transparent);
|
||||||
|
color: var(--foreground);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.stacked-cover {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -33,7 +33,7 @@ export type * from './types';
|
|||||||
export const profile: Profile = profileData;
|
export const profile: Profile = profileData;
|
||||||
export const experience: Experience[] = experienceData;
|
export const experience: Experience[] = experienceData;
|
||||||
export const leadership: Leadership[] = leadershipData;
|
export const leadership: Leadership[] = leadershipData;
|
||||||
export const about: AboutContent = aboutData;
|
export const about: AboutContent = aboutData as AboutContent;
|
||||||
export const desktop: DesktopContent = desktopData;
|
export const desktop: DesktopContent = desktopData;
|
||||||
export const blogSettings: BlogSettings = blogData;
|
export const blogSettings: BlogSettings = blogData;
|
||||||
export const playlist: Playlist = musicData;
|
export const playlist: Playlist = musicData;
|
||||||
|
|||||||
@@ -133,10 +133,14 @@ export interface Game {
|
|||||||
url: string;
|
url: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type WatchStatus = 'watched' | 'watching';
|
||||||
|
|
||||||
export interface RatedTitle {
|
export interface RatedTitle {
|
||||||
name: string;
|
name: string;
|
||||||
rating: number;
|
rating?: number;
|
||||||
url: string;
|
url: string;
|
||||||
|
status?: WatchStatus;
|
||||||
|
collection?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type BookShelf = 'read' | 'reading' | 'want-to-read';
|
export type BookShelf = 'read' | 'reading' | 'want-to-read';
|
||||||
@@ -147,6 +151,7 @@ export interface Book {
|
|||||||
title: string;
|
title: string;
|
||||||
authors: string[];
|
authors: string[];
|
||||||
cover: string | null;
|
cover: string | null;
|
||||||
|
coverSource?: string | null;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
pages: number | null;
|
pages: number | null;
|
||||||
published: string | null;
|
published: string | null;
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import type { RatedTitle } from '#lib/content';
|
||||||
|
import type { MediaDetails } from './types';
|
||||||
|
|
||||||
|
export interface MediaCollection {
|
||||||
|
name: string;
|
||||||
|
items: RatedTitle[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GroupedMedia {
|
||||||
|
watching: RatedTitle[];
|
||||||
|
collections: MediaCollection[];
|
||||||
|
standalone: RatedTitle[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isWatching(item: RatedTitle): boolean {
|
||||||
|
return item.status === 'watching';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ratingOf(item: RatedTitle): number {
|
||||||
|
return item.rating ?? -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function releaseYear(item: RatedTitle, details: Record<string, MediaDetails>): number {
|
||||||
|
const year = Number.parseInt(details[item.url]?.year ?? '', 10);
|
||||||
|
return Number.isNaN(year) ? Number.MAX_SAFE_INTEGER : year;
|
||||||
|
}
|
||||||
|
|
||||||
|
function averageRating(items: RatedTitle[]): number {
|
||||||
|
const rated = items.filter((item) => item.rating !== undefined);
|
||||||
|
return rated.length ? rated.reduce((total, item) => total + ratingOf(item), 0) / rated.length : -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function groupMedia(items: RatedTitle[], details: Record<string, MediaDetails>): GroupedMedia {
|
||||||
|
const groups = new Map<string, RatedTitle[]>();
|
||||||
|
const standalone: RatedTitle[] = [];
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
if (item.collection) {
|
||||||
|
groups.set(item.collection, [...(groups.get(item.collection) ?? []), item]);
|
||||||
|
} else if (!isWatching(item)) {
|
||||||
|
standalone.push(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const collections = [...groups.entries()]
|
||||||
|
.map(([name, groupItems]) => ({
|
||||||
|
name,
|
||||||
|
items: [...groupItems].sort((first, second) => releaseYear(first, details) - releaseYear(second, details))
|
||||||
|
}))
|
||||||
|
.sort((first, second) => averageRating(second.items) - averageRating(first.items) || first.name.localeCompare(second.name));
|
||||||
|
|
||||||
|
return {
|
||||||
|
watching: items.filter(isWatching),
|
||||||
|
collections,
|
||||||
|
standalone: [...standalone].sort((first, second) => ratingOf(second) - ratingOf(first))
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { about, books } from '#lib/content';
|
||||||
|
import type { HobbyStats } from '#lib/btop/types';
|
||||||
|
import { aniListStats, bookStats, listeningStats, screenStats } from '#lib/btop/hobbies';
|
||||||
|
import { getAniListProfile } from './anilist';
|
||||||
|
import { getListeningProfile } from './lastfm/profile';
|
||||||
|
|
||||||
|
export async function getHobbyStats(): Promise<HobbyStats> {
|
||||||
|
const anilistUsername = about.anilistUsername;
|
||||||
|
const [listening, anime, manga] = await Promise.all([
|
||||||
|
about.lastfmUsername ? getListeningProfile(about.lastfmUsername).catch(() => null) : null,
|
||||||
|
anilistUsername ? getAniListProfile(anilistUsername, 'ANIME').catch(() => null) : null,
|
||||||
|
anilistUsername ? getAniListProfile(anilistUsername, 'MANGA').catch(() => null) : null
|
||||||
|
]);
|
||||||
|
const hasManga = !!manga?.statusCounts.some((status) => status.count > 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
listening: listening ? listeningStats(listening) : null,
|
||||||
|
books: bookStats(books),
|
||||||
|
shows: screenStats(about.shows),
|
||||||
|
movies: screenStats(about.movies),
|
||||||
|
anime: anime ? aniListStats(anime) : null,
|
||||||
|
manga: manga && hasManga ? aniListStats(manga) : null
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
import Section from '#lib/components/ui/Section.svelte';
|
import Section from '#lib/components/ui/Section.svelte';
|
||||||
import Panel from '#lib/components/ui/Panel.svelte';
|
import Panel from '#lib/components/ui/Panel.svelte';
|
||||||
import AniListSection from '#lib/components/about/AniListSection.svelte';
|
import AniListSection from '#lib/components/about/AniListSection.svelte';
|
||||||
import MediaGrid from '#lib/components/about/MediaGrid.svelte';
|
import MediaSection from '#lib/components/about/media/MediaSection.svelte';
|
||||||
import GameCard from '#lib/components/about/GameCard.svelte';
|
import GameCard from '#lib/components/about/GameCard.svelte';
|
||||||
import BooksSection from '#lib/components/about/books/BooksSection.svelte';
|
import BooksSection from '#lib/components/about/books/BooksSection.svelte';
|
||||||
import SetupSection from '#lib/components/about/setup/SetupSection.svelte';
|
import SetupSection from '#lib/components/about/setup/SetupSection.svelte';
|
||||||
@@ -15,12 +15,9 @@
|
|||||||
|
|
||||||
let { data }: PageProps = $props();
|
let { data }: PageProps = $props();
|
||||||
|
|
||||||
const byRating = (first: { rating: number }, second: { rating: number }) => second.rating - first.rating;
|
|
||||||
const shows = [...about.shows].sort(byRating);
|
|
||||||
const movies = [...about.movies].sort(byRating);
|
|
||||||
const mediaGroups = [
|
const mediaGroups = [
|
||||||
{ id: 'shows', title: 'TV Shows', items: shows, kind: 'show' as const },
|
{ id: 'shows', title: 'TV Shows', items: about.shows, kind: 'show' as const },
|
||||||
{ id: 'movies', title: 'Movies', items: movies, kind: 'movie' as const }
|
{ id: 'movies', title: 'Movies', items: about.movies, kind: 'movie' as const }
|
||||||
];
|
];
|
||||||
|
|
||||||
const sections = $derived([
|
const sections = $derived([
|
||||||
@@ -68,7 +65,7 @@
|
|||||||
|
|
||||||
{#each mediaGroups as group (group.id)}
|
{#each mediaGroups as group (group.id)}
|
||||||
<Section title={group.title} id={group.id}>
|
<Section title={group.title} id={group.id}>
|
||||||
<MediaGrid items={group.items} mediaDetails={data.mediaDetails} kind={group.kind} />
|
<MediaSection items={group.items} mediaDetails={data.mediaDetails} kind={group.kind} />
|
||||||
</Section>
|
</Section>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import { getHobbyStats } from '#lib/server/btopHobbies';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
|
||||||
|
export const GET: RequestHandler = async () => {
|
||||||
|
return json(await getHobbyStats(), { headers: { 'Cache-Control': 'no-cache' } });
|
||||||
|
};
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import json
|
||||||
|
import re
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
import goodreads
|
||||||
|
from jsonfile import PROJECT_ROOT
|
||||||
|
|
||||||
|
SCRAPE_CACHE_DIR = PROJECT_ROOT / "tools" / "content" / ".cache" / "goodreads"
|
||||||
|
COVERS_DIR = PROJECT_ROOT / "static" / "covers" / "books"
|
||||||
|
COVERS_PUBLIC_PATH = "/covers/books"
|
||||||
|
IMAGE_EXTENSION_PATTERN = re.compile(r"\.(jpe?g|png|webp|gif)(?:$|\?)", re.I)
|
||||||
|
|
||||||
|
|
||||||
|
def scrape_cache_path(identifier: str):
|
||||||
|
return SCRAPE_CACHE_DIR / f"{identifier}.json"
|
||||||
|
|
||||||
|
|
||||||
|
def cached_scrape(identifier: str) -> dict | None:
|
||||||
|
path = scrape_cache_path(identifier)
|
||||||
|
if not path.exists():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def store_scrape(scraped: dict) -> None:
|
||||||
|
SCRAPE_CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
scrape_cache_path(scraped["goodreadsId"]).write_text(json.dumps(scraped, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def scrape_book(url: str, fresh: bool = False) -> dict:
|
||||||
|
identifier = goodreads.book_id(url)
|
||||||
|
cached = None if fresh or not identifier else cached_scrape(identifier)
|
||||||
|
if cached:
|
||||||
|
return cached
|
||||||
|
scraped = goodreads.scrape_book(url)
|
||||||
|
if scraped.get("title"):
|
||||||
|
store_scrape(scraped)
|
||||||
|
return scraped
|
||||||
|
|
||||||
|
|
||||||
|
def is_local_cover(cover: str | None) -> bool:
|
||||||
|
return bool(cover) and cover.startswith(COVERS_PUBLIC_PATH)
|
||||||
|
|
||||||
|
|
||||||
|
def existing_cover(identifier: str):
|
||||||
|
return next(iter(sorted(COVERS_DIR.glob(f"{identifier}.*"))), None) if COVERS_DIR.exists() else None
|
||||||
|
|
||||||
|
|
||||||
|
def cache_cover(identifier: str, source_url: str | None, fresh: bool = False) -> str | None:
|
||||||
|
existing = existing_cover(identifier)
|
||||||
|
if existing and not fresh:
|
||||||
|
return f"{COVERS_PUBLIC_PATH}/{existing.name}"
|
||||||
|
if not source_url or is_local_cover(source_url):
|
||||||
|
return f"{COVERS_PUBLIC_PATH}/{existing.name}" if existing else None
|
||||||
|
|
||||||
|
match = IMAGE_EXTENSION_PATTERN.search(source_url)
|
||||||
|
extension = match.group(1).lower().replace("jpeg", "jpg") if match else "jpg"
|
||||||
|
request = urllib.request.Request(source_url, headers=goodreads.REQUEST_HEADERS)
|
||||||
|
with urllib.request.urlopen(request, timeout=15) as response:
|
||||||
|
image = response.read()
|
||||||
|
|
||||||
|
COVERS_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
if existing:
|
||||||
|
existing.unlink()
|
||||||
|
(COVERS_DIR / f"{identifier}.{extension}").write_bytes(image)
|
||||||
|
return f"{COVERS_PUBLIC_PATH}/{identifier}.{extension}"
|
||||||
|
|
||||||
|
|
||||||
|
def remove_cover(identifier: str) -> None:
|
||||||
|
existing = existing_cover(identifier)
|
||||||
|
if existing:
|
||||||
|
existing.unlink()
|
||||||
+26
-3
@@ -1,5 +1,6 @@
|
|||||||
import datetime
|
import datetime
|
||||||
|
|
||||||
|
import book_cache
|
||||||
import goodreads
|
import goodreads
|
||||||
import jsonfile
|
import jsonfile
|
||||||
|
|
||||||
@@ -47,13 +48,21 @@ def short_description(text: str | None) -> str | None:
|
|||||||
return text[:DESCRIPTION_MAX_LENGTH].rsplit(" ", 1)[0] + "..."
|
return text[:DESCRIPTION_MAX_LENGTH].rsplit(" ", 1)[0] + "..."
|
||||||
|
|
||||||
|
|
||||||
|
def local_cover(scraped: dict) -> str | None:
|
||||||
|
try:
|
||||||
|
return book_cache.cache_cover(scraped["goodreadsId"], scraped["cover"]) or scraped["cover"]
|
||||||
|
except OSError:
|
||||||
|
return scraped["cover"]
|
||||||
|
|
||||||
|
|
||||||
def stored_fields(scraped: dict) -> dict:
|
def stored_fields(scraped: dict) -> dict:
|
||||||
return {
|
return {
|
||||||
"goodreadsId": scraped["goodreadsId"],
|
"goodreadsId": scraped["goodreadsId"],
|
||||||
"url": scraped["url"],
|
"url": scraped["url"],
|
||||||
"title": scraped["title"],
|
"title": scraped["title"],
|
||||||
"authors": scraped["authors"],
|
"authors": scraped["authors"],
|
||||||
"cover": scraped["cover"],
|
"cover": local_cover(scraped),
|
||||||
|
"coverSource": scraped["cover"],
|
||||||
"description": short_description(scraped["description"]),
|
"description": short_description(scraped["description"]),
|
||||||
"pages": scraped["pages"],
|
"pages": scraped["pages"],
|
||||||
"published": scraped["published"],
|
"published": scraped["published"],
|
||||||
@@ -107,7 +116,7 @@ def add_book(url: str, shelf: str = "read", rating: int | None = None) -> dict:
|
|||||||
if find_index(load_books(), identifier) is not None:
|
if find_index(load_books(), identifier) is not None:
|
||||||
raise ValueError(f"{identifier} is already listed")
|
raise ValueError(f"{identifier} is already listed")
|
||||||
|
|
||||||
scraped = goodreads.scrape_book(url)
|
scraped = book_cache.scrape_book(url)
|
||||||
if not scraped["title"]:
|
if not scraped["title"]:
|
||||||
raise ValueError("Could not read the book from Goodreads")
|
raise ValueError("Could not read the book from Goodreads")
|
||||||
|
|
||||||
@@ -130,6 +139,7 @@ def remove_book(text: str) -> dict:
|
|||||||
raise ValueError(f"{text} is not listed")
|
raise ValueError(f"{text} is not listed")
|
||||||
removed = books.pop(index)
|
removed = books.pop(index)
|
||||||
save_books(books)
|
save_books(books)
|
||||||
|
book_cache.remove_cover(removed["goodreadsId"])
|
||||||
return removed
|
return removed
|
||||||
|
|
||||||
|
|
||||||
@@ -156,5 +166,18 @@ def refresh_book(text: str) -> dict:
|
|||||||
index = find_index(books, text)
|
index = find_index(books, text)
|
||||||
if index is None:
|
if index is None:
|
||||||
raise ValueError(f"{text} is not listed")
|
raise ValueError(f"{text} is not listed")
|
||||||
scraped = goodreads.scrape_book(books[index]["url"])
|
scraped = book_cache.scrape_book(books[index]["url"], fresh=True)
|
||||||
return update_book(text, lambda book: {**book, **stored_fields(scraped)})
|
return update_book(text, lambda book: {**book, **stored_fields(scraped)})
|
||||||
|
|
||||||
|
|
||||||
|
def books_missing_local_covers() -> list[dict]:
|
||||||
|
return [book for book in load_books() if not book_cache.is_local_cover(book.get("cover"))]
|
||||||
|
|
||||||
|
|
||||||
|
def cache_book_cover(identifier: str) -> dict:
|
||||||
|
def change(book: dict) -> dict:
|
||||||
|
source = book.get("coverSource") or book.get("cover")
|
||||||
|
cover = book_cache.cache_cover(identifier, source)
|
||||||
|
return {**book, "cover": cover or book.get("cover"), "coverSource": source}
|
||||||
|
|
||||||
|
return update_book(identifier, change)
|
||||||
@@ -22,6 +22,10 @@ REQUEST_HEADERS = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class BlockedError(ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
class PageResult:
|
class PageResult:
|
||||||
def __init__(self, status: int, final_url: str, text: str):
|
def __init__(self, status: int, final_url: str, text: str):
|
||||||
self.status = status
|
self.status = status
|
||||||
@@ -168,10 +172,14 @@ def scrape_book(url: str) -> dict:
|
|||||||
if not identifier:
|
if not identifier:
|
||||||
raise ValueError(f"not a Goodreads book url: {url}")
|
raise ValueError(f"not a Goodreads book url: {url}")
|
||||||
page = download_page(url)
|
page = download_page(url)
|
||||||
|
raise_if_blocked(page)
|
||||||
|
return parse_book(page.text, identifier)
|
||||||
|
|
||||||
|
|
||||||
|
def raise_if_blocked(page: PageResult):
|
||||||
reason = blocked_reason(page.text)
|
reason = blocked_reason(page.text)
|
||||||
if reason:
|
if reason:
|
||||||
raise ValueError(reason)
|
raise BlockedError(f"Goodreads is rate limiting requests ({reason}), wait a few minutes and try again")
|
||||||
return parse_book(page.text, identifier)
|
|
||||||
|
|
||||||
|
|
||||||
def print_diagnostics(url: str, save_path: Path | None):
|
def print_diagnostics(url: str, save_path: Path | None):
|
||||||
|
|||||||
@@ -143,9 +143,7 @@ def scrape_series(url: str) -> dict:
|
|||||||
if not identifier:
|
if not identifier:
|
||||||
raise ValueError(f"not a Goodreads series url: {url}")
|
raise ValueError(f"not a Goodreads series url: {url}")
|
||||||
page = goodreads.download_page(url)
|
page = goodreads.download_page(url)
|
||||||
reason = goodreads.blocked_reason(page.text)
|
goodreads.raise_if_blocked(page)
|
||||||
if reason:
|
|
||||||
raise ValueError(reason)
|
|
||||||
|
|
||||||
entries = entries_from_react_props(page.text) or entries_from_next_data(page.text) or entries_from_links(page.text)
|
entries = entries_from_react_props(page.text) or entries_from_next_data(page.text) or entries_from_links(page.text)
|
||||||
entries = unique_entries(entries)
|
entries = unique_entries(entries)
|
||||||
|
|||||||
+60
-14
@@ -6,22 +6,39 @@ MEDIA_KINDS = {
|
|||||||
"show": {"key": "shows", "label": "TV show", "omdb_type": "series"},
|
"show": {"key": "shows", "label": "TV show", "omdb_type": "series"},
|
||||||
"movie": {"key": "movies", "label": "movie", "omdb_type": "movie"},
|
"movie": {"key": "movies", "label": "movie", "omdb_type": "movie"},
|
||||||
}
|
}
|
||||||
|
STATUSES = ["watched", "watching"]
|
||||||
|
|
||||||
|
|
||||||
def check_rating(rating: int) -> int:
|
def check_rating(rating: int | None) -> int | None:
|
||||||
if not 0 <= rating <= 10:
|
if rating is not None and not 0 <= rating <= 10:
|
||||||
raise ValueError("Rating must be between 0 and 10")
|
raise ValueError("Rating must be between 0 and 10")
|
||||||
return rating
|
return rating
|
||||||
|
|
||||||
|
|
||||||
|
def check_status(status: str) -> str:
|
||||||
|
if status not in STATUSES:
|
||||||
|
raise ValueError(f"Status must be one of {', '.join(STATUSES)}")
|
||||||
|
return status
|
||||||
|
|
||||||
|
|
||||||
def load_items(kind: str) -> tuple[dict, list[dict]]:
|
def load_items(kind: str) -> tuple[dict, list[dict]]:
|
||||||
data = jsonfile.load(ABOUT_FILE)
|
data = jsonfile.load(ABOUT_FILE)
|
||||||
return data, data.setdefault(MEDIA_KINDS[kind]["key"], [])
|
return data, data.setdefault(MEDIA_KINDS[kind]["key"], [])
|
||||||
|
|
||||||
|
|
||||||
|
def status_of(item: dict) -> str:
|
||||||
|
return item.get("status", "watched")
|
||||||
|
|
||||||
|
|
||||||
def list_items(kind: str) -> list[dict]:
|
def list_items(kind: str) -> list[dict]:
|
||||||
_, items = load_items(kind)
|
_, items = load_items(kind)
|
||||||
return sorted(items, key=lambda item: -item.get("rating", 0))
|
rating = lambda item: item["rating"] if item.get("rating") is not None else -1
|
||||||
|
return sorted(items, key=lambda item: (status_of(item) != "watching", -rating(item)))
|
||||||
|
|
||||||
|
|
||||||
|
def collection_names(kind: str) -> list[str]:
|
||||||
|
_, items = load_items(kind)
|
||||||
|
return sorted({item["collection"] for item in items if item.get("collection")})
|
||||||
|
|
||||||
|
|
||||||
def find_index(items: list[dict], text: str) -> int | None:
|
def find_index(items: list[dict], text: str) -> int | None:
|
||||||
@@ -34,27 +51,53 @@ def find_index(items: list[dict], text: str) -> int | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def look_up_name(kind: str, identifier: str) -> str:
|
def look_up_name(identifier: str) -> str:
|
||||||
details = imdb.lookup(identifier)
|
details = imdb.lookup(identifier)
|
||||||
if not details:
|
if not details:
|
||||||
raise ValueError("Could not look up the title on OMDb. Enter a name, or set OMDB_API_KEY in .env")
|
raise ValueError("Could not look up the title on OMDb. Enter a name, or set OMDB_API_KEY in .env")
|
||||||
return details.get("Title") or identifier
|
return details.get("Title") or identifier
|
||||||
|
|
||||||
|
|
||||||
def add_item(kind: str, text: str, rating: int, name: str | None = None) -> dict:
|
def with_optional(item: dict, key: str, value) -> dict:
|
||||||
|
updated = {name: field for name, field in item.items() if name != key}
|
||||||
|
if value is not None and value != "":
|
||||||
|
updated[key] = value
|
||||||
|
return updated
|
||||||
|
|
||||||
|
|
||||||
|
def build_item(name: str, url: str, rating: int | None, status: str, collection: str | None) -> dict:
|
||||||
|
item = {"name": name, "url": url}
|
||||||
|
item = with_optional(item, "rating", rating)
|
||||||
|
if status != "watched":
|
||||||
|
item["status"] = status
|
||||||
|
return with_optional(item, "collection", collection)
|
||||||
|
|
||||||
|
|
||||||
|
def add_item(kind: str, text: str, rating: int | None, name: str | None = None, status: str = "watched", collection: str | None = None) -> dict:
|
||||||
identifier = imdb.imdb_id(text)
|
identifier = imdb.imdb_id(text)
|
||||||
if not identifier:
|
if not identifier:
|
||||||
raise ValueError(f"{text} is not an IMDb link or ID")
|
raise ValueError(f"{text} is not an IMDb link or ID")
|
||||||
check_rating(rating)
|
check_rating(rating)
|
||||||
|
check_status(status)
|
||||||
data, items = load_items(kind)
|
data, items = load_items(kind)
|
||||||
if find_index(items, identifier) is not None:
|
if find_index(items, identifier) is not None:
|
||||||
raise ValueError(f"{identifier} is already listed")
|
raise ValueError(f"{identifier} is already listed")
|
||||||
item = {"name": name or look_up_name(kind, identifier), "rating": rating, "url": imdb.title_url(identifier)}
|
item = build_item(name or look_up_name(identifier), imdb.title_url(identifier), rating, status, collection)
|
||||||
items.append(item)
|
items.append(item)
|
||||||
jsonfile.save(ABOUT_FILE, data)
|
jsonfile.save(ABOUT_FILE, data)
|
||||||
return item
|
return item
|
||||||
|
|
||||||
|
|
||||||
|
def update_item(kind: str, text: str, change) -> dict:
|
||||||
|
data, items = load_items(kind)
|
||||||
|
index = find_index(items, text)
|
||||||
|
if index is None:
|
||||||
|
raise ValueError(f"{text} is not listed")
|
||||||
|
items[index] = change(items[index])
|
||||||
|
jsonfile.save(ABOUT_FILE, data)
|
||||||
|
return items[index]
|
||||||
|
|
||||||
|
|
||||||
def remove_item(kind: str, text: str) -> dict:
|
def remove_item(kind: str, text: str) -> dict:
|
||||||
data, items = load_items(kind)
|
data, items = load_items(kind)
|
||||||
index = find_index(items, text)
|
index = find_index(items, text)
|
||||||
@@ -65,12 +108,15 @@ def remove_item(kind: str, text: str) -> dict:
|
|||||||
return removed
|
return removed
|
||||||
|
|
||||||
|
|
||||||
def rate_item(kind: str, text: str, rating: int) -> dict:
|
def rate_item(kind: str, text: str, rating: int | None) -> dict:
|
||||||
check_rating(rating)
|
check_rating(rating)
|
||||||
data, items = load_items(kind)
|
return update_item(kind, text, lambda item: with_optional(item, "rating", rating))
|
||||||
index = find_index(items, text)
|
|
||||||
if index is None:
|
|
||||||
raise ValueError(f"{text} is not listed")
|
def set_status(kind: str, text: str, status: str) -> dict:
|
||||||
items[index]["rating"] = rating
|
check_status(status)
|
||||||
jsonfile.save(ABOUT_FILE, data)
|
return update_item(kind, text, lambda item: with_optional(item, "status", None if status == "watched" else status))
|
||||||
return items[index]
|
|
||||||
|
|
||||||
|
def set_collection(kind: str, text: str, collection: str | None) -> dict:
|
||||||
|
return update_item(kind, text, lambda item: with_optional(item, "collection", collection))
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import time
|
||||||
|
|
||||||
from rich.markup import escape
|
from rich.markup import escape
|
||||||
from textual import on, work
|
from textual import on, work
|
||||||
from textual.app import ComposeResult
|
from textual.app import ComposeResult
|
||||||
@@ -6,11 +8,14 @@ from textual.containers import Horizontal, VerticalScroll
|
|||||||
from textual.widgets import DataTable, Static
|
from textual.widgets import DataTable, Static
|
||||||
|
|
||||||
import books
|
import books
|
||||||
|
import goodreads
|
||||||
import goodreads_series
|
import goodreads_series
|
||||||
from tui.content_pane import ContentPane
|
from tui.content_pane import ContentPane
|
||||||
from tui.dialogs import ConfirmDialog, FormDialog, FormField
|
from tui.dialogs import ConfirmDialog, FormDialog, FormField
|
||||||
from tui.series_picker import SeriesPicker
|
from tui.series_picker import SeriesPicker
|
||||||
|
|
||||||
|
REQUEST_DELAY_SECONDS = 3
|
||||||
|
|
||||||
|
|
||||||
def book_details(book: dict) -> str:
|
def book_details(book: dict) -> str:
|
||||||
rows = [
|
rows = [
|
||||||
@@ -40,6 +45,7 @@ class BooksPane(ContentPane):
|
|||||||
Binding("d", "delete", "Delete"),
|
Binding("d", "delete", "Delete"),
|
||||||
Binding("u", "refresh", "Refresh"),
|
Binding("u", "refresh", "Refresh"),
|
||||||
Binding("U", "refresh_all", "Refresh all"),
|
Binding("U", "refresh_all", "Refresh all"),
|
||||||
|
Binding("C", "cache_covers", "Cache covers"),
|
||||||
Binding("r", "reload", "Reload"),
|
Binding("r", "reload", "Reload"),
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -151,19 +157,48 @@ class BooksPane(ContentPane):
|
|||||||
self.notify("Refreshing every book from Goodreads...")
|
self.notify("Refreshing every book from Goodreads...")
|
||||||
self.refresh_every_book()
|
self.refresh_every_book()
|
||||||
|
|
||||||
|
def report_blocked(self, error: Exception):
|
||||||
|
self.app.call_from_thread(self.notify, str(error), title="Stopped", severity="error", timeout=15)
|
||||||
|
|
||||||
@work(thread=True, exclusive=True, group="refresh")
|
@work(thread=True, exclusive=True, group="refresh")
|
||||||
def refresh_every_book(self):
|
def refresh_every_book(self):
|
||||||
failures = 0
|
refreshed = 0
|
||||||
all_books = books.load_books()
|
all_books = books.load_books()
|
||||||
for book in all_books:
|
for index, book in enumerate(all_books):
|
||||||
|
if index:
|
||||||
|
time.sleep(REQUEST_DELAY_SECONDS)
|
||||||
try:
|
try:
|
||||||
books.refresh_book(book["goodreadsId"])
|
books.refresh_book(book["goodreadsId"])
|
||||||
|
except goodreads.BlockedError as error:
|
||||||
|
self.report_blocked(error)
|
||||||
|
break
|
||||||
except (OSError, ValueError) as error:
|
except (OSError, ValueError) as error:
|
||||||
failures += 1
|
|
||||||
self.app.call_from_thread(self.notify, f"{book['title']}: {error}", title="Skipped", severity="warning")
|
self.app.call_from_thread(self.notify, f"{book['title']}: {error}", title="Skipped", severity="warning")
|
||||||
|
continue
|
||||||
|
refreshed += 1
|
||||||
self.app.call_from_thread(lambda: self.reload(self.selected_key()))
|
self.app.call_from_thread(lambda: self.reload(self.selected_key()))
|
||||||
summary = f"Refreshed {len(all_books) - failures} of {len(all_books)} books"
|
self.app.call_from_thread(self.notify, f"Refreshed {refreshed} of {len(all_books)} books")
|
||||||
self.app.call_from_thread(self.notify, summary)
|
|
||||||
|
def action_cache_covers(self):
|
||||||
|
missing = books.books_missing_local_covers()
|
||||||
|
if not missing:
|
||||||
|
self.notify("Every cover is already saved in static/covers/books")
|
||||||
|
return
|
||||||
|
self.notify(f"Saving {len(missing)} covers...")
|
||||||
|
self.cache_missing_covers(missing)
|
||||||
|
|
||||||
|
@work(thread=True, exclusive=True, group="covers")
|
||||||
|
def cache_missing_covers(self, missing: list[dict]):
|
||||||
|
saved = 0
|
||||||
|
for book in missing:
|
||||||
|
try:
|
||||||
|
books.cache_book_cover(book["goodreadsId"])
|
||||||
|
except (OSError, ValueError) as error:
|
||||||
|
self.app.call_from_thread(self.notify, f"{book['title']}: {error}", title="Skipped", severity="warning")
|
||||||
|
continue
|
||||||
|
saved += 1
|
||||||
|
self.app.call_from_thread(lambda: self.reload(self.selected_key()))
|
||||||
|
self.app.call_from_thread(self.notify, f"Saved {saved} of {len(missing)} covers")
|
||||||
|
|
||||||
def action_add_series(self):
|
def action_add_series(self):
|
||||||
fields = [FormField("link", "Goodreads series link", placeholder="https://www.goodreads.com/series/92037-wings-of-fire", required=True)]
|
fields = [FormField("link", "Goodreads series link", placeholder="https://www.goodreads.com/series/92037-wings-of-fire", required=True)]
|
||||||
@@ -185,12 +220,21 @@ class BooksPane(ContentPane):
|
|||||||
@work(thread=True, exclusive=True, group="series")
|
@work(thread=True, exclusive=True, group="series")
|
||||||
def add_series_books(self, series: dict, choices: dict[str, str]):
|
def add_series_books(self, series: dict, choices: dict[str, str]):
|
||||||
added = 0
|
added = 0
|
||||||
|
already_listed = set(books.library_shelves())
|
||||||
|
fetched_any = False
|
||||||
for entry in series["books"]:
|
for entry in series["books"]:
|
||||||
shelf = choices.get(entry["goodreadsId"])
|
shelf = choices.get(entry["goodreadsId"])
|
||||||
if not shelf:
|
if not shelf:
|
||||||
continue
|
continue
|
||||||
|
needs_fetch = entry["goodreadsId"] not in already_listed
|
||||||
|
if needs_fetch and fetched_any:
|
||||||
|
time.sleep(REQUEST_DELAY_SECONDS)
|
||||||
|
fetched_any = fetched_any or needs_fetch
|
||||||
try:
|
try:
|
||||||
book = books.add_series_book(series, entry, shelf)
|
book = books.add_series_book(series, entry, shelf)
|
||||||
|
except goodreads.BlockedError as error:
|
||||||
|
self.report_blocked(error)
|
||||||
|
break
|
||||||
except (OSError, ValueError) as error:
|
except (OSError, ValueError) as error:
|
||||||
self.app.call_from_thread(self.notify, f"{entry['title']}: {error}", title="Skipped", severity="warning")
|
self.app.call_from_thread(self.notify, f"{entry['title']}: {error}", title="Skipped", severity="warning")
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
from rich.text import Text
|
||||||
from textual.app import ComposeResult
|
from textual.app import ComposeResult
|
||||||
from textual.binding import Binding
|
from textual.binding import Binding
|
||||||
from textual.widgets import DataTable
|
from textual.widgets import DataTable
|
||||||
@@ -7,11 +8,19 @@ import media
|
|||||||
from tui.content_pane import ContentPane
|
from tui.content_pane import ContentPane
|
||||||
from tui.dialogs import ConfirmDialog, FormDialog, FormField
|
from tui.dialogs import ConfirmDialog, FormDialog, FormField
|
||||||
|
|
||||||
|
STATUS_STYLES = {"watching": "blue", "watched": "green"}
|
||||||
|
|
||||||
|
|
||||||
|
def rating_text(item: dict) -> str:
|
||||||
|
return f"{item['rating']}/10" if item.get("rating") is not None else "-"
|
||||||
|
|
||||||
|
|
||||||
class MediaPane(ContentPane):
|
class MediaPane(ContentPane):
|
||||||
BINDINGS = [
|
BINDINGS = [
|
||||||
Binding("a", "add", "Add"),
|
Binding("a", "add", "Add"),
|
||||||
Binding("e", "rate", "Rate"),
|
Binding("e", "rate", "Rate"),
|
||||||
|
Binding("s", "status", "Watching"),
|
||||||
|
Binding("g", "collection", "Group"),
|
||||||
Binding("d", "delete", "Delete"),
|
Binding("d", "delete", "Delete"),
|
||||||
Binding("r", "reload", "Reload"),
|
Binding("r", "reload", "Reload"),
|
||||||
]
|
]
|
||||||
@@ -29,8 +38,10 @@ class MediaPane(ContentPane):
|
|||||||
|
|
||||||
def on_mount(self):
|
def on_mount(self):
|
||||||
table = self.table()
|
table = self.table()
|
||||||
|
table.add_column("Status", key="status")
|
||||||
table.add_column("Rating", key="rating")
|
table.add_column("Rating", key="rating")
|
||||||
table.add_column("Title", key="title")
|
table.add_column("Title", key="title")
|
||||||
|
table.add_column("Group", key="collection")
|
||||||
table.add_column("IMDb", key="imdb")
|
table.add_column("IMDb", key="imdb")
|
||||||
self.reload()
|
self.reload()
|
||||||
|
|
||||||
@@ -39,13 +50,31 @@ class MediaPane(ContentPane):
|
|||||||
table.clear()
|
table.clear()
|
||||||
for item in media.list_items(self.kind):
|
for item in media.list_items(self.kind):
|
||||||
identifier = imdb.imdb_id(item.get("url", "")) or item.get("name", "")
|
identifier = imdb.imdb_id(item.get("url", "")) or item.get("name", "")
|
||||||
table.add_row(f"{item.get('rating', '?')}/10", item.get("name", ""), identifier, key=identifier)
|
status = media.status_of(item)
|
||||||
|
table.add_row(
|
||||||
|
Text(status, style=STATUS_STYLES[status]),
|
||||||
|
rating_text(item),
|
||||||
|
item.get("name", ""),
|
||||||
|
item.get("collection", ""),
|
||||||
|
identifier,
|
||||||
|
key=identifier,
|
||||||
|
)
|
||||||
self.restore_cursor(focus_key)
|
self.restore_cursor(focus_key)
|
||||||
|
|
||||||
|
def selected_item(self) -> dict | None:
|
||||||
|
identifier = self.selected_key()
|
||||||
|
return next((item for item in media.list_items(self.kind) if imdb.imdb_id(item.get("url", "")) == identifier), None)
|
||||||
|
|
||||||
|
def collection_hint(self) -> str:
|
||||||
|
names = media.collection_names(self.kind)
|
||||||
|
return f"Existing: {', '.join(names)}" if names else "e.g. Star Wars"
|
||||||
|
|
||||||
def action_add(self):
|
def action_add(self):
|
||||||
fields = [
|
fields = [
|
||||||
FormField("link", "IMDb link or ID", placeholder="https://www.imdb.com/title/tt0816692/", required=True),
|
FormField("link", "IMDb link or ID", placeholder="https://www.imdb.com/title/tt0816692/", required=True),
|
||||||
FormField("rating", "Your rating (0 to 10)", kind="rating", required=True),
|
FormField("status", "Status", kind="select", options=media.STATUSES, value="watched"),
|
||||||
|
FormField("rating", "Your rating (0 to 10, optional)", kind="rating"),
|
||||||
|
FormField("collection", "Group (optional, same name groups titles together)", placeholder=self.collection_hint()),
|
||||||
FormField("name", "Name (looked up on OMDb when empty)"),
|
FormField("name", "Name (looked up on OMDb when empty)"),
|
||||||
]
|
]
|
||||||
self.app.push_screen(FormDialog(f"Add a {self.label}", fields, "Add"), self.add_item)
|
self.app.push_screen(FormDialog(f"Add a {self.label}", fields, "Add"), self.add_item)
|
||||||
@@ -57,31 +86,51 @@ class MediaPane(ContentPane):
|
|||||||
|
|
||||||
def added(item: dict):
|
def added(item: dict):
|
||||||
self.reload(identifier)
|
self.reload(identifier)
|
||||||
self.notify(f"Added {item['name']} ({item['rating']}/10)")
|
self.notify(f"Added {item['name']} ({media.status_of(item)})")
|
||||||
|
|
||||||
self.run_in_background(
|
self.run_in_background(
|
||||||
f"Adding {identifier or values['link']}...",
|
f"Adding {identifier or values['link']}...",
|
||||||
lambda: media.add_item(self.kind, values["link"], values["rating"], values["name"]),
|
lambda: media.add_item(self.kind, values["link"], values["rating"], values["name"], values["status"], values["collection"]),
|
||||||
added,
|
added,
|
||||||
)
|
)
|
||||||
|
|
||||||
def action_rate(self):
|
def action_rate(self):
|
||||||
identifier = self.selected_key()
|
item = self.selected_item()
|
||||||
if not identifier:
|
if not item:
|
||||||
return
|
return
|
||||||
current = next((item for item in media.list_items(self.kind) if imdb.imdb_id(item.get("url", "")) == identifier), {})
|
identifier = imdb.imdb_id(item["url"])
|
||||||
fields = [FormField("rating", f"Rating for {current.get('name', identifier)}", kind="rating", required=True, value=str(current.get("rating", "")))]
|
current = "" if item.get("rating") is None else str(item["rating"])
|
||||||
|
fields = [FormField("rating", f"Rating for {item['name']} (empty to clear)", kind="rating", value=current)]
|
||||||
|
|
||||||
def rated(values: dict | None):
|
def rated(values: dict | None):
|
||||||
if values:
|
if values is not None:
|
||||||
self.run_now(
|
self.run_now(lambda: media.rate_item(self.kind, identifier, values["rating"]), f"Updated {item['name']}", identifier)
|
||||||
lambda: media.rate_item(self.kind, identifier, values["rating"]),
|
|
||||||
lambda item: f"Rated {item['name']} {item['rating']}/10",
|
|
||||||
identifier,
|
|
||||||
)
|
|
||||||
|
|
||||||
self.app.push_screen(FormDialog("Change rating", fields), rated)
|
self.app.push_screen(FormDialog("Change rating", fields), rated)
|
||||||
|
|
||||||
|
def action_status(self):
|
||||||
|
item = self.selected_item()
|
||||||
|
if not item:
|
||||||
|
return
|
||||||
|
identifier = imdb.imdb_id(item["url"])
|
||||||
|
next_status = "watched" if media.status_of(item) == "watching" else "watching"
|
||||||
|
self.run_now(lambda: media.set_status(self.kind, identifier, next_status), f"{item['name']} is now {next_status}", identifier)
|
||||||
|
|
||||||
|
def action_collection(self):
|
||||||
|
item = self.selected_item()
|
||||||
|
if not item:
|
||||||
|
return
|
||||||
|
identifier = imdb.imdb_id(item["url"])
|
||||||
|
fields = [FormField("collection", f"Group for {item['name']} (empty to remove)", value=item.get("collection", ""), placeholder=self.collection_hint())]
|
||||||
|
|
||||||
|
def grouped(values: dict | None):
|
||||||
|
if values is not None:
|
||||||
|
collection = values["collection"]
|
||||||
|
message = f"Added {item['name']} to {collection}" if collection else f"Removed {item['name']} from its group"
|
||||||
|
self.run_now(lambda: media.set_collection(self.kind, identifier, collection), message, identifier)
|
||||||
|
|
||||||
|
self.app.push_screen(FormDialog("Group titles", fields), grouped)
|
||||||
|
|
||||||
def action_delete(self):
|
def action_delete(self):
|
||||||
identifier = self.selected_key()
|
identifier = self.selected_key()
|
||||||
if not identifier:
|
if not identifier:
|
||||||
|
|||||||
Reference in new issue
Block a user