diff --git a/Cargo.lock b/Cargo.lock index 1874d80..933c836 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -188,6 +188,7 @@ dependencies = [ "benshi-store", "benshi-sync", "humantime", + "jiff", "rusqlite", "serde", "serde_json", @@ -250,6 +251,12 @@ dependencies = [ "benshi-core", ] +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.13.2" @@ -373,6 +380,37 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" +[[package]] +name = "defmt" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4a0795ac740cb54d7b3775c9c17345f6aaecf884c01733b39d6017738e0e3e1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "840bb056bd99c495c9188198a258ab787c79f306d864db3e6e8451442d6d8366" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "defmt-parser" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71958f69fb2de856c5dd51ac9377e986ca1aa5919f931bf5395a807277f889a0" +dependencies = [ + "thiserror", +] + [[package]] name = "displaydoc" version = "0.2.7" @@ -849,6 +887,60 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jiff" +version = "0.2.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2b005715dcbeb0089a3c0dab99f2ff1cc3b2525323552703d648585d342a383" +dependencies = [ + "defmt", + "jiff-core", + "jiff-static", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "windows-link", +] + +[[package]] +name = "jiff-core" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e52fe76043ccecc9005d2305ebaadf7d7fc0cc89ca6baa10a94d6bc68c7128c" +dependencies = [ + "defmt", + "log", +] + +[[package]] +name = "jiff-static" +version = "0.2.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cc9817253cf7c7ee4684451bd327e88d6f3658014e54a29198625590650695c" +dependencies = [ + "jiff-core", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa8377070c6bae868759445e5a77f66d84f0b72f3a054bfb00e6d038b8282da7" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + [[package]] name = "jni" version = "0.22.4" @@ -1049,6 +1141,21 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" +dependencies = [ + "portable-atomic", +] + [[package]] name = "potential_utf" version = "0.1.6" @@ -1184,7 +1291,7 @@ version = "0.40.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23f2a97da3e3873c73cb2a2e71b35c40ff95e0b1eefa8d72d8499a6928c3b5b3" dependencies = [ - "bitflags", + "bitflags 2.13.2", "fallible-iterator", "fallible-streaming-iterator", "hashlink", @@ -1208,7 +1315,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags", + "bitflags 2.13.2", "errno", "libc", "linux-raw-sys", @@ -1324,7 +1431,7 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags", + "bitflags 2.13.2", "core-foundation", "core-foundation-sys", "libc", @@ -1695,7 +1802,7 @@ version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ - "bitflags", + "bitflags 2.13.2", "bytes", "futures-util", "http", diff --git a/crates/daemon/Cargo.toml b/crates/daemon/Cargo.toml index fe63ee4..26bad58 100644 --- a/crates/daemon/Cargo.toml +++ b/crates/daemon/Cargo.toml @@ -17,6 +17,9 @@ benshi-sinks = { path = "../sinks" } benshi-store = { path = "../store" } benshi-sync = { path = "../sync" } humantime = "2.4.0" +# The day of a viewing in a time zone, by the rules that zone had on that +# date. +jiff = "0.2.38" serde = { version = "1.0.229", features = ["derive"] } serde_json = "1.0.151" socket2 = "0.6.5" diff --git a/crates/daemon/src/day.rs b/crates/daemon/src/day.rs new file mode 100644 index 0000000..53b7fc9 --- /dev/null +++ b/crates/daemon/src/day.rs @@ -0,0 +1,122 @@ +//! The day a viewing was seen on, as a time zone names it. +//! +//! A list keeps the day an entry was begun and the day it was completed. The +//! store keeps the instant of a viewing in UTC, and the day is the one a zone +//! names for that instant. Read in the zone where it was seen, a viewing just +//! after midnight is on the day it was seen there. A past instant is read by +//! the rules of summer time the zone had then. + +use std::time::SystemTime; + +use benshi_sync::plan::Day; +use jiff::Timestamp; +use jiff::tz::TimeZone; + +/// The day `zone` names for an instant, and none where that day is in a year +/// below 0 or the instant is past [`Timestamp::MAX`]. +#[must_use] +pub fn day_in(at: SystemTime, zone: &TimeZone) -> Option { + let instant = Timestamp::try_from(at).ok()?; + let date = zone.to_datetime(instant).date(); + + Some(Day { + year: u16::try_from(date.year()).ok()?, + month: u8::try_from(date.month()).ok()?, + day: u8::try_from(date.day()).ok()?, + }) +} + +#[cfg(test)] +mod tests { + use std::time::{Duration, SystemTime, UNIX_EPOCH}; + + use benshi_sync::plan::Day; + use jiff::tz::{Offset, TimeZone}; + + use super::day_in; + + /// The instant this RFC 3339 text names. + fn at(text: &str) -> SystemTime { + SystemTime::from( + text.parse::() + .unwrap_or_else(|failed| panic!("{text}: {failed}")), + ) + } + + fn day(year: u16, month: u8, day: u8) -> Day { + Day { year, month, day } + } + + /// A zone this many hours east of UTC, with no summer time. + fn east(hours: i8) -> TimeZone { + TimeZone::fixed(Offset::constant(hours)) + } + + #[test] + fn an_evening_in_utc_is_the_next_day_three_hours_east() { + let late = at("2026-10-13T22:30:00Z"); + + assert_eq!(day_in(late, &TimeZone::UTC), Some(day(2026, 10, 13))); + assert_eq!(day_in(late, &east(3)), Some(day(2026, 10, 14))); + } + + #[test] + fn a_morning_in_utc_is_the_day_before_far_enough_west() { + assert_eq!( + day_in(at("2026-10-14T02:00:00Z"), &east(-5)), + Some(day(2026, 10, 13)) + ); + } + + #[test] + fn an_instant_is_read_by_the_summer_time_of_its_own_date() { + let central = + TimeZone::posix("CET-1CEST,M3.5.0,M10.5.0/3").expect("a zone with summer time"); + + // 22:30 in UTC is 00:30 the next day in summer, two hours east. + assert_eq!( + day_in(at("2026-07-01T22:30:00Z"), ¢ral), + Some(day(2026, 7, 2)) + ); + // The same hour in winter is 23:30 the same day, one hour east. + assert_eq!( + day_in(at("2026-01-01T22:30:00Z"), ¢ral), + Some(day(2026, 1, 1)) + ); + assert_eq!( + day_in(at("2026-01-01T23:30:00Z"), ¢ral), + Some(day(2026, 1, 2)) + ); + } + + #[test] + fn an_instant_before_the_first_year_has_no_day() { + let long_ago = UNIX_EPOCH - Duration::from_hours(2_100 * 365 * 24); + + assert_eq!(day_in(long_ago, &TimeZone::UTC), None); + } + + #[test] + fn the_first_hour_of_year_zero_is_a_day_and_the_hour_before_is_none() { + let first = at("0000-01-01T01:00:00Z"); + + assert_eq!(day_in(first, &TimeZone::UTC), Some(day(0, 1, 1))); + assert_eq!(day_in(first, &east(-3)), None); + } + + #[test] + fn the_last_instant_the_clock_reads_is_a_day_and_the_next_second_is_none() { + let last = at("9999-12-30T22:00:00Z"); + + assert_eq!(day_in(last, &TimeZone::UTC), Some(day(9999, 12, 30))); + assert_eq!(day_in(last + Duration::from_secs(1), &TimeZone::UTC), None); + } + + #[test] + fn an_instant_before_1970_is_read_as_any_other() { + assert_eq!( + day_in(at("1969-12-31T22:30:00Z"), &east(3)), + Some(day(1970, 1, 1)) + ); + } +} diff --git a/crates/daemon/src/lib.rs b/crates/daemon/src/lib.rs index 9b1472a..b28f2b3 100644 --- a/crates/daemon/src/lib.rs +++ b/crates/daemon/src/lib.rs @@ -40,6 +40,8 @@ pub mod bus; pub mod config; +pub mod day; + pub mod detection; #[cfg(unix)] diff --git a/crates/sync/data/anime-relations.LICENSE b/crates/sync/data/anime-relations.LICENSE new file mode 100644 index 0000000..670154e --- /dev/null +++ b/crates/sync/data/anime-relations.LICENSE @@ -0,0 +1,116 @@ +CC0 1.0 Universal + +Statement of Purpose + +The laws of most jurisdictions throughout the world automatically confer +exclusive Copyright and Related Rights (defined below) upon the creator and +subsequent owner(s) (each and all, an "owner") of an original work of +authorship and/or a database (each, a "Work"). + +Certain owners wish to permanently relinquish those rights to a Work for the +purpose of contributing to a commons of creative, cultural and scientific +works ("Commons") that the public can reliably and without fear of later +claims of infringement build upon, modify, incorporate in other works, reuse +and redistribute as freely as possible in any form whatsoever and for any +purposes, including without limitation commercial purposes. These owners may +contribute to the Commons to promote the ideal of a free culture and the +further production of creative, cultural and scientific works, or to gain +reputation or greater distribution for their Work in part through the use and +efforts of others. + +For these and/or other purposes and motivations, and without any expectation +of additional consideration or compensation, the person associating CC0 with a +Work (the "Affirmer"), to the extent that he or she is an owner of Copyright +and Related Rights in the Work, voluntarily elects to apply CC0 to the Work +and publicly distribute the Work under its terms, with knowledge of his or her +Copyright and Related Rights in the Work and the meaning and intended legal +effect of CC0 on those rights. + +1. Copyright and Related Rights. A Work made available under CC0 may be +protected by copyright and related or neighboring rights ("Copyright and +Related Rights"). Copyright and Related Rights include, but are not limited +to, the following: + + i. the right to reproduce, adapt, distribute, perform, display, communicate, + and translate a Work; + + ii. moral rights retained by the original author(s) and/or performer(s); + + iii. publicity and privacy rights pertaining to a person's image or likeness + depicted in a Work; + + iv. rights protecting against unfair competition in regards to a Work, + subject to the limitations in paragraph 4(a), below; + + v. rights protecting the extraction, dissemination, use and reuse of data in + a Work; + + vi. database rights (such as those arising under Directive 96/9/EC of the + European Parliament and of the Council of 11 March 1996 on the legal + protection of databases, and under any national implementation thereof, + including any amended or successor version of such directive); and + + vii. other similar, equivalent or corresponding rights throughout the world + based on applicable law or treaty, and any national implementations thereof. + +2. Waiver. To the greatest extent permitted by, but not in contravention of, +applicable law, Affirmer hereby overtly, fully, permanently, irrevocably and +unconditionally waives, abandons, and surrenders all of Affirmer's Copyright +and Related Rights and associated claims and causes of action, whether now +known or unknown (including existing as well as future claims and causes of +action), in the Work (i) in all territories worldwide, (ii) for the maximum +duration provided by applicable law or treaty (including future time +extensions), (iii) in any current or future medium and for any number of +copies, and (iv) for any purpose whatsoever, including without limitation +commercial, advertising or promotional purposes (the "Waiver"). Affirmer makes +the Waiver for the benefit of each member of the public at large and to the +detriment of Affirmer's heirs and successors, fully intending that such Waiver +shall not be subject to revocation, rescission, cancellation, termination, or +any other legal or equitable action to disrupt the quiet enjoyment of the Work +by the public as contemplated by Affirmer's express Statement of Purpose. + +3. Public License Fallback. Should any part of the Waiver for any reason be +judged legally invalid or ineffective under applicable law, then the Waiver +shall be preserved to the maximum extent permitted taking into account +Affirmer's express Statement of Purpose. In addition, to the extent the Waiver +is so judged Affirmer hereby grants to each affected person a royalty-free, +non transferable, non sublicensable, non exclusive, irrevocable and +unconditional license to exercise Affirmer's Copyright and Related Rights in +the Work (i) in all territories worldwide, (ii) for the maximum duration +provided by applicable law or treaty (including future time extensions), (iii) +in any current or future medium and for any number of copies, and (iv) for any +purpose whatsoever, including without limitation commercial, advertising or +promotional purposes (the "License"). The License shall be deemed effective as +of the date CC0 was applied by Affirmer to the Work. Should any part of the +License for any reason be judged legally invalid or ineffective under +applicable law, such partial invalidity or ineffectiveness shall not +invalidate the remainder of the License, and in such case Affirmer hereby +affirms that he or she will not (i) exercise any of his or her remaining +Copyright and Related Rights in the Work or (ii) assert any associated claims +and causes of action with respect to the Work, in either case contrary to +Affirmer's express Statement of Purpose. + +4. Limitations and Disclaimers. + + a. No trademark or patent rights held by Affirmer are waived, abandoned, + surrendered, licensed or otherwise affected by this document. + + b. Affirmer offers the Work as-is and makes no representations or warranties + of any kind concerning the Work, express, implied, statutory or otherwise, + including without limitation warranties of title, merchantability, fitness + for a particular purpose, non infringement, or the absence of latent or + other defects, accuracy, or the present or absence of errors, whether or not + discoverable, all to the greatest extent permissible under applicable law. + + c. Affirmer disclaims responsibility for clearing rights of other persons + that may apply to the Work or any use thereof, including without limitation + any person's Copyright and Related Rights in the Work. Further, Affirmer + disclaims responsibility for obtaining any necessary consents, permissions + or other rights required for any use of the Work. + + d. Affirmer understands and acknowledges that Creative Commons is not a + party to this document and has no duty or obligation with respect to this + CC0 or use of the Work. + +For more information, please see + diff --git a/crates/sync/data/anime-relations.txt b/crates/sync/data/anime-relations.txt new file mode 100644 index 0000000..c84c8e7 --- /dev/null +++ b/crates/sync/data/anime-relations.txt @@ -0,0 +1,1458 @@ +# This file includes anime relation data for Taiga. It is used to redirect an +# episode to another, which is required to handle special episodes and the case +# where fansub groups use continuous numbering scheme in their releases. +# +# Rules are sorted alphabetically by anime title. Rule syntax is: +# +# 10001|10002|10003:14-26 -> 20001|20002|20003:1-13! +# └─┬─┘ └─┬─┘ └─┬─┘ └─┬─┘ └─┬─┘ └─┬─┘ └─┬─┘ └─┬─┘ +# 1 2 3 4 1 2 3 4 +# +# (1) MyAnimeList ID +# +# (2) Kitsu ID +# +# (3) AniList ID +# +# (4) Episode number or range +# +# - "?" is used for unknown values. +# - "~" is used to repeat the source ID. +# - "!" suffix is shorthand for creating a new rule where destination ID is +# redirected to itself. +# +# If you are editing this file in the installation directory of Taiga, be aware +# that it may be overwritten the next time you update the application. +# +# The latest version of this file can be found at: +# +# +# This file is in the public domain. + +::meta + +# Do not change this line. +- version: 1.3.0 + +# Update this date when you add, remove or modify a rule. +- last_modified: 2026-10-11 + +::rules + +# 100-man no Inochi no Ue ni Ore wa Tatteiru -> ~ 2nd Season +- 41380|43367|116242:13-24 -> 44881|43883|127366:1-12! + +# 11eyes -> ~: Momoiro Genmutan +- 6682|4662|6682:13 -> 7739|5102|7739:1 + +# 3-gatsu no Lion -> ~ 2nd season +- 31646|11380|21366:23-44 -> 35180|13401|98478:1-22 + +# 3D Kanojo: Real Girl -> ~ 2nd Season +- 36793|14276|100526:13-24 -> 37956|41973|102882:1-12! + +# 86 -> ~ 2nd Season +- 41457|43066|116589:12-23 -> 48569|44398|131586:1-12! + +# 91 Days -> ~: Toki no Asase/Subete no Kinou/Ashita, Mata Ashita +- 32998|11957|21711:13 -> 34777|13598|98778:1 + +# Acchi Kocchi (TV) -> ~: Place=Princess +- 12291|6701|12291:13 -> 16273|7401|16273:1 + +# Aikatsu! -> ~ 2 +- ?|7205|15061:51-101 -> ?|7972|20181:1-51 +# Aikatsu! -> ~ 3 +- ?|7205|15061:102-152 -> ?|9096|20794:1-51 +# Aikatsu! -> ~ 4 +- ?|7205|15061:153-178 -> ?|11142|21307:1-26 +# Aikatsu Stars! -> ~ Hoshi no Tsubasa +- ?|?|21648:51-100 -> ?|?|98542:1-50 +# Aikatsu Friends! -> ~ Kagayaki no Jewel +- 37204|41015|101043:51-76 -> 39078|42169|107447:1-26 + +# Ajin -> ~ 2nd Season +- 31580|11368|21341:14-26 -> 33253|12115|21799:1-13! + +# Ajin OVA -> Ajin 2nd Season OVA +- 32015|11615|?:3 -> 36625|41634|?:1 + +# Akagami no Shirayuki-hime -> ~ 2nd Season +- 30123|10621|21058:13-24 -> 31173|11179|21258:1-12! + +# Akuma no Riddle -> ~: Shousha wa Dare? Nukiuchi Test +- 19429|7844|19429:13 -> 24751|8724|20926:1 + +# Aldnoah.Zero -> ~ 2nd Season +- 22729|8297|20632:13-24 -> 27655|9136|20853:1-12 + +# Amagami SS -> ~: Imouto +- 8676|5435|8676:26 -> 9925|5943|9925:1 + +# Amanchu! -> ~ Special +- 31771|11432|21406:13 -> 33818|12471|98536:1 + +# Ano Natsu de Matteru -> ~: Bokutachi wa Koukou Saigo no Natsu wo Sugoshinagara, Ano Natsu de Matteiru. +- 11433|6508|11433:13 -> 23447|8407|20659:1 + +# Ansatsu Kyoushitsu (TV) -> ~: Deai no Jikan +- 19759|7973|19759:0 -> 28405|10020|21303:1 + +# Another -> ~: The Other - Inga +- 11111|6462|11111:0 -> 11701|6569|11701:1 + +# Ao Haru Ride -> ~ OVA +- 21995|8246|20596:0 -> 24151|8488|20837:1 +- 21995|8246|20596:13 -> 24151|8488|20837:2 + +# Ao Oni The Animation (Movie) -> ~ +- 33820|12478|21898:0 -> ~|~|~:1 + +# Arknights: Reimei Zensou -> Arknights: Fuyukomori Kaerimichi +- 50205|45367|140660:9-16 -> 53881|46880|158895:1-8! + +# Arknights: Rise from Ember -> ~ +- 58749|48796|177175:17-26 -> ~|~|~:1-10 + +# Baccano! -> ~ Specials +- 2251|2039|2251:14-16 -> 3901|3334|3901:1-3 + +# Beastars 2nd Season -> ~ +- 40935|42904|114194:13-24 -> ~|~|~:1-12 + +# Beatless -> ~: Final Stage +- 36516|13939|?:21-24 -> 38020|41407|?:1-4! +- 36516|13939|?:25-28 -> 38020|41407|?:1-4! +- ?|?|100245:25-28 -> ?|?|100245:21-24 + +# Berserk (2016) -> Berserk (2017) +- 32379|11655|21560:13-24 -> 34055|12569|97643:1-12! + +# Bikini Warriors -> ~ Special +- 30782|10920|21192:13 -> 31283|11216|21387:1 +# Bikini Warriors -> ~ OVA +- 30782|10920|21192:14-15 -> 33712|12452|87480:1-2 + +# Birdie Wing: Golf Girls' Story -> ~ Season 2 +- 50248|45391|140830:14-25 -> 52229|46218|151606:1-12! + +# Bishoujo Senshi Sailor Moon -> ~ R +- 530|489|530:47-89 -> 740|664|740:1-43! +# Bishoujo Senshi Sailor Moon -> ~ S +- 530|489|530:90-127 -> 532|491|532:1-38! +# Bishoujo Senshi Sailor Moon -> ~ SuperS +- 530|489|530:128-166 -> 1239|1115|1239:1-39! +# Bishoujo Senshi Sailor Moon -> ~: Sailor Stars +- 530|489|530:167-200 -> 996|886|996:1-34! +# Bishoujo Senshi Sailor Moon Crystal -> ~ Season III +- 14751|7163|14751:27-39 -> 31733|11415|21462:1-13! + +# Black Clover -> ~ 2nd Season +- 34572|13209|97940:171-? -> 61967|50024|195604:1-?! + +# Black Lagoon -> ~: The Second Barrage +- 889|789|889:13-24 -> 1519|1363|1519:1-12! + +# Bleach: Sennen Kessen-hen -> ~ - Ketsubetsu-tan +- 41467|43078|116674:14-26 -> 53998|46903|159322:1-13! +# Bleach: Sennen Kessen-hen -> ~ - Soukoku-tan +- 41467|43078|116674:27-40 -> 56784|48015|169755:1-14! +# Bleach: Sennen Kessen-hen -> ~ - Kashin-tan +- 41467|43078|116674:41-50 -> 60636|49444|185874:1-10! + +# Blue Lock -> ~ vs. U-20 Japan +- 49596|44973|137822:25-38 -> 54865|47245|163146:1-14! + +# Boku no Hero Academia -> ~ 2nd Season +- 31964|11469|21459:14-38 -> 33486|12268|21856:1-25 +# Boku no Hero Academia 2nd Season -> ~: Hero Note +- 33486|12268|21856:0 -> 35262|13313|99308:1 +# Boku no Hero Academia -> ~ 3rd Season +- 31964|11469|21459:39-63 -> 36456|13881|100166:1-25! +# Boku no Hero Academia -> ~ 4th Season +- 31964|11469|21459:64-88 -> 38408|41971|104276:1-25! +# Boku no Hero Academia -> ~ 5th Season +- 31964|11469|21459:89-113 -> 41587|43108|117193:1-25! +# Boku no Hero Academia -> ~ 6th Season +- 31964|11469|21459:114-138 -> 49918|45240|139630:1-25! +# Boku no Hero Academia -> ~ 7th Season +- 31964|11469|21459:139-159 -> 54789|47232|163139:1-21! +# Boku no Hero Academia -> ~: Final Season +- 31964|11469|21459:160-170 -> 60098|49279|182896:1-11! +# Boku no Hero Academia -> ~: More +- 31964|11469|21459:171 -> 63130|50399|204356:1 + +# Boku no Kokoro no Yabai Yatsu -> ~ Season 2 +- 52578|46299|153152:13-25 -> 55690|47634|166216:1-13! + +# Boku wa Tomodachi ga Sukunai -> ~ Episode 0 +- 10719|6316|10719:0 -> 10897|6397|10897:1 + +# Break Blade 1 -> ~ 2,3,4,5,6 +- 6772|4708|6772:2 -> 8514|5366|8514:1 +- 6772|4708|6772:3 -> 9252|5645|9252:1 +- 6772|4708|6772:4 -> 9465|5743|9465:1 +- 6772|4708|6772:5 -> 9724|5837|9724:1 +- 6772|4708|6772:6 -> 10092|6031|10092:1 + +# Bubuki Buranki -> ~: Hoshi no Kyojin +- 32023|11486|21471:13-24 -> 33041|12004|21740:1-12 + +# Build Divide: Code Black -> ~: Code White +- 48776|44425|132525:13-24 -> 48777|44426|132526:1-12! + +# Bungou Stray Dogs -> ~ 2nd Season +- 31478|11339|21311:13-24 -> 32867|11882|21679:1-12! +# Bungou Stray Dogs -> ~: Hitori Ayumu +- 31478|11339|21311:25 -> 33071|12051|21791:1 +# Bungou Stray Dogs -> ~ 3rd Season +- 31478|11339|21311:26-37 -> 38003|41984|103223:1-12! +# Bungou Stray Dogs -> ~ 4th Season +- 31478|11339|21311:38-50 -> 50330|45460|141249:1-13! +# Bungou Stray Dogs -> ~ 5th Season +- 31478|11339|21311:51-61 -> 54898|47258|163263:1-11! + +# Busou Shoujo Machiavellianism -> ~: Doki! "Goken-darake" no Ian Ryokou +- 33475|12265|21851:13 -> 35314|14261|98856:1 + +# Bye Bye, Earth -> ~ 2nd Season +- 53626|46764|157371:11-20 -> 59819|49198|181078:1-10! + +# C³ -> ~ Special +- 10578|6249|10578:13 -> 12505|6751|12505:1 + +# Candy Boy -> Candy☆Boy +- 4535|3668|4535:0 -> 3456|3022|3456:1 + +# Cardfight!! Vanguard -> ~: Asia Circuit-hen +- 9539|5781|9539:66-104 -> 13145|6893|13145:1-39! +# Cardfight!! Vanguard -> ~: Link Joker-hen +- 9539|5781|9539:105-163 -> 15611|7283|15611:1-59! +# Cardfight!! Vanguard -> ~: Legion Mate-hen +- 9539|5781|9539:164-196 -> 21729|8264|20564:1-33! + +# ChäoS;Child -> ~ Episode 0 +- 30485|10786|?:0 -> 34753|13282|?:1 + +# Charlotte -> ~: Tsuyoi Monotachi +- 28999|10103|20997:14 -> 31553|11365|21339:1 + +# Chihayafuru 2 -> ~: Waga Mi Yo ni Furu Nagame Seshi Ma ni +- 14397|7123|14397:26 -> 18745|7814|18745:1 + +# Chobits -> ~ Recap +- 59|40|59:27 -> 1311|1176|1311:1 + +# Choboraunyopomi Gekijou Dai Ni Maku Ai Mai Mii: Mousou Catastrophe -> ~ Special +- 23551|8498|20689:13 -> 25439|8786|21125:1 + +# Choujigen Game Neptune: The Animation -> ~ OVA +- 16157|7379|16157:13 -> 20479|8071|20472:1 + +# Chuunibyou demo Koi ga Shitai! -> ~: Kirameki no... Slapstick Noel +- 14741|7160|14741:13 -> 16934|7556|16934:1 +# Chuunibyou demo Koi ga Shitai! Ren -> ~: The Rikka Wars +- 18671|7705|18671:13 -> 27601|9132|20889:1 + +# Cinderella Girls Gekijou -> ~ 2nd Season +- 34257|12671|97765:14-26 -> 35883|13895|99742:1-13 +# Cinderella Girls Gekijou -> ~ 3rd Season +- 34257|12671|97765:27-39 -> 37422|41377|101267:1-13 +# Cinderella Girls Gekijou -> ~: Climax Season +- 34257|12671|97765:40-52 -> 38767|42229|105962:1-13 +# Cinderella Girls Gekijou: Kayou Cinderella Theater -> ~ 2nd Season +- 35346|13327|98556:14-26 -> 36500|13896|100225:1-13 +# Cinderella Girls Gekijou: Kayou Cinderella Theater -> ~ 3rd Season +- 35346|13327|98556:27-33 -> 37424|41462|103311:1-7 +# Cinderella Girls Gekijou: Kayou Cinderella Theater -> ~ 4th Season +- 35346|13327|98556:34-40 -> 39654|42275|108810:1-7 + +# Clannad -> ~: Mou Hitotsu no Sekai, Tomoyo-hen +- 2167|1962|2167:24 -> 4059|3438|4059:1 +# Clannad: After Story -> ~ - Mou Hitotsu no Sekai, Kyou-hen +- 4181|3505|4181:25 -> 6351|4529|6351:1 + +# Code Geass: Boukoku no Akito 1 -> 2,3,4,5 +- 8888|5518|8888:2 -> 15197|7227|15197:1 +- 8888|5518|8888:3 -> 15199|7228|15199:1 +- 8888|5518|8888:4 -> 15201|7229|15201:1 +- 8888|5518|8888:5 -> 30711|10904|21178:1 + +# Concrete Revolutio: Choujin Gensou -> ~ - The Last Song +- 31147|11165|21249:14-24 -> 32313|11620|21540:1-11! + +# Dakara Boku wa, H ga Dekinai. -> ~ OVA +- 12549|7656|12549:13 -> 14893|7180|14893:1 + +# Dandadan -> ~ 2nd Season +- 57334|48269|171018:13-24 -> 60543|49425|185660:1-12! + +# Danna ga Nani wo Itteiru ka Wakaranai Ken -> ~ 2 Sure-me +- 26349|8891|20735:14-26 -> 29067|10148|21000:1-13! + +# Darker than Black: Kuro no Keiyakusha -> ~ Special +- 2025|1824|2025:26 -> 4182|3506|4182:1 + +# Date A Live -> ~: Date to Date +- 15583|7278|15583:13 -> 17641|7839|17641:1 +# Date A Live II -> Date A Live: Encore OVA +- 19163|7864|19163:11 -> 22961|8511|20767:1 + +# Dead Mount Death Play -> ~ Part 2 +- 53613|46760|157198:13-24 -> 54743|47195|162803:1-12! + +# Demi-chan wa Kataritai -> ~: Demi-chan no Natsuyasumi +- 33988|12529|97592:13 -> 35823|13591|99394:1 + +# Denpa Onna to Seishun Otoko -> ~ Special +- 9379|5710|9379:13 -> 10638|6276|10638:1 + +# Diamond no Ace OVA -> ~ Second Season OVA +- 25341|8792|20861:4-5 -> 34349|12725|98582:1-2 + +# Dies Irae -> ~: Reimei +- 32271|11212|?:0 -> 36524|13919|?:1 + +# Digimon Adventure tri. 1 -> ~ 2,3,4,5,6 +- 25687|8684|20802:2 -> 32108|11546|21500:1 +- 25687|8684|20802:3 -> 32551|11547|21596:1 +- 25687|8684|20802:4 -> 34299|11548|97734:1 +- 25687|8684|20802:5 -> 34962|11549|98402:1 +- 25687|8684|20802:6 -> 36466|11550|100181:1 + +# Dokkiri Doctor -> ~ Special +- 4554|3680|4554:27 -> 13781|7027|13781:1 + +# Douluo Dalu -> ~: Jingying Sai +- 37150|41004|101920:27-78 -> 37822|42037|103543:1-52 + +# Dr. Stone New World -> ~ Part 2 +- 48549|44289|131518:12-22 -> 55644|47246|162670:1-11! + +# Dr. Stone: Science Future -> ~ Part 2 +- 57592|48342|172019:13-24 -> 61322|49751|189117:1-12! +# Dr. Stone: Science Future -> ~ Part 3 +- 57592|48342|172019:25-37 -> 62568|50187|199221:1-13! + +# Dragon Ball Kai -> ~: Mirai ni Heiwa wo! Goku no Tamashii yo Eien ni +- 6033|4394|6033:98 -> 11359|6500|11359:1 +# Dragon Ball Kai -> ~ (2014) +- 6033|4394|6033:99-159 -> 22777|8351|20635:1-61! + +# Drifters -> ~ Specials +- 31339|11028|21123:13-14 -> 36480|41325|97988:1-2 + +# Dungeon ni Deai wo Motomeru no wa Machigatteiru Darou ka -> ~ OVA +- 28121|9962|20920:14 -> 32801|11851|21660:1 +# Dungeon ni Deai wo Motomeru no wa Machigatteiru Darou ka S4 -> ~ Part 2 +- 47164|44031|129196:12-22 -> 53111|46556|155211:1-11! + +# Durarara!! -> ~ Specials +- 6746|4696|6746:25 -> 8408|5324|8408:2 +# Durarara!!x2 Shou -> Durarara!!x2 Ten +- 23199|8314|20652:13-24 -> 27831|9726|20879:1-12! +# Durarara!!x2 Shou -> Durarara!!x2 Ketsu +- 23199|8314|20652:25-36 -> 27833|9725|20880:1-12! + +# Edens Zero -> ~ 2nd Season +- 42192|43521|119683:26-50 -> 50002|45847|144932:1-25! + +# Elf-san wa Yaserarenai. -> ~ Specials +- 57845|48423|173388:13-14 -> 59820|49034|179126:1-2! + +# Enen no Shouboutai: San no Shou -> ~ Part 2 +- 51818|46119|149118:13-25 -> 59229|49016|179062:1-13! + +# Fairy Gone -> ~ 2nd Season +- 39063|42130|107418:13-24 -> 39811|42358|109562:1-12! + +# Fairy Tail -> ~ (2014) +- 6702|4676|6702:176-277 -> 22043|8203|20626:1-102! +# Fairy Tail -> ~ (2018) +- 6702|4676|6702:278-328 -> 35972|13658|99749:1-51! + +# Fate/Extra: Last Encore -> ~ - Illustrias Tendousetsu +- 33047|12006|?:11-13 -> 37651|41396|?:1-3! + +# Fate/Grand Order: Zettai Majuu Sensen Babylonia -> ~ - Initium Iter +- 38084|42269|103275:0 -> 40206|42475|110851:1! + +# Fate/stay night: Unlimited Blade Works (TV) -> ~ Episode 0 +- 22297|7882|?:0 -> 27821|9718|?:1 +# Fate/stay night: Unlimited Blade Works (TV) -> ~ 2nd Season +- 22297|7882|19603:13-25 -> 28701|10052|20792:1-13! + +# Fate/Zero -> ~ 2nd Season +- 10087|6028|10087:14-25 -> 11741|7658|11741:1-12! + +# Futsuu no Joshikousei ga [Locodol] Yattemita. -> ~: Nagarekawa, Annai Shitemita. +- 22189|8267|20611:13 -> 25719|8659|20851:1 +# Futsuu no Joshikousei ga [Locodol] Yattemita. -> ~ OVA +- 22189|8267|20611:14-15 -> 30988|11077|21442:1-2 + +# Gakkou no Kaidan -> ~: Kubinashi Rider!! Shi no Noroi +- 1281|1156|1281:20 -> 30825|11004|102000:1 + +# Gakusen Toshi Asterisk -> ~ 2nd Season +- 30544|10857|21131:13-24 -> 31737|11421|21390:1-12! + +# Gantz -> ~ 2nd Stage +- 384|351|384:14-26 -> 395|362|395:1-13! + +# Garo: Honoo no Kokuin -> ~ - Home +- 23311|8409|20753:25 -> 32415|11837|102098:1 +# Garo: Guren no Tsuki -> ~ Special +- 28537|10045|21277:24 -> 33709|12631|102429:1 + +# Gatchaman Crowds Insight -> ~: Inbound +- 21039|8180|20487:0 -> 30925|11033|21252:1 + +# Gate: Jieitai Kanochi nite, Kaku Tatakaeri -> ~ 2nd Season +- 28907|10085|20994:13-24 -> 31637|11376|21364:1-12! + +# Genjitsu Shugi Yuusha no Oukoku Saikenki -> ~ 2nd Season +- 41710|43613|117612:14-26 -> 49930|45252|139648:1-13! + +# Getsuyoubi no Tawawa -> ~ Specials +- 34213|12640|97716:13-14 -> 34481|12932|98060:1-2 + +# Ginga Eiyuu Densetsu: Die Neue These -> ~ - Seiran +- 31433|11331|?:13-16 -> 36369|13825|?:1-4! +- 31433|11331|?:17-20 -> 36370|42352|?:1-4! +- 31433|11331|?:21-24 -> 36371|42353|?:1-4! +- ?|?|20628:13-24 -> ?|?|100780:1-12! +# Ginga Eiyuu Densetsu: Die Neue These -> ~ - Gekitotsu +- 31433|11331|20628:25-36 -> 42886|44169|124032:1-12! +# Ginga Eiyuu Densetsu: Die Neue These -> ~ - Sakubou +- 31433|11331|20628:37-48 -> 51805|46114|148970:1-12! + +# Gintama -> ~' +- 918|818|918:202-252 -> 9969|5971|9969:1-51! +# Gintama -> ~': Enchousen +- 918|818|918:253-265 -> 15417|7253|15417:1-13! +# Gintama -> ~° +- 918|818|918:266-316 -> 28977|10083|20996:1-51! +# Gintama -> ~. +- 918|818|918:317-328 -> 34096|12553|97889:1-12! +# Gintama -> ~.: Porori-hen +- 918|818|918:329-341 -> 35843|13726|99714:1-13! +# Gintama -> ~.: Shirogane no Tamashii-hen +- 918|818|918:342-353 -> 36838|14095|100784:1-12! +# Gintama -> ~.: Shirogane no Tamashii-hen 2 +- 918|818|918:354-367 -> 37491|41079|101925:1-14! +# Gintama.: Shirogane no Tamashii-hen -> ~ 2 +- 36838|14095|100784:13-26 -> 37491|41079|101925:1-14 + +# Girls & Panzer: Saishuushou Part 1 -> ~ 2,3,4 +- 33970|12628|99938:2 -> 38081|42947|107208:1 +- 33970|12628|99938:3 -> 39166|44519|124115:1 +- 33970|12628|99938:4 -> 39167|44520|132420:1 + +# Glass no Hana to Kowasu Sekai -> ~ +- 25907|8971|21042:0 -> ~|~|~:1 + +# God Eater -> ~ Prologue +- 27631|9130|20849:0 -> 10470|6197|10470:1 + +# Golden Kamuy -> ~ 2 +- 36028|13689|99699:13-24 -> 37989|41442|102977:1-12! +# Golden Kamuy -> ~ 3 +- 36028|13689|99699:25-36 -> 40059|42435|110355:1-12! +# Golden Kamuy -> ~ 4 +- 36028|13689|99699:37-49 -> 50528|45556|142343:1-13! +# Golden Kamuy -> ~: Saishuushou +- 36028|13689|99699:50-62 -> 55772|47655|166521:1-13! + +# Grappler Baki (TV) -> ~: Saidai Tournament-hen +- 287|262|287:25-48 -> 551|508|551:1-24! + +# Guilty Crown -> ~: Lost Christmas +- 10793|6349|10793:0 -> 13411|6958|13411:1 + +# Gundam Build Divers Re:Rise -> ~ 2nd Season +- 40192|42585|110786:14-26 -> 40942|43144|114233:1-13! + +# Gurazeni -> ~ 2 +- 35183|13981|98479:13-24 -> 37990|41948|102974:1-12! + +# Hachigatsu no Cinderella Nine -> ~: Tsudzuku, Monogatari +- 38091|42144|104989:13 -> 49913|45268|139813:1 + +# Hagure Yuusha no Aesthetica: Hajirai Ippai -> Queen's Blade Rebellion vs. Hagure Yuusha no Aesthetica +- 15729|7298|15729:0 -> 14663|7152|14663:1 + +# Haikyuu!!: To the Top -> ~ 2nd Season +- 38883|42059|106625:14-25 -> 40776|42938|113538:1-12! + +# Haitai Nanafa -> ~ 2nd Season +- 15043|7201|15043:14-26 -> 17727|7721|17727:1-13! + +# Haiyoru! Nyaruani: Remember My Love(craft-sensei) -> ~ Special +- 9598|7808|9598:12 -> 10477|5803|10477:1 + +# Hajime no Ippo -> ~ Special +- 263|238|263:76 -> 12069|6660|12069:1 + +# Hakkenden: Touhou Hakken Ibun -> ~ 2nd Season +- 15613|7284|15613:14-26 -> 18055|7679|18055:1-13! + +# Hanyou no Yashahime: Sengoku Otogizoushi -> ~ - Ni no Shou +- 41911|43180|118399:25-48 -> 48505|44199|131264:1-24! + +# Hataraku Maou-sama!! -> ~ 2nd Season +- 48413|44113|130592:13-24 -> 53200|46550|155168:1-12! + +# Hataraku Saibou -> ~: Kaze Shoukougun +- 37141|14212|100977:14 -> 38733|42007|105662:1 + +# Hayate no Gotoku!! -> ~: Atsu ga Natsuize - Mizugihen! +- 4192|3514|4192:0 -> 5521|4133|5521:1 + +# Hibike! Euphonium -> ~: Kakedasu Monaka +- 27989|9980|20912:14 -> 31665|11391|21376:1 + +# High Card -> ~ Season 2 +- 49154|44706|135778:13-24 -> 54869|47250|163151:1-12! + +# High School DxD -> ~ OVA +- 11617|6550|11617:13-14 -> 12729|6801|12729:1-2 +# High School DxD New -> ~: Oppai, Tsutsumimasu! +- 15451|7258|15451:13 -> 30300|10744|21103:1 +# High School DxD BorN -> ~: Yomigaeranai Fushichou +- 24703|8514|20745:13 -> 32215|11598|21573:1 +# High School DxD Hero -> ~: Taiikukan-ura no Holy +- 34281|12677|?:0 -> 37719|41208|?:1 + +# Hikari no Ou -> ~ 2nd Season +- 43760|45818|125869:11-20 -> 54754|47194|162842:1-10! + +# Himesama "Goumon" no Jikan desu -> ~ 2nd Season +- 55774|47656|166522:13-24 -> 58524|48667|176370:1-12! + +# Hipira-kun -> ~ ONA +- 7723|5097|7723:11-12 -> 22983|8585|104388:1-2 +# Hipira-kun -> ~ Special +- 7723|5097|7723:13 -> 22985|9004|104435:1 + +# Hokuto no Ken -> ~ 2 +- 967|857|967:110-152 -> 1356|1210|1356:1-43! + +# Hoozuki no Reitetsu 2nd Season -> ~ Sono Ni +- 35075|13226|98438:14-26 -> 37029|14157|100852:1-13 + +# Honzuki no Gekokujou: Shisho ni Naru Tame ni wa Shudan wo Erandeiraremasen -> ~ 2nd Season +- 39468|42241|108268:15-26 -> 40815|42838|113693:1-12! +# Honzuki no Gekokujou: Shisho ni Naru Tame ni wa Shudan wo Erandeiraremasen -> ~ 3rd Season +- 39468|42241|108268:27-36 -> 42429|43607|121176:1-10! + +# Houkago Shounen Hanako-kun -> ~ Part 2 +- 56425|47887|159074:5-8 -> 57102|48212|170893:1-4 + +# Hourou Musuko -> ~ +- 8426|5331|8426:12 -> ~|~|~:11 + +# Hyakushou Kizoku -> ~ Specials +- 53426|46667|155971:13-14 -> 56743|48933|169511:1-2 +# Hyakushou Kizoku -> ~ 2nd Season +- 53426|46667|155971:15-26 -> 57562|48336|171689:1-12! +# Hyakushou Kizoku -> ~ 2nd Season Specials +- 53426|46667|155971:27-28 -> 60595|49440|185716:1-2 +# Hyakushou Kizoku -> ~ 3rd Season +- 53426|46667|155971:29-40 -> 60551|49439|185694:1-12! + +# IGPX: Immortal Grand Prix (2005) -> ~ 2nd Season +- 3270|2896|3270:14-26 -> 1410|1263|1410:1-13! + +# Inari, Konkon, Koi Iroha. -> ~: Inari, Konkon, Semishigure. +- 20457|7988|20488:11 -> 22057|8251|20598:1 + +# Initial D -> ~ Second Stage +- 185|162|185:27-39 -> 186|163|186:1-13! +# Initial D -> ~ Fourth Stage +- 185|162|185:40-63 -> 18|9|18:1-24! + +# Itsuka Tenma no Kuro Usagi -> ~ OVA +- 9750|5850|9750:13 -> 10934|6412|10934:1 + +# Ginga Kikoutai Majestic Prince -> ~: Mirai e no Tsubasa +- 15863|7325|15863:25 -> 33024|12002|21750:1 + +# Jashin-chan Dropkick -> ~ ONA +- 36906|41165|100714:12 -> 38383|41970|104278:1 + +# Jibaku Shounen Hanako-kun 2 -> ~ Part 2 +- 53924|46924|170892:13-24 -> 61339|49776|189326:1-12! + +# Jigoku Sensei Nube (2025) -> ~ Part 2 +- 59342|49070|179678:14-26 -> 61608|50433|191967:1-13! + +# Jigokuraku -> ~ 2nd Season +- 46569|44012|128893:14-25 -> 55825|47679|166613:1-12! + +# JoJo's Bizarre Adventure: Stardust Crusaders -> ~ 2nd Season +- 20899|8063|20474:25-48 -> 26055|8739|20799:1-24! + +# JoJo's Bizarre Adventure: Stone Ocean -> ~ Part 2 +- 48661|44294|131942:13-24 -> 51367|46010|146722:1-12! +- ?|?|131942:25-38 -> ?|?|146722:13-26! +# JoJo's Bizarre Adventure: Stone Ocean -> ~ Part 3 +- 48661|44294|?:25-38 -> 53273|46598|?:1-14! + +# Jormungand -> ~: Perfect Order +- 12413|6726|12413:13-24 -> 13331|6940|13331:1-12! + +# Jujutsu Kaisen -> ~ 2nd Season +- 40748|42765|113415:25-47 -> 51009|45857|145064:1-23! +# Jujutsu Kaisen -> ~: Shimetsu Kaiyuu - Zenpen +- 40748|42765|113415:48-59 -> 57658|48363|172463:1-12! + +# K-On! -> ~: Live House! +- 5680|4240|5680:14 -> 6862|4758|6862:1 +# K-On!! -> ~: Keikaku! +- 7791|5124|7791:27 -> 9734|5841|9734:1 + +# Kage no Jitsuryokusha ni Naritakute! -> ~ 2nd Season +- 48316|44107|130298:21-32 -> 54595|47099|161964:1-12! + +# Kaginado -> ~ Season 2 +- 48775|44420|132528:13-24 -> 50685|45699|143150:1-12! + +# Kaguya-sama wa Kokurasetai: First Kiss wa Owaranai -> ~ +- 52198|46206|?:1-4 -> ~|~|?:1 + +# Kaguya-sama wa Kokurasetai: Otona e no Kaidan -> ~ +- 61903|?|?:1-2 -> ~|?|?:1 + +# Kaijuu 8-gou -> ~ 2nd Season +- 52588|46300|153288:13-23 -> 59177|48994|178754:1-11! + +# Kaitou Joker -> ~ 2nd Season +- 24909|8664|20818:14-26 -> 28869|10070|21004:1-13! +# Kaitou Joker -> ~ 3rd Season +- 24909|8664|20818:27-39 -> 31670|11393|21427:1-13! +# Kaitou Joker -> ~ 4th Season +- 24909|8664|20818:40-52 -> 33490|12361|21859:1-13! + +# Kakumeiki Valvrave -> ~ 2nd Season +- 16668|7487|16668:13-24 -> 18295|7698|18295:1-12! + +# Kami no Tou: Ouji no Kikan -> Kami no Tou: Koubou-sen +- 52635|46312|?:14-26 -> 59989|49244|?:1-13! + +# Kamichu! -> ~ Specials +- 489|7763|489:13-16 -> 3449|450|3449:1-4 + +# Kamisama no Inai Nichiyoubi -> ~ Special +- 16009|7358|16009:13 -> 20423|7989|20423:1 + +# Kamonohashi Ron no Kindan Suiri -> ~ 2nd Season +- 53879|46876|158926:14-26 -> 57635|48399|172355:1-13! + +# Kämpfer -> ~ für die Liebe +- 6205|4474|6205:13-14 -> 10076|6023|10076:1-2! + +# Kannagi -> ~: Moshimo Kannagi ga Attara... +- 3958|3378|3958:14 -> 5978|4369|5978:1 + +# Kanojo ga Flag wo Oraretara -> ~ OVA +- 19685|8011|19685:14 -> 24451|8705|20731:1 + +# Kanojo mo Kanojo -> ~ Season 2 +- 43969|44165|126192:13-24 -> 53040|46486|154692:1-12! + +# Kanojo, Okarishimasu -> ~ 2nd Season +- 40839|43028|113813:13-24 -> 42963|43588|124410:1-12! +# Kanojo, Okarishimasu -> ~ 3rd Season +- 40839|43028|113813:25-36 -> 53050|46489|154745:1-12! +# Kanojo, Okarishimasu -> ~ 4th Season +- 40839|43028|113813:37-48 -> 59277|49033|179344:1-12! +# Kanojo, Okarishimasu -> ~ 5th Season +- 40839|43028|113813:49-60 -> 62485|50182|199029:1-12! + +# Kara no Kyoukai 1 -> ~ 2,3,4,5,6,7 +- 2593|2357|2593:2 -> 3782|3248|3782:1 +- 2593|2357|2593:3 -> 3783|3249|3783:1 +- 2593|2357|2593:4 -> 4280|3545|4280:1 +- 2593|2357|2593:5 -> 4282|3546|4282:1 +- 2593|2357|2593:6 -> 5204|3983|5204:1 +- 2593|2357|2593:7 -> 5205|3984|5205:1 + +# Kidou Senshi Gundam: Suisei no Majo -> ~ Season 2 +- 49828|45217|139274:13-24 -> 53199|46561|155158:1-12! + +# Kikansha no Mahou wa Tokubetsu desu -> ~ 2nd Season +- 54852|47240|163142:13-? -> 57612|48397|172192:1-?! + +# Kimi ni Todoke 2nd Season -> ~: Kataomoi +- 9656|5820|9656:0 -> 10152|6055|10152:1 + +# Kimi no Koto ga Daidaidaidaidaisuki na 100-nin no Kanojo -> ~ 2nd Season +- 54714|47179|162694:13-24 -> 57616|48348|172258:1-12! +# Kimi no Koto ga Daidaidaidaidaisuki na 100-nin no Kanojo -> ~ 3rd Season +- 54714|47179|162694:25-36 -> 62811|50272|200637:1-12! + +# Kindaichi Shounen no Jikenbo Returns -> ~ 2nd Season +- 22817|8310|20642:26-47 -> 31227|11211|21286:1-22! + +# Kino no Tabi: The Beautiful World -> ~ - Tou no Kuni +- 486|447|486:0 -> 3466|3031|3466:1 + +# Kiratto Pri☆chan -> ~ Season 2 +- 37178|41141|101097:52-102 -> 38804|42114|108625:1-51 +# Kiratto Pri☆chan -> ~ Season 3 +- 37178|41141|101097:103-153 -> 40880|43369|113990:1-51 + +# Kizumonogatari I: Tekketsu-hen -> Kizumonogatari II: Nekketsu-hen +- 9260|5649|9260:2 -> 31757|11406|21399:1 +# Kizumonogatari I: Tekketsu-hen -> Kizumonogatari III: Reiketsu-hen +- 9260|5649|9260:3 -> 31758|11407|21400:1 + +# Kizuna no Allele -> ~ Season 2 +- 51139|46732|156896:13-24 -> 55775|47672|166523:1-12! + +# Kobayashi-san Chi no Maid Dragon -> ~: Valentine, Soshite Onsen! - Amari Kitai Shinaide Kudasai +- 33206|12243|21776:14 -> 35363|13861|98580:1 + +# Kobayashi-san Chi no Maid Dragon S -> ~: Nippon no Omotenashi - Attend wa Dragon desu +- 39247|42149|107717:13 -> 49893|45014|136436:1 + +# Kokoro Connect -> ~ Michi Random +- 11887|6626|11887:14-17 -> 16001|7355|16001:1-4 + +# Kono Naka ni Hitori, Imouto ga Iru! -> ~: Ani, Imouto, Koibito +- 13367|6947|13367:13 -> 15609|7282|15609:1 + +# Kono Oto Tomare! -> ~ 2nd Season +- 38080|42101|103302:14-26 -> 38889|42306|108891:1-13! + +# Kono Subarashii Sekai ni Shukufuku wo! -> ~ OVA +- 30831|10941|21202:11 -> 32380|11752|21574:1 + +# Kore wa Zombie Desu ka? -> ~ OVA +- 8841|5507|8841:13 -> 10209|6079|10209:1 +# Kore wa Zombie Desu ka? of the Dead -> Kore wa Zombie Desu ka? OVA +- 10790|6347|10790:0 -> 10209|6079|10209:2 +# Kore wa Zombie Desu ka? of the Dead -> ~ OVA +- 10790|6347|10790:11 -> 15437|7256|15437:1 + +# Kuroko no Basket -> ~ 2nd Season +- 11771|6595|11771:26-50 -> 16894|7545|16894:1-25! +# Kuroko no Basket -> ~ 3rd Season +- 11771|6595|11771:51-75 -> 24415|8524|20725:1-25! + +# Kusuriya no Hitorigoto -> ~ 2nd Season +- 54492|47083|161645:25-48 -> 58514|48649|176301:1-24! +# Kusuriya no Hitorigoto -> ~ 3rd Season +- 54492|47083|161645:49-60 -> 61987|50021|195516:1-12! + +# Kyokou Suiri -> ~ 2nd Season +- 39017|42117|107201:13-24 -> 44204|43755|126529:1-12! + +# Kyoukai no Kanata -> ~: Shinonome +- 18153|7714|18153:0 -> 23385|8379|20779:1 + +# Kyoukai no Rinne (TV) -> ~ 2nd Season +- 28423|10018|20953:26-50 -> 31610|11366|21357:1-25! +# Kyoukai no Rinne (TV) -> ~ 3rd Season +- 28423|10018|20953:51-75 -> 34106|12561|97670:1-25! + +# Kyoukai Senki -> ~ Part 2 +- 48466|44197|131005:14-25 -> 50678|45703|143203:1-12! + +# Kyoukaisenjou no Horizon -> ~ II +- 10456|6187|10456:14-26 -> 12487|6746|12487:1-13! + +# Kyousou Giga -> ~ (TV) Recaps +- 10893|6396|10893:0 -> 20723|8050|20443:1 +# Kyousou Giga (TV) -> ~ Recaps +- 19703|7870|19703:0 -> 20723|8050|20443:1 + +# Log Horizon -> ~ 2 +- 17265|7622|17265:26-50 -> 23321|8313|20671:1-25! + +# Lord El-Melloi II-sei no Jikenbo: "Rail Zeppelin" Grace note -> ~ - Hakamori to Neko to Majutsu-shi +- 38959|42092|106918:0 -> 38936|42076|106862:1 + +# Love Hina -> ~: Motoko's Choice, Love or the Sword - Don't Cry +- 189|166|189:25 -> 963|853|963:1 + +# Love Live! The School Idol Movie -> ~ +- 24997|8531|20766:0 -> ~|~|~:1 + +# Lovedol: Lovely Idol -> ~ OVA +- 1561|1401|1561:13 -> 6227|4484|6227:1 + +# Lupin III (2015) -> ~ Specials +- 27947|9958|20906:25-26 -> 33074|12018|21822:1-2 + +# Magia Record 2nd Season -> Magia Record Final Season +- 41530|43090|117002:9-12 -> 49291|44813|136080:1-4! + +# Magic Knight Rayearth -> ~ II +- 435|399|435:21-49 -> 1563|1403|1563:1-29! + +# Mahou no Shimai LuluttoLilly -> ~ Part 2 +- 59176|49677|178749:13-? -> 63667|50996|209032:1-?! + +# Mahoutsukai no Yome Season 2 -> ~ Part 2 +- 52955|46436|154364:13-24 -> 55742|47649|166452:1-12! + +# Major S1 -> ~ S2,S3,S4,S5,S6 +- 627|582|627:27-52 -> 558|515|558:1-26! +- 627|582|627:53-78 -> 1842|1659|1842:1-26! +- 627|582|627:79-104 -> 3226|2869|3226:1-26! +- 627|582|627:105-129 -> 5028|3891|5028:1-25! +- 627|582|627:130-154 -> 7655|5070|7655:1-25! + +# Mangirl! -> ~: Asobu Henshuu Girl +- 14045|7071|14045:14 -> 17351|7886|17351:1 + +# Maou Gakuin no Futekigousha II -> ~ Part 2 +- 48417|44125|130588:13-24 -> 48418|45081|130590:1-12! + +# MapleStory -> ~ Special +- 2719|2480|?:26 -> 28237|9993|?:1 + +# Maria-sama ga Miteru -> ~ Haru,3rd,4th +- 158|7815|158:14-26 -> 444|7816|444:1-13 +- 158|7815|158:27-31 -> 1729|1550|1729:1-5 +- 158|7815|158:32-44 -> 3750|3228|3750:1-13 + +# Marie & Gali -> ~ Episode Zero +- 6130|4440|6130:0 -> 6355|4530|6355:1 + +# Mashle -> ~ 2nd Season +- 52211|46229|151801:13-24 -> 55813|47671|166610:1-12! + +# Megami no Café Terrace -> ~ 2nd Season +- 52973|46458|154412:13-24 -> 55749|47654|166477:1-12! + +# Megaton-kyuu Musashi -> ~ 2nd Season +- 33737|12509|87502:14-28 -> 50559|45580|142541:1-15! + +# MF Ghost -> ~ 2nd Season, 3rd Season +- 50695|45713|143327:13-24 -> 57559|48337|171642:1-12! +- 50695|45713|143327:25-37 -> 60602|49441|185753:1-13! + +# Mobile Suit Gundam: Iron-Blooded Orphans -> ~ 2nd Season +- 31251|11206|21268:26-50 -> 33051|12005|21749:1-25! + +# Mobile Suit Gundam Thunderbolt -> ~ 2nd Season +- 31973|11476|21458:5-8 -> 34391|12755|97853:1-4 + +# Mononogatari -> ~ 2nd Season +- 50384|45509|141785:13-24 -> 54883|47269|163205:1-12! + +# Monster Farm: Enbanseki no Himitsu -> Monster Farm: Legend e no Michi +- 1469|1315|1469:49-73 -> 13201|6909|13201:1-25 + +# Monster Strike 2 -> ~: Katsubou no Hate no Risoukyou +- 34670|13316|?:0 -> 35265|13343|?:1 + +# Morita-san wa Mukuchi. -> ~ 2 +- 10671|6285|10671:14-26 -> 11615|6549|11615:1-13 + +# Muhyo to Rouji no Mahouritsu Soudan Jimusho -> ~ 2nd Season +- 37487|41076|101340:13-24 -> 39948|43295|110028:1-12! + +# Musaigen no Phantom World -> ~ Special +- 31442|11333|21306:14 -> 33075|11686|21777:1 + +# Mushishi Zoku Shou -> ~ Special +- 21939|8204|20595:11-12 -> 24687|8519|20752:1 +# Mushishi Zoku Shou -> ~ 2nd Season +- 21939|8204|20595:13-22 -> 24701|8581|20751:1-10 +# Mushishi Zoku Shou -> ~: Suzu no Shizuku +- 21939|8204|20595:23-24 -> 28957|10084|21008:1 + +# Mushoku Tensei: Isekai Ittara Honki Dasu -> ~ Part 2 +- 39535|42323|108465:12-23 -> 45576|43907|127720:1-12! +# Mushoku Tensei: Isekai Ittara Honki Dasu -> ~ Part 2 - Eris no Goblin Toubatsu +- 39535|42323|108465:24 -> 50360|45492|141534:1! +# Mushoku Tensei II: Isekai Ittara Honki Dasu -> ~ Part 2 +- 51179|45950|146065:13-24 -> 55888|47694|166873:1-12! + +# Muv-Luv Alternative -> ~ 2nd Season +- 40608|43690|112716:13-24 -> 50638|45656|142985:1-12! + +# Nageki no Bourei wa Intai shitai -> ~ Part 2 +- 58172|48559|175019:14-24 -> 60619|49651|185801:1-11! + +# Nanbaka -> ~ (2017) +- 30016|10467|21051:14-25 -> 34414|12768|97875:1-12! + +# Nazo no Kanojo X -> ~: Nazo no Natsu Matsuri +- 12467|6740|12467:14 -> 13093|6882|13093:1 + +# Net-juu no Susume -> ~ Special +- 36038|13703|99726:11 -> 36043|14061|100684:1 + +# Nichijou -> ~: Nichijou no 0-wa +- 10165|6062|10165:0 -> 8857|5512|8857:1 + +# NieR Automata Ver1.1a -> ~ 2nd Season +- 51105|45926|145665:13-24 -> 56063|47784|167420:1-12! + +# No Guns Life -> ~ 2nd Season +- 39539|42197|108478:13-24 -> 40529|42880|112803:1-12! + +# Noblesse: Awakening -> ~ +- 32668|11806|21629:0 -> ~|~|~:1 + +# Nobunaga no Shinobi -> ~ Episode 0 +- 32829|11871|21666:0 -> 34283|12672|97969:1 +# Nobunaga no Shinobi -> ~: Ise Kanegasaki-hen +- 32829|11871|21666:27-52 -> 34978|13303|98420:1-26 +# Nobunaga no Shinobi -> ~: Anegawa Ishiyama-hen +- 32829|11871|21666:53-78 -> 37126|14207|100966:1-26 + +# Nodame Cantabile Finale -> ~ Special +- 5690|4248|5690:0 -> 8038|5177|8038:1 + +# Non Non Biyori -> ~: Okinawa e Ikukoto ni Natta +- 17549|7711|17549:13 -> 21667|8187|20497:1 +# Non Non Biyori Repeat -> ~ OVA +- 23623|8410|20694:13 -> 32547|11767|21585:1 + +# Odekake Kozame -> ~ 2nd Season +- 55463|47497|165426:61-132 -> 63125|50459|204269:1-72! + +# Ojisan to Marshmallow -> ~ Special +- 31848|11261|21239:13 -> 33201|12057|21774:1 + +# One Piece: Strong World -> ~ Episode 0 +- 4155|3492|4155:0 -> 8740|5469|8740:1 +# One Piece: Heart of Gold -> ~ +- 33338|12228|21831:0 -> ~|~|~:1 + +# One Room 2nd Season -> ~: Hanasaka Yui no Prologue +- 36431|13905|?:0 -> 38061|41452|?:1 + +# Onee-chan ga Kita -> ~ Special +- 20931|8083|20473:13 -> 23427|8343|20682:1 + +# Ooi! Tonbo -> ~ 2nd Season +- 55194|47427|164440:14-26 -> 59175|49013|178729:1-13! + +# Ookami to Koushinryou II -> ~: Ookami to Kohakuiro no Yuuutsu +- 5341|4063|5341:0 -> 6007|4382|6007:1 + +# Ore dake Level Up na Ken -> ~: Season 2 +- 52299|46231|151807:13-25 -> 58567|48671|176496:1-13! + +# Ore no Imouto ga Konnani Kawaii Wake ga Nai -> ~ Specials +- 8769|5487|8769:13-15 -> 10020|5998|10020:2-4 +# Ore no Imouto ga Konnani Kawaii Wake ga Nai. -> ~ Specials +- 13659|7006|13659:14-16 -> 18857|7822|18857:1-3 + +# Orient -> ~: Awajishima Gekitou-hen +- 45560|44205|128034:13-24 -> 51368|45993|146637:1-12! + +# Osake wa Fuufu ni Natte kara -> ~ Episode 14 +- 35484|13719|98657:14 -> 37826|41271|104530:1 + +# "Oshi no Ko" -> ~ 2nd Season +- 52034|46170|150672:12-24 -> 55791|47659|166531:1-13! + +# Owari no Seraph -> ~: Nagoya Kessen-hen +- 26243|8736|20829:13-24 -> 28927|10082|20993:1-12! + +# Papa no Iukoto wo Kikinasai! -> ~ Pokkapoka +- 11179|6469|11179:13 -> 12673|6787|12673:1 +# Papa no Iukoto wo Kikinasai! -> ~ OVA +- 11179|6469|11179:14-15 -> 17875|7685|17875:1-2 + +# Persona 4 the Animation -> ~: No One is Alone +- 10588|6255|10588:26 -> 13587|6997|13587:1 + +# Persona 5 the Animation: The Day Breakers -> ~ +- 31645|11602|21432:0 -> ~|~|~:1 + +# Phi Brain: Kami no Puzzle -> ~ - Orpheus Order-hen +- 9981|5978|9981:26-50 -> 12753|6803|12753:1-25! + +# Piano no Mori (TV) -> ~ 2nd Season +- 36652|13984|100401:13-24 -> 37975|41461|103049:1-12! + +# Pingu in the City -> ~ 2nd Season +- 36259|13783|99994:27-52 -> 38262|41981|99995:1-26! + +# Princess Principal: Crown Handler Movie 1 -> ~ 2 +- 37807|41254|101595:2 -> 41140|44680|129759:1 +# Princess Principal: Crown Handler Movie 1 -> ~ 3 +- 37807|41254|101595:3 -> 41141|44962|137612:1 + +# PriPara -> ~ 2nd Season +- ?|8555|20742:39-89 -> ?|10209|20985:1-51 +# PriPara -> ~ 3rd Season +- ?|8555|20742:90-140 -> ?|12683|21559:1-51 + +# Pokemon -> ~ Advanced Generation +- 527|486|527:277-468 -> 1564|1404|1564:1-192 + +# Puniru wa Kawaii Slime -> ~ 2nd Season +- 57517|49234|171400:13-24 -> 60603|49436|185755:1-12! + +# Re:Zero kara Hajimeru Break Time -> Re:Petit kara Hajimeru Isekai Seikatsu +- 33142|12047|21780:12-25 -> 33569|12313|21891:1-14 +# Re:Zero kara Hajimeru Break Time 2nd Season -> ~ Part 2 +- ?|?|120700:14-25 -> ?|?|128306:1-12! + +# Re:Zero kara Hajimeru Isekai Seikatsu -> ~ 2nd Season +- 31240|11209|21355:26-38 -> 39587|42198|108632:1-13! +# Re:Zero kara Hajimeru Isekai Seikatsu -> ~ 2nd Season Part 2 +- 31240|11209|21355:39-50 -> 42203|43247|119661:1-12! +# Re:Zero kara Hajimeru Isekai Seikatsu 2nd Season -> ~ Part 2 +- 39587|42198|108632:14-25 -> 42203|43247|119661:1-12! +# Re:Zero kara Hajimeru Isekai Seikatsu -> ~ 3rd Season +- 31240|11209|21355:51-66 -> 54857|47235|163134:1-16! +# Re:Zero kara Hajimeru Isekai Seikatsu -> ~ 4th Season +- 31240|11209|21355:67-85 -> 61316|49746|189046:1-19! + +# Recorder to Randoseru Do♪ -> Recorder to Randoseru Re♪ +- 11491|6519|11491:14-26 -> 13377|6951|13377:1-13! +# Recorder to Randoseru Mi☆ -> ~ Special +- 18099|7695|18099:13 -> 24935|8569|20828:1 + +# ReLIFE -> ~: Kanketsu-hen +- 30015|10464|21049:14-17 -> 35466|13502|98635:1-4 + +# Rewrite -> ~ 2nd Season +- 31716|11410|21382:14-24 -> 34126|12861|97665:1-11 + +# Rinne no Lagrange -> ~ Season 2 +- 11227|6477|11227:13-24 -> 12281|6699|12281:1-12 +# Rinne no Lagrange Specials -> Rinne no Lagrange Season 2 Specials +- 14583|7139|14583:7-12 -> 18639|7919|18639:1-6 + +# Rurouni Kenshin: Meiji Kenkaku Romantan -> ~ Special +- 45|27|45:95 -> 12067|6659|12067:1 + +# Rurouni Kenshin: Meiji Kenkaku Romantan (2023) -> ~ Kyoto Douran +- 50613|45616|142877:25-47 -> 57554|48393|171637:1-23! + +# Saenai Heroine no Sodatekata -> ~: Ai to Seishun no Service Kai +- 23277|8406|?:0 -> 29317|10225|?:1 +# Saenai Heroine no Sodatekata ♭ -> ~ Episode 0 +- 30727|10909|?:0 -> 35338|13322|?:1 + +# Saint Seiya: The Lost Canvas - Meiou Shinwa -> ~ 2 +- 6171|4458|6171:14-26 -> 9130|5612|9130:1-13 + +# Sakamoto Days -> ~ Part 2 +- 58939|48915|177709:12-22 -> 60285|49332|184237:1-11! + +# Sakamoto desu ga? -> ~ Special +- 32542|11751|21595:13 -> 33524|12278|21862:1 + +# Saki Achiga-hen: Episode of Side-A -> ~: Specials +- 10884|6392|10884:13-16 -> 15989|7353|15989:1-4 + +# Sankarea -> ~ OVA +- 11499|6521|11499:0 -> 13055|6873|13055:1 +# Sankarea -> ~: Wagahai mo... Zombie de Aru... +- 11499|6521|11499:13 -> 16694|7492|16694:1 +# Sankarea -> ~ OVA +- 11499|6521|11499:14 -> 13055|6873|13055:2 + +# Sasami: Mahou Shoujo Club -> ~ 2 +- 1881|7760|1881:14-26 -> 1882|1693|1882:1-13 + +# Satsuriku no Tenshi -> ~ ONA +- ?|13715|99629:13-16 -> ?|41963|104243:1-4 + +# Seihantai na Kimi to Boku -> ~ 2nd Season +- 60371|49372|184951:13-25 -> 63832|50634|210031:1-13! + +# Seikaisuru Kado -> ~ Episode 0 +- 32032|11490|?:0 -> 35369|13323|?:1 + +# Seitokai Yakuindomo -> ~ OVA +- 8675|5434|8675:14-21 -> 10119|6046|10119:1-8 +# Seitokai Yakuindomo * -> ~ OVA +- 20847|8061|20448:14-19 -> 26123|8708|20826:1-6 + +# Sekai Seifuku: Bouryaku no Zvezda -> ~ - Shin Zvezda Daisakusen +- 20973|8082|20496:13 -> 23459|8381|20695:1 + +# Sekaiichi Hatsukoi -> ~ 2 +- 9926|5944|9926:13-24 -> 11123|6464|11123:1-12! + +# Sekirei: Pure Engagement -> ~ Special +- 5277|4029|5277:0 -> 9355|5700|9355:1 + +# Sengoku Choujuu Giga: Kou -> Sengoku Choujuu Giga: Otsu +- 33803|12492|21895:14-26 -> 34738|13186|116053:1-13! + +# Sengoku Youko: Yonaoshi Kyoudai-hen -> Sengoku Youko: Senma Konton-hen +- 56242|47891|168194:14-35 -> 58488|48690|176660:1-22! + +# Senyuu. -> ~ 2 +- 15751|7303|15751:14-26 -> 18523|7719|18523:1-13! + +# Seto no Hanayome -> ~ OVA +- 2104|1903|2104:27-28 -> 4163|3498|4163:1-2 + +# Shangri-La Frontier -> ~ Season 2 +- 52347|46241|151970:26-50 -> 58572|48677|176508:1-25! + +# Shelter -> ~ +- 34240|12668|97731:0 -> ~|~|~:1 + +# Shibou Yuugi de Meshi wo Kuu. -> ~ 44: Cloudy Beach +- 59711|50337|180746:12 -> 63819|50646|209961:1 + +# Shigofumi -> ~: Sore kara +- 3420|2996|3420:13 -> 4715|3756|4715:1 + +# Shikabane Hime: Aka -> Shikabane Hime: Kuro +- 4581|7666|4581:14-25 -> 5034|7667|5034:1-12 +# Shikabane Hime: Aka -> Shikabane Hime: Kuro Special +- 4581|7666|4581:26 -> 6344|3695|6344:1 +# Shikabane Hime: Kuro -> ~ Special +- 5034|7667|5034:13 -> 6344|3695|6344:1 + +# Shingeki no Kyojin -> ~ Season 2 +- 16498|7442|16498:26-37 -> 25777|8671|20958:1-12! +# Shingeki no Kyojin -> ~ Season 3 +- 16498|7442|16498:38-49 -> 35760|13569|99147:1-12! +# Shingeki no Kyojin -> ~ Season 3 Part 2 +- 16498|7442|16498:50-59 -> 38524|41982|104578:1-10! +# Shingeki no Kyojin -> ~: The Final Season +- 16498|7442|16498:60-75 -> 40028|42422|110277:1-16! +# Shingeki no Kyojin -> ~: The Final Season Part 2 +- 16498|7442|16498:76-87 -> 48583|44240|131681:1-12! +# Shingeki no Kyojin Season 3 -> ~ Part 2 +- 35760|13569|99147:13-22 -> 38524|41982|104578:1-10 +- 35760|13569|99147:50-59 -> 38524|41982|104578:1-10 +# Shingeki no Kyojin: The Final Season -> ~ Part 2 +- 40028|42422|110277:17-75 -> 48583|44240|131681:1-59 +- 40028|42422|110277:76-87 -> 48583|44240|131681:1-12 + +# Shingeki no Kyojin OVA -> Shingeki no Kyojin: Kuinaki Sentaku +- 18397|7694|18397:4-5 -> 25781|8674|20811:1-2 +# Shingeki no Kyojin OVA -> Shingeki no Kyojin: Lost Girls +- 18397|7694|18397:6-8 -> 36106|13718|99634:1-3 + +# Shinigami Bocchan to Kuro Maid -> ~ 2nd Season +- 47257|44035|129277:13-24 -> 49858|45229|139435:1-12! +# Shinigami Bocchan to Kuro Maid -> ~ 3rd Season +- 47257|44035|129277:25-36 -> 56738|47984|169584:1-12! + +# Shokugeki no Souma: San no Sara -> ~ - Toutsuki Ressha-hen +- 35788|13581|99255:13-24 -> 36949|14092|100773:1-12! + +# Shoushimin Series -> ~ 2nd Season +- 57810|48415|173295:11-22 -> 59828|49201|181182:1-12! + +# Shugo Chara! -> ~! Doki +- 2923|2648|2923:52-102 -> 5262|4019|5262:1-51! + +# Shuumatsu no Valkyrie II -> ~ Part 2 +- 49618|44988|138056:11-15 -> 55454|47495|165356:1-5! + +# SHY -> ~ 2nd Season +- 53237|46589|155389:13-24 -> 57567|48328|171748:1-12! + +# Sono Bisque Doll wa Koi wo Suru -> ~ 2nd Season +- 48736|44382|132405:13-24 -> 53065|46492|154768:1-12! + +# So Ra No Wo To -> ~ Specials +- 6802|7668|6802:13 -> 8197|4724|8197:2 + +# Soukyuu no Fafner: Dead Aggressor - Exodus -> ~ 2nd Season +- 17080|7575|17080:14-26 -> 30549|10854|21287:1-13! + +# Soushin Shoujo Matoi -> ~ Special +- 33668|12322|21873:13 -> 34538|13004|98843:1 + +# Sousou no Frieren -> ~ 2nd Season +- 52991|46474|154587:29-38 -> 59978|49240|182255:1-10! + +# Sousou no Frieren: ●● no Mahou -> ~ Part 2 +- ?|?|170068:13-18 -> ?|?|189513:1-6! +# Sousou no Frieren: ●● no Mahou -> ~ Part 3 +- ?|?|170068:19-? -> ?|?|206425:1-?! + +# Souten no Ken -> ~ Specials +- 1541|1382|1541:23-26 -> 3864|3308|3864:1-4 +# Souten no Ken: REGENESIS -> ~ 2 +- 36655|13983|100408:13-24 -> 37388|41953|102860:1-12! + +# Space☆Dandy -> ~ 2nd Season +- 20057|7910|20057:14-26 -> 23327|8312|20666:1-13 +# Space☆Dandy Picture Drama -> Space☆Dandy 2nd Season Picture Drama +- 25635|8741|20893:3-5 -> 30246|10787|108972:1-3 + +# Spider Riders: Oracle no Yuusha-tachi -> Spider Riders: Yomigaeru Taiyou +- 2619|2383|2619:27-52 -> 2620|2384|2620:1-26 + +# Spy Kyoushitsu -> ~ 2nd Season +- 51252|45966|146323:13-24 -> 54947|47288|163542:1-12! + +# Spy x Family -> ~ Part 2 +- 50265|45398|140960:13-25 -> 50602|45619|142838:1-13! +# Spy x Family -> ~ Season 2 +- 50265|45398|140960:26-37 -> 53887|46873|158927:1-12! +# Spy x Family -> ~ Season 3 +- 50265|45398|140960:38-50 -> 59027|48939|177937:1-13! + +# Steins;Gate 0 -> ~ Special +- 30484|10788|21127:24 -> 37492|41443|104174:1 + +# Sugar Apple Fairy Tale -> ~ Part 2 +- 49980|45270|139821:13-24 -> 54842|47231|163079:1-12! + +# Suisei no Gargantia -> ~ Specials +- 16524|7449|16524:14-15 -> 19211|7872|19211:1-2 +# Puchitto Gargantia -> ~ Special +- 18413|7751|18413:14 -> 23099|8592|21863:1 + +# Suzumiya Haruhi no Yuuutsu -> ~ (2009) +- 849|751|849:15-28 -> 4382|3579|4382:1-14 + +# Sword Art Online: Alicization - War of Underworld -> ~ 2nd Season +- 39597|42213|108759:13-23 -> 40540|42927|114308:1-11! + +# Synduality: Noir -> ~ Part 2 +- 53026|46477|154643:13-24 -> 56731|47999|169559:1-12! + +# Tales of Zestiria the X -> ~: Saiyaku no Jidai +- 30911|11023|?:0 -> 33558|12283|?:1 +# Tales of Zestiria the X -> ~ 2nd Season +- 30911|11023|21221:13-25 -> 34086|12294|97645:1-13 + +# Tamayura: Sotsugyou Shashin Part 1 -> ~ 2,3,4 +- 25729|8808|20805:2 -> 29829|10439|21135:1 +- 25729|8808|20805:3 -> 29830|10440|21324:1 +- 25729|8808|20805:4 -> 29831|10441|21554:1 + +# Tasogare Otome x Amnesia -> ~: Taima Otome +- 12445|6734|12445:13 -> 14189|7093|14189:1 + +# Teekyuu -> ~ 2 +- 15125|7217|15125:13-24 -> 18121|7718|18121:1-12! +# Teekyuu -> ~ 3 +- 15125|7217|15125:25-36 -> 20473|7980|20444:1-12! +# Teekyuu -> ~ 4 +- 15125|7217|15125:37-48 -> 26395|8906|20833:1-12! +# Teekyuu -> ~ 5 +- 15125|7217|15125:49-60 -> 30567|10858|21136:1-12! +# Teekyuu -> ~ 6 +- 15125|7217|15125:61-72 -> 31440|11334|21301:1-12! +# Teekyuu -> ~ 7 +- 15125|7217|15125:73-84 -> 32228|11597|21513:1-12! +# Teekyuu -> ~ 8 +- 15125|7217|15125:85-96 -> 33811|12473|21896:1-12! +# Teekyuu -> ~ 9 +- 15125|7217|15125:97-108 -> 35370|13342|98566:1-12! + +# Teekyuu 2 Specials -> ~ 3,4,5,6 +- 21201|8078|20450:3-4 -> 22335|8289|20834:1-2 +- 21201|8078|20450:5-6 -> 31608|11392|21438:1-2 +- 21201|8078|20450:7-8 -> 31668|11444|21437:1-2 +- 21201|8078|20450:9-10 -> 32816|12092|98759:1-2 + +# Tegami Bachi -> ~ Reverse +- 6444|4564|6444:26-50 -> 8311|5288|8311:1-25! + +# Tenjou Tenge -> ~: The Ultimate Fight +- 174|151|174:25-26 -> 448|411|448:1-2 + +# Tensei Akujo no Kuro Rekishi -> ~ OVA +- 58146|48546|174914:13 -> 63266|50578|205054:1 + +# Tensei Kizoku, Kantei Skill de Nariagaru -> ~ 2nd Season +- 55265|47481|164702:13-24 -> 59131|48979|178434:1-12! +# Tensei Kizoku, Kantei Skill de Nariagaru -> ~ 3rd Season +- 55265|47481|164702:25-? -> 60601|49437|185756:1-?! + +# Tensei shitara Dainana Ouji Datta node, Kimama ni Majutsu wo Kiwamemasu -> ~ 2nd Season +- 53516|46704|156415:13-24 -> 59095|48968|178090:1-12! + +# Tensei shitara Slime Datta Ken -> ~ 2nd Season +- 37430|41024|101280:25-36 -> 39551|42196|108511:1-12! +# Tensei shitara Slime Datta Ken -> ~ 2nd Season Part 2 +- 37430|41024|101280:37-48 -> 41487|43361|116742:1-12! +# Tensei shitara Slime Datta Ken -> ~ 3rd Season +- 37430|41024|101280:49-72 -> 53580|46729|156822:1-24! +# Tensei shitara Slime Datta Ken 2nd Season -> ~ Part 2 +- 39551|42196|108511:13-24 -> 41487|43361|116742:1-12! + +# Tensui no Sakuna-hime -> ~: Kokorowa Inasaku Nisshi +- 58357|48607|175868:14-15 -> 60294|49335|184280:1-2 + +# The iDOLM@STER -> ~: 765 Pro to Iu Monogatari +- 10278|6111|10278:765 -> 11889|6627|11889:1 +# The iDOLM@STER Cinderella Girls -> ~ 2nd Season +- 23587|8777|20693:14-25 -> 30344|10760|21094:1-12! +# The iDOLM@STER Cinderella Girls -> ~: Anytime, Anywhere with Cinderella. +- 23587|8777|20693:26 -> 32174|11749|21564:1 +# The iDOLM@STER SideM -> ~ Prologue SideM: Episode of Jupiter +- 34915|13755|?:0 -> 36186|13890|?:1 + +# To LOVE-Ru Darkness OVA -> To LOVE-Ru Darkness 2nd OVA +- 13851|7047|13851:7-9 -> 31380|11418|21853:1-3 +# To LOVE-Ru Darkness 2nd -> ~ Specials +- 28979|10078|20995:13-14 -> 31711|12666|21852:1-2 + +# Toaru no Majutsu no Index -> ~ II +- 4654|3725|4654:25-48 -> 8937|5541|8937:1-24! + +# Tokyo Ghoul:re -> ~ 2nd Season +- 36511|13929|100240:13-24 -> 37799|41359|102351:1-12! + +# Tokyo Majin Gakuen Kenpucho: Tou -> ~ Dai Ni Maku +- 1860|1677|1860:15-26 -> 2683|2445|2683:1-12! + +# Tokyo Mew Mew New ♡ -> ~ 2nd Season +- 41589|43098|117196:13-24 -> 53097|46521|154876:1-12! + +# Towa no Quon 1 -> ~ 2,3,4,5,6 +- 10294|6116|10294:2 -> 10713|6310|10713:1 +- 10294|6116|10294:3 -> 10714|6311|10714:1 +- 10294|6116|10294:4 -> 10715|6312|10715:1 +- 10294|6116|10294:5 -> 10716|6313|10716:1 +- 10294|6116|10294:6 -> 10717|6314|10717:1 + +# Towa no Yuugure -> ~: Asa wo Kokoro ni, Ichi, Ni to Kazoe yo +- 61917|50008|?:0 -> 62359|50190|?:1 + +# Trigun Stampede -> Trigun Stargaze +- 52093|46183|151040:13-24 -> 54863|47243|163144:1-12! + +# Trinity Seven -> ~ OVA +- 25157|8607|20631:13 -> 28285|10014|21064:1 +# Trinity Seven Movie: Eternity Library to Alchemic Girl -> ~ +- 33581|12324|21874:0 -> ~|~|~:1 + +# Tsubasa Chronicle -> ~ 2nd Season +- 177|154|177:27-52 -> 969|859|969:1-26 + +# Uchuu Kyoudai -> ~: Number Zero +- 12431|6729|12431:0 -> 22583|8358|20559:1 + +# Uma Musume: Cinderella Gray -> ~ Part 2 +- 59636|49143|180516:14-23 -> 61930|50006|195240:1-10! + +# Umi Monogatari: Anata ga Ite Kureta Koto -> ~ - Shinjiau Kokoro +- 6129|4439|6129:13 -> 6883|4767|6883:1 + +# Under the Dog -> ~ Episode 0 +- 27387|9098|21002:0 -> 27387|9098|21002:1 + +# UN-GO -> ~: Inga-ron +- 10798|6353|10798:0 -> 11531|6529|11531:1 + +# Unnamed Memory -> ~ Act.2 +- 53835|46921|158709:13-24 -> 59142|48991|178550:1-12! + +# Urawa no Usagi-chan -> ~ Special +- 27927|9949|20949:13 -> 31426|11345|21433:1 + +# Urusei Yatsura (2022) -> ~ 2nd Season +- 50710|45694|143277:24-46 -> 54829|46620|155645:1-23! + +# Ushio to Tora (TV) -> ~ 2nd Season +- 29854|10359|21038:27-39 -> 31098|11126|21241:1-13! + +# Vanitas no Karte -> ~ Part 2 +- 48580|44241|131646:13-24 -> 49114|44672|135136:1-12! + +# Violet Evergarden -> ~ Special +- 33352|12230|21827:14 -> 37095|41155|101432:1 + +# ViVid Strike! -> ~ Specials +- 33589|12304|21871:13 -> 34812|13344|98220:3 + +# Wan Sheng Jie -> ~ 2 +- 41611|43184|114555:13-24 -> 42640|43614|122497:1-12! +# Wan Sheng Jie -> ~ 3 +- 41611|43184|114555:25-36 -> 49587|44980|137740:1-12! +# Wan Sheng Jie -> ~ 4 +- 41611|43184|114555:37-48 -> 53859|46867|158830:1-12! + +# Watashi ga Koibito ni Nareru Wake Nai jan, Muri Muri! (※Muri ja Nakatta!?) -> ~ Next Shine +- 60326|49358|184591:13-17 -> 62543|50180|199112:1-5! + +# Watashi ga Motenai no wa Dou Kangaetemo Omaera ga Warui! -> ~ OVA +- 16742|7504|16742:13 -> 20449|7991|20453:1 + +# Watashi ni Tenshi ga Maiorita! -> ~ Special +- 37993|41403|102680:13 -> 38999|42141|107138:1 + +# Watashi no Shiawase na Kekkon -> ~: Watashi no Shiawase na Katachi +- 51552|46050|147103:13 -> 55889|47800|167486:1 +# Watashi no Shiawase na Kekkon -> ~ 2nd Season +- 51552|46050|147103:14-26 -> 56701|47969|169441:1-13 + +# White Album -> ~ 2nd Season +- 4720|3760|4720:14-26 -> 6165|4455|6165:1-13! + +# Wind Breaker -> ~ Season 2 +- 54900|47271|163270:14-25 -> 59160|48992|178680:1-12! + +# Winter Sonata -> ~ Episode 0 +- 4189|3511|4189:0 -> 7377|4957|7377:1 + +# Wolf's Rain -> ~ OVA +- 202|7716|202:27-30 -> 1023|913|1023:1-4 + +# Wonder Egg Priority -> ~ Special +- 43299|43620|124845:13 -> 48614|44260|131773:1 + +# Wooser no Sono Higurashi -> ~: Ken to Pantsu to Wooser to +- 15313|7238|15313:13 -> 16738|7502|16738:1 + +# Working!!! -> ~ Lord of the Takanashi +- 25879|8712|20819:14 -> 31715|11420|21384:1 + +# Yakusoku no Neverland -> ~ 2nd Season +- 37779|41312|101759:13-23 -> 39617|42220|108725:1-11 + +# Yama no Susume -> ~: Kabette Kowakunai no? +- 14355|7117|14355:13 -> 17341|7766|17341:1 +# Yama no Susume 2nd Season -> ~ Specials +- 21435|8201|20485:25 -> 27887|9783|21003:2 + +# Yasei no Last Boss ga Arawareta! -> ~ 2nd Season +- 59644|49147|180523:13-? -> 63140|50404|204389:1-?! + +# Yomigaeru Sora: Rescue Wings -> ~ Special +- 798|712|798:13 -> 1843|1660|1843:1 + +# Yoroi Shin Den Samurai Troopers -> ~ Part 2 +- 61830|50427|194318:13-24 -> 63047|50622|209800:1-12! + +# Yoru wa Neko to Issho -> ~ Season 2 +- 51692|46090|148042:31-60 -> 54198|46974|160088:1-30! +# Yoru wa Neko to Issho -> ~ Season 3 +- 51692|46090|148042:61-100 -> 58199|48567|175137:1-40! + +# Youjo Senki Movie -> ~ +- 37055|14173|100878:0 -> ~|~|~:1 + +# Youjo Shenki -> ~ Episode 0 +- 34742|13215|?:0 -> 34723|13222|?:1 + +# Yowamushi Pedal: Re:RIDE -> ~ +- 25755|8657|20808:0 -> ~|~|~:1 +# Yowamushi Pedal: Re:ROAD -> ~ +- 30790|10940|21194:0 -> ~|~|~:1 +# Yowamushi Pedal Movie -> ~ +- 30413|10769|21114:0 -> ~|~|~:1 + +# Yu☆Gi☆Oh! Zexal -> ~ Second +- 10015|5995|10015:74-146 -> 15489|7263|15489:1-73! + +# Yuru Camp△ -> ~ Specials +- 34798|13480|98444:0 -> 37341|41068|101206:1 + +# Yurumates 3D: Ushinawareta Genjitsu -> Yurumates 3D Plus: Natsuyasumi Maison du Wish Report +- 14949|7187|?:2 -> 31551|11371|?:1 + +# Yuukoku no Moriarty -> ~ 2nd Season +- 40911|43448|114124:12-24 -> 43325|43708|124858:1-13! + +# Yuusha ni Narenakatta Ore wa Shibushibu Shuushoku wo Ketsui Shimashita. -> ~ OVA +- 18677|7725|18677:13 -> 20545|8029|20551:1 diff --git a/crates/sync/src/lib.rs b/crates/sync/src/lib.rs index c1cc684..606a17e 100644 --- a/crates/sync/src/lib.rs +++ b/crates/sync/src/lib.rs @@ -1,4 +1,5 @@ -//! List backends: the trait, and a list kept in memory. +//! List backends: the trait, a list kept in memory and AniList's, and the +//! rules that send an episode of one work to another. //! //! A backend reads an account's list and writes where the account stands on //! one work, named by the number the service gives it. Recognition yields a @@ -15,6 +16,9 @@ pub mod anilist; pub mod memory; +pub mod plan; +pub mod relations; +pub mod resolve; use std::time::Duration; diff --git a/crates/sync/src/plan.rs b/crates/sync/src/plan.rs new file mode 100644 index 0000000..3b09319 --- /dev/null +++ b/crates/sync/src/plan.rs @@ -0,0 +1,466 @@ +//! What a viewing writes to an entry of a list. +//! +//! A write carries the whole of where the account stands on a work: the +//! status, the progress, how many times it was seen again, and both dates. +//! AniList moves an entry on its own where a write leaves something out: a +//! progress of the whole count sent alone makes a new entry current, and +//! completes it the second time it is sent. So [`plan`] answers with +//! everything, decided from what the list holds of the entry and the episode +//! that was seen, and a write sent twice leaves the entry where it left it +//! once. +//! +//! **What is written, by what the entry holds and which episode was seen:** +//! +//! | The entry | The episode | Written | +//! | --- | --- | --- | +//! | none, planned, on hold, dropped, watching | above the progress, below the last | watching, that progress, the start date where there is none | +//! | the same | the last | completed, that progress, the start date where there is none, the completion date | +//! | watching, on hold, dropped, planned | at or below the progress | nothing: a conflict | +//! | completed, one episode | 1 | completed, one more time seen again | +//! | completed, several | 1 | rewatching, progress 1 | +//! | completed, several | another | nothing: no rewatch begins there | +//! | rewatching | above the progress, below the last | rewatching, that progress | +//! | rewatching | the last | completed, one more time seen again | +//! | rewatching | at or below the progress | nothing: a conflict | +//! +//! A date is the day of the viewing, which the caller names. Where the table +//! names no date, the dates are the ones the entry holds. The last episode is +//! the entry's count, and an entry with no count has no last episode. An +//! entry completed with no count is taken as one of several. An episode past +//! the count writes nothing. + +use benshi_core::list::Status; + +/// A day of the calendar, as a list keeps the day an entry was begun or +/// completed. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Day { + /// The year. + pub year: u16, + /// The month, from 1 to 12. + pub month: u8, + /// The day of the month, from 1 to 31. + pub day: u8, +} + +/// Where the account stands on a work, as its list holds it, or as a write +/// leaves it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Standing { + /// The status. + pub status: Status, + /// How many episodes the account has seen. + pub progress: u32, + /// How many times the account has seen the work again. + pub rewatches: u32, + /// The day the account began the work, where the list holds one. + pub started: Option, + /// The day the account completed the work, where the list holds one. + pub completed: Option, +} + +/// Why a viewing writes nothing. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Why { + /// The episode is at or below the progress the list holds. + Conflict { + /// The progress the list holds. + progress: u32, + }, + /// The list holds the work as completed, and an episode other than the + /// first begins no rewatch. + Completed, + /// The episode is past the count of the work. + PastCount { + /// The count. + episodes: u32, + }, +} + +/// What a viewing writes, or why it writes nothing. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Plan { + /// The whole entry, as the write leaves it. + Write(Standing), + /// Nothing, and why. + Nothing(Why), +} + +/// What seeing this episode on `day` writes to an entry the list holds as +/// `held`. +/// +/// `held` is none where the work is on no list. `episodes` is the work's +/// count, and none where the service holds none; `episode` is in the entry's +/// numbering. +#[must_use] +pub fn plan(held: Option<&Standing>, episodes: Option, episode: u32, day: Day) -> Plan { + if let Some(count) = episodes + && episode > count + { + return Plan::Nothing(Why::PastCount { episodes: count }); + } + let last = episodes == Some(episode); + let held = held.copied().unwrap_or(NOWHERE); + + match held.status { + Status::Planned | Status::OnHold | Status::Dropped | Status::Watching => { + if episode <= held.progress { + return Plan::Nothing(Why::Conflict { + progress: held.progress, + }); + } + let started = held.started.or(Some(day)); + Plan::Write(if last { + Standing { + status: Status::Completed, + progress: episode, + started, + completed: Some(day), + ..held + } + } else { + Standing { + status: Status::Watching, + progress: episode, + started, + ..held + } + }) + } + Status::Completed if episode != 1 => Plan::Nothing(Why::Completed), + Status::Completed if episodes == Some(1) => Plan::Write(Standing { + progress: 1, + rewatches: held.rewatches.saturating_add(1), + ..held + }), + Status::Completed => Plan::Write(Standing { + status: Status::Rewatching, + progress: 1, + ..held + }), + Status::Rewatching => { + if episode <= held.progress { + return Plan::Nothing(Why::Conflict { + progress: held.progress, + }); + } + Plan::Write(if last { + Standing { + status: Status::Completed, + progress: episode, + rewatches: held.rewatches.saturating_add(1), + ..held + } + } else { + Standing { + progress: episode, + ..held + } + }) + } + } +} + +/// A work on no list, which a viewing writes as an entry planned and not +/// begun would be written. +const NOWHERE: Standing = Standing { + status: Status::Planned, + progress: 0, + rewatches: 0, + started: None, + completed: None, +}; + +#[cfg(test)] +mod tests { + use super::{Day, Plan, Standing, Why, plan}; + use benshi_core::list::Status; + + /// The day of every viewing here. + const TODAY: Day = Day { + year: 2026, + month: 10, + day: 14, + }; + + /// A day an entry was begun before. + const BEGUN: Day = Day { + year: 2026, + month: 9, + day: 2, + }; + + /// A day an entry was completed before. + const DONE: Day = Day { + year: 2026, + month: 9, + day: 30, + }; + + /// An entry of this status at this progress, begun on [`BEGUN`] where it + /// is under way. + fn held(status: Status, progress: u32) -> Standing { + Standing { + status, + progress, + rewatches: 0, + started: (progress > 0).then_some(BEGUN), + completed: None, + } + } + + /// A completed entry of this progress, seen again this many times. + fn completed(progress: u32, rewatches: u32) -> Standing { + Standing { + status: Status::Completed, + progress, + rewatches, + started: Some(BEGUN), + completed: Some(DONE), + } + } + + /// A write of this standing. + fn write( + status: Status, + progress: u32, + rewatches: u32, + started: Option, + completed: Option, + ) -> Plan { + Plan::Write(Standing { + status, + progress, + rewatches, + started, + completed, + }) + } + + #[test] + fn a_work_on_no_list_seen_short_of_the_last_is_begun_today() { + assert_eq!( + plan(None, Some(12), 3, TODAY), + write(Status::Watching, 3, 0, Some(TODAY), None) + ); + } + + #[test] + fn a_work_on_no_list_seen_at_the_last_is_begun_and_completed_today() { + assert_eq!( + plan(None, Some(12), 12, TODAY), + write(Status::Completed, 12, 0, Some(TODAY), Some(TODAY)) + ); + assert_eq!( + plan(None, Some(1), 1, TODAY), + write(Status::Completed, 1, 0, Some(TODAY), Some(TODAY)) + ); + } + + #[test] + fn an_entry_planned_on_hold_or_dropped_is_watching_once_an_episode_past_its_progress_is_seen() { + for status in [ + Status::Planned, + Status::OnHold, + Status::Dropped, + Status::Watching, + ] { + let before = held(status, 4); + + assert_eq!( + plan(Some(&before), Some(12), 5, TODAY), + write(Status::Watching, 5, 0, Some(BEGUN), None), + "{status:?}" + ); + } + } + + #[test] + fn an_entry_under_way_keeps_its_completion_date_and_its_rewatches() { + let before = Standing { + status: Status::Dropped, + progress: 3, + rewatches: 2, + started: Some(BEGUN), + completed: Some(DONE), + }; + + assert_eq!( + plan(Some(&before), Some(12), 4, TODAY), + write(Status::Watching, 4, 2, Some(BEGUN), Some(DONE)) + ); + } + + #[test] + fn an_entry_completed_by_a_viewing_takes_its_day_over_the_one_it_held() { + let before = Standing { + status: Status::Dropped, + progress: 11, + rewatches: 2, + started: Some(BEGUN), + completed: Some(DONE), + }; + + assert_eq!( + plan(Some(&before), Some(12), 12, TODAY), + write(Status::Completed, 12, 2, Some(BEGUN), Some(TODAY)) + ); + } + + #[test] + fn an_entry_with_no_start_date_is_begun_today() { + let planned = held(Status::Planned, 0); + assert_eq!(planned.started, None); + + assert_eq!( + plan(Some(&planned), Some(12), 1, TODAY), + write(Status::Watching, 1, 0, Some(TODAY), None) + ); + } + + #[test] + fn an_entry_seen_at_its_last_episode_is_completed_today() { + for status in [ + Status::Planned, + Status::OnHold, + Status::Dropped, + Status::Watching, + ] { + let before = held(status, 11); + + assert_eq!( + plan(Some(&before), Some(12), 12, TODAY), + write(Status::Completed, 12, 0, Some(BEGUN), Some(TODAY)), + "{status:?}" + ); + } + } + + #[test] + fn an_episode_at_or_below_the_progress_of_an_entry_under_way_is_a_conflict() { + for status in [ + Status::Watching, + Status::OnHold, + Status::Dropped, + Status::Planned, + ] { + let before = held(status, 5); + + for episode in [5, 2] { + assert_eq!( + plan(Some(&before), Some(12), episode, TODAY), + Plan::Nothing(Why::Conflict { progress: 5 }), + "{status:?} {episode}" + ); + } + } + } + + #[test] + fn a_work_of_one_episode_seen_again_is_completed_one_time_more() { + // A film seen again: the dates are the ones the list holds. + assert_eq!( + plan(Some(&completed(1, 2)), Some(1), 1, TODAY), + write(Status::Completed, 1, 3, Some(BEGUN), Some(DONE)) + ); + } + + #[test] + fn a_series_seen_again_from_its_first_episode_is_rewatched() { + assert_eq!( + plan(Some(&completed(12, 1)), Some(12), 1, TODAY), + write(Status::Rewatching, 1, 1, Some(BEGUN), Some(DONE)) + ); + } + + #[test] + fn another_episode_of_a_completed_series_writes_nothing() { + for episode in [2, 5, 12] { + assert_eq!( + plan(Some(&completed(12, 0)), Some(12), episode, TODAY), + Plan::Nothing(Why::Completed), + "{episode}" + ); + } + } + + #[test] + fn a_rewatch_goes_on_short_of_the_last_episode() { + let rewatching = Standing { + status: Status::Rewatching, + progress: 3, + ..completed(3, 1) + }; + + assert_eq!( + plan(Some(&rewatching), Some(12), 4, TODAY), + write(Status::Rewatching, 4, 1, Some(BEGUN), Some(DONE)) + ); + } + + #[test] + fn a_rewatch_at_its_last_episode_is_completed_one_time_more() { + let rewatching = Standing { + status: Status::Rewatching, + progress: 11, + ..completed(11, 1) + }; + + assert_eq!( + plan(Some(&rewatching), Some(12), 12, TODAY), + write(Status::Completed, 12, 2, Some(BEGUN), Some(DONE)) + ); + } + + #[test] + fn an_episode_at_or_below_the_progress_of_a_rewatch_is_a_conflict() { + let rewatching = Standing { + status: Status::Rewatching, + progress: 5, + ..completed(5, 1) + }; + + for episode in [5, 2] { + assert_eq!( + plan(Some(&rewatching), Some(12), episode, TODAY), + Plan::Nothing(Why::Conflict { progress: 5 }), + "{episode}" + ); + } + } + + #[test] + fn an_entry_with_no_count_has_no_last_episode() { + assert_eq!( + plan(Some(&held(Status::Watching, 29)), None, 30, TODAY), + write(Status::Watching, 30, 0, Some(BEGUN), None) + ); + assert_eq!( + plan(None, None, 1, TODAY), + write(Status::Watching, 1, 0, Some(TODAY), None) + ); + } + + #[test] + fn an_entry_completed_with_no_count_is_taken_as_one_of_several() { + assert_eq!( + plan(Some(&completed(12, 0)), None, 1, TODAY), + write(Status::Rewatching, 1, 0, Some(BEGUN), Some(DONE)) + ); + } + + #[test] + fn an_episode_past_the_count_writes_nothing() { + for before in [ + None, + Some(held(Status::Watching, 11)), + Some(completed(12, 0)), + ] { + assert_eq!( + plan(before.as_ref(), Some(12), 13, TODAY), + Plan::Nothing(Why::PastCount { episodes: 12 }), + "{before:?}" + ); + } + } +} diff --git a/crates/sync/src/relations.rs b/crates/sync/src/relations.rs new file mode 100644 index 0000000..457e1ce --- /dev/null +++ b/crates/sync/src/relations.rs @@ -0,0 +1,846 @@ +//! The rules of `anime-relations`: an episode of one work that is an episode +//! of another. +//! +//! A release numbers its episodes as its group chose. A group that numbers a +//! second season on from the first names its first episode 14, of a series of +//! thirteen, and a group that counts a special into a series names it as one +//! of the series. The rules of `erengy/anime-relations` say where each such +//! episode belongs, for the works of three services. The file is compiled in, +//! from that repository at commit +//! `1eae4b63008d9e91fb9b5a9ced22ed20037f479f`, version 1.3.0, and is in the +//! public domain under CC0 1.0, whose text is beside it in +//! `data/anime-relations.LICENSE`. +//! +//! A rule is written as the file writes it: +//! +//! ```text +//! - 900001|900002|900003:14-25 -> 900004|900005|900006:1-12! +//! ``` +//! +//! A work is numbered by MyAnimeList, by Kitsu and by AniList, in that order, +//! and `?` is a number the file does not know. `~` in the second half +//! repeats the number of the first. The episodes are one number or a range, +//! and a range that ends in `?` has no last episode. An `!` at the end makes +//! a second rule, from the second work numbered as the first to the second +//! work: the rule above also sends episode 14 of `900006` to its episode 1. +//! +//! **Every rule is read or none is.** A range of the second half is one +//! episode, which every episode of the first goes to, or as long as the +//! range of the first, which it takes episode by episode; a range is open on +//! both sides or on neither. A file that writes a rule any other way is +//! refused at the line, because a rule read some other way would send an +//! episode where its author did not. + +use std::sync::LazyLock; + +use benshi_core::list::EntryId; + +/// The rules this build carries, as the file of `anime-relations` writes them. +pub const BUNDLED: &str = include_str!("../data/anime-relations.txt"); + +/// A service whose numbers a rule carries, one column each. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Service { + /// The first column. + MyAnimeList, + /// The second column. + Kitsu, + /// The third column. + AniList, +} + +impl Service { + /// The column of this service in a rule. + const fn column(self) -> usize { + match self { + Self::MyAnimeList => 0, + Self::Kitsu => 1, + Self::AniList => 2, + } + } +} + +/// Episodes of one work, from the first to the last, and on with no end +/// where the rule writes `?` for the last. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Episodes { + /// The first episode. + pub first: u32, + /// The last episode, and none where the range has no end. + pub last: Option, +} + +impl Episodes { + /// Whether the episodes hold this one. + fn holds(self, episode: u32) -> bool { + episode >= self.first && self.last.is_none_or(|last| episode <= last) + } +} + +/// One half of a rule: a work as each service numbers it, and its episodes. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct End { + /// The work, by the number of each service in the order of + /// [`Service`], and none where the file does not know the number. + pub work: [Option; 3], + /// Its episodes. + pub episodes: Episodes, +} + +/// A rule, as the file writes it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Rule { + /// The work and the episodes the rule takes. + pub from: End, + /// The work and the episodes it sends them to. + pub to: End, + /// Whether the rule ends in `!`, and so takes the same episodes of the + /// second work as well. + pub also_to_itself: bool, +} + +/// Where a rule sends an episode. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Redirect { + /// This episode of this work. + To { + /// The work, by the number of the service that asked. + work: EntryId, + /// The episode of it. + episode: u32, + }, + /// A rule takes the episode, and gives no number for where it goes: it + /// writes `?` for the work on the service that asked, or the episode it + /// sends to is past `u32::MAX`. + Unnumbered, +} + +/// Why a file of rules was not read, and at which line. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +#[error("line {line}: {reason}")] +pub struct Error { + /// The line, counted from one, and 0 for a file with no line. + pub line: usize, + /// What is wrong with it. + pub reason: Reason, +} + +/// What is wrong with a line of a file of rules. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum Reason { + /// The line stands before the first section. + #[error("line is in no section")] + NoSection, + /// A section this reader does not know. + #[error("section {0:?} is not one of meta and rules")] + Section(String), + /// A line of the rules section that does not begin with `- `, as a rule + /// does. + #[error("{0:?} is not a rule")] + NotRule(String), + /// A rule with no arrow between its two halves. + #[error("rule has no arrow")] + NoArrow, + /// A half of a rule that is not three numbers and episodes. + #[error("{0:?} is not three numbers and episodes")] + Half(String), + /// A number of a work that is not digits within the range of + /// [`EntryId`], `?` or, in the second half, `~`. + #[error("{0:?} is not a number of a work")] + Work(String), + /// Episodes that are not a number or a range, or name an episode past + /// `u32::MAX`. + #[error("{0:?} is not an episode or a range of them")] + Episodes(String), + /// A range that ends before it begins. + #[error("range {0:?} ends before it begins")] + Backwards(String), + /// A range open on one side of a rule and closed on the other. + #[error("range is open on one side only")] + OpenOnOneSide, + /// A second half neither one episode nor as long as the first. + #[error("second half is neither one episode nor as long as first half")] + Lengths, + /// A file that names no version in its meta section, or names only an + /// empty one. + #[error("file names no version")] + NoVersion, +} + +/// The rules of a file, in the order it writes them. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Relations { + version: String, + rules: Vec, +} + +impl Relations { + /// The rules of a file written as `anime-relations` writes one. + /// + /// # Errors + /// + /// The first line that is refused, with what is wrong with it: a line + /// before every section, a section other than `meta` and `rules`, or a + /// line of `rules` that is not a rule written as the module says. + /// Comments and blank lines are passed over anywhere, and so is every + /// line of `meta` but its version. A file that names no version, or only + /// an empty one, is refused at its last line, which is line 0 for an + /// empty file. + pub fn read(text: &str) -> Result { + let mut section = None; + let mut version = None; + let mut rules = Vec::new(); + let mut lines = 0; + + for (index, written) in text.lines().enumerate() { + let line = index + 1; + lines = line; + let at = |reason| Error { line, reason }; + let written = written.trim(); + if written.is_empty() || written.starts_with('#') { + continue; + } + if let Some(name) = written.strip_prefix("::") { + section = match name { + "meta" => Some(Section::Meta), + "rules" => Some(Section::Rules), + other => return Err(at(Reason::Section(other.to_owned()))), + }; + continue; + } + match section { + None => return Err(at(Reason::NoSection)), + Some(Section::Meta) => { + if let Some(named) = written.strip_prefix("- version:").map(str::trim) + && !named.is_empty() + { + version = Some(named.to_owned()); + } + } + Some(Section::Rules) => rules.push(rule(written).map_err(at)?), + } + } + + let version = version.ok_or(Error { + line: lines, + reason: Reason::NoVersion, + })?; + Ok(Self { version, rules }) + } + + /// The version the file names. + #[must_use] + pub fn version(&self) -> &str { + &self.version + } + + /// Every rule, in the order the file writes them. + #[must_use] + pub fn rules(&self) -> &[Rule] { + &self.rules + } + + /// Where the rules send this episode of this work, as `service` numbers + /// works, and nothing where no rule takes it. + /// + /// The first rule that takes the episode decides, in the order of the + /// file, and the second rule an `!` makes counts right after the rule + /// that makes it, numbered or [`Redirect::Unnumbered`]. + #[must_use] + pub fn redirect(&self, service: Service, work: EntryId, episode: u32) -> Option { + let column = service.column(); + self.rules + .iter() + .find(|rule| rule.takes(column, work, episode)) + .map(|rule| rule.sends(column, episode)) + } +} + +impl Rule { + /// Whether this rule takes this episode of a work numbered in this + /// column. + fn takes(&self, column: usize, work: EntryId, episode: u32) -> bool { + self.from.episodes.holds(episode) + && (self.from.work[column] == Some(work) + || self.also_to_itself && self.to.work[column] == Some(work)) + } + + /// Where this rule sends an episode it takes, as this column numbers + /// works. + fn sends(&self, column: usize, episode: u32) -> Redirect { + let Some(sent_to) = self.to.work[column] else { + return Redirect::Unnumbered; + }; + let to = self.to.episodes; + let sent = if to.last == Some(to.first) { + Some(to.first) + } else { + to.first.checked_add(episode - self.from.episodes.first) + }; + + sent.map_or(Redirect::Unnumbered, |episode| Redirect::To { + work: sent_to, + episode, + }) + } +} + +/// A section of the file. +#[derive(Debug, Clone, Copy)] +enum Section { + /// `::meta`, whose `version` is the one entry read. + Meta, + /// `::rules`, where every line but a comment or a blank one is a rule. + Rules, +} + +/// One line of the rules section, read as a rule. +fn rule(written: &str) -> Result { + let body = written + .strip_prefix("- ") + .ok_or_else(|| Reason::NotRule(written.to_owned()))?; + let (from, to) = body.split_once(" -> ").ok_or(Reason::NoArrow)?; + let (to, also_to_itself) = to.strip_suffix('!').map_or((to, false), |to| (to, true)); + let from = half(from, None)?; + let to = half(to, Some(&from.work))?; + + match (from.episodes.last, to.episodes.last) { + (None, None) => {} + (Some(_), None) | (None, Some(_)) => return Err(Reason::OpenOnOneSide), + (Some(last), Some(sent_last)) => { + let sent = sent_last - to.episodes.first; + if sent != 0 && sent != last - from.episodes.first { + return Err(Reason::Lengths); + } + } + } + + Ok(Rule { + from, + to, + also_to_itself, + }) +} + +/// One half of a rule: three numbers of a work and its episodes. +/// +/// `first` is the work of the first half, which a `~` in the second repeats, +/// and none for the first half, where a `~` is refused. +fn half(written: &str, first: Option<&[Option; 3]>) -> Result { + let not_a_half = || Reason::Half(written.to_owned()); + let (numbers, episodes) = written.split_once(':').ok_or_else(not_a_half)?; + let numbers: Vec<&str> = numbers.split('|').collect(); + if numbers.len() != 3 { + return Err(not_a_half()); + } + + let mut work = [None; 3]; + for (column, number) in numbers.into_iter().enumerate() { + work[column] = match (number, first) { + ("?", _) => None, + ("~", Some(first)) => first[column], + (digits, _) => Some(EntryId( + whole(digits).ok_or_else(|| Reason::Work(digits.to_owned()))?, + )), + }; + } + + Ok(End { + work, + episodes: range(episodes)?, + }) +} + +/// Episodes written as one number, as a range, or as a range open at its +/// end. +fn range(written: &str) -> Result { + let not_episodes = || Reason::Episodes(written.to_owned()); + let episode = |digits: &str| whole(digits).ok_or_else(not_episodes); + + match written.split_once('-') { + None => { + let only = episode(written)?; + Ok(Episodes { + first: only, + last: Some(only), + }) + } + Some((first, "?")) => Ok(Episodes { + first: episode(first)?, + last: None, + }), + Some((first, last)) => { + let (first, last) = (episode(first)?, episode(last)?); + if last < first { + return Err(Reason::Backwards(written.to_owned())); + } + Ok(Episodes { + first, + last: Some(last), + }) + } + } +} + +/// A number written in decimal digits and nothing else. +fn whole(digits: &str) -> Option { + if digits.is_empty() || !digits.bytes().all(|byte| byte.is_ascii_digit()) { + return None; + } + digits.parse().ok() +} + +/// The rules this build carries. +/// +/// # Panics +/// +/// Where [`BUNDLED`] does not read, which its tests rule out. +#[must_use] +pub fn bundled() -> &'static Relations { + static READ: LazyLock = LazyLock::new(|| { + Relations::read(BUNDLED) + .unwrap_or_else(|refused| panic!("bundled anime-relations does not read: {refused}")) + }); + &READ +} + +#[cfg(test)] +mod tests { + use super::{Episodes, Error, Reason, Redirect, Relations, Service, bundled}; + use benshi_core::list::EntryId; + + /// A file of these rule lines, after a meta section that names its + /// version, so the first of them is line 4. + fn file(lines: &str) -> String { + format!("::meta\n- version: 1.3.0\n::rules\n{lines}\n") + } + + /// The rules of a file of these rule lines. + fn rules(lines: &str) -> Relations { + let text = file(lines); + Relations::read(&text).unwrap_or_else(|refused| panic!("{refused}: {text}")) + } + + /// What a file of these rule lines is refused with. + fn refusal(lines: &str) -> Error { + Relations::read(&file(lines)).expect_err("the file is refused") + } + + /// Where the rules send an episode of a work numbered by AniList. + fn on_anilist(relations: &Relations, work: i64, episode: u32) -> Option { + relations.redirect(Service::AniList, EntryId(work), episode) + } + + /// This episode of this work. + fn to(work: i64, episode: u32) -> Redirect { + Redirect::To { + work: EntryId(work), + episode, + } + } + + #[test] + fn every_rule_of_the_bundled_file_is_read() { + let bundled = bundled(); + + assert_eq!(bundled.version(), "1.3.0"); + assert_eq!(bundled.rules().len(), 549); + } + + #[test] + fn the_bundled_file_reads_as_counted_over_its_text() { + // Counted over the text with no reader of its own: 255 rules end in + // `!`, 26 have no AniList number for the work they take and 26 none + // for the work they send to, and 6 have an open range. + let rules = bundled().rules(); + let anilist = Service::AniList.column(); + + assert_eq!(rules.iter().filter(|rule| rule.also_to_itself).count(), 255); + assert_eq!( + rules + .iter() + .filter(|rule| rule.from.work[anilist].is_none()) + .count(), + 26 + ); + assert_eq!( + rules + .iter() + .filter(|rule| rule.to.work[anilist].is_none()) + .count(), + 26 + ); + assert_eq!( + rules + .iter() + .filter(|rule| rule.from.episodes.last.is_none()) + .count(), + 6 + ); + } + + #[test] + fn every_rule_of_the_bundled_file_that_takes_several_episodes_to_one_sends_each_there() { + let mut several_to_one = 0; + for rule in bundled().rules() { + let (from, to) = (rule.from.episodes, rule.to.episodes); + let (Some(last), Some(only)) = (from.last, to.last) else { + continue; + }; + if last == from.first || only != to.first { + continue; + } + several_to_one += 1; + for service in [Service::MyAnimeList, Service::Kitsu, Service::AniList] { + let (Some(work), Some(sent_to)) = ( + rule.from.work[service.column()], + rule.to.work[service.column()], + ) else { + continue; + }; + for episode in from.first..=last { + assert_eq!( + bundled().redirect(service, work, episode), + Some(Redirect::To { + work: sent_to, + episode: to.first, + }), + "{rule:?} {service:?} {episode}" + ); + } + } + } + assert_eq!(several_to_one, 4); + } + + #[test] + fn two_episodes_go_to_one() { + // A special the group counted as two episodes of the series. + let relations = rules("- 900001|900002|900003:11-12 -> 900004|900005|900006:1"); + + assert_eq!(on_anilist(&relations, 900_003, 11), Some(to(900_006, 1))); + assert_eq!(on_anilist(&relations, 900_003, 12), Some(to(900_006, 1))); + assert_eq!(on_anilist(&relations, 900_003, 10), None); + assert_eq!(on_anilist(&relations, 900_003, 13), None); + } + + #[test] + fn an_episode_zero_goes_to_the_first() { + let relations = rules("- 900001|900002|900003:0 -> 900004|900005|900006:1"); + + assert_eq!(on_anilist(&relations, 900_003, 0), Some(to(900_006, 1))); + assert_eq!(on_anilist(&relations, 900_003, 1), None); + } + + #[test] + fn a_range_goes_to_a_range_episode_by_episode() { + let relations = rules("- 900001|900002|900003:14-25 -> 900004|900005|900006:1-12"); + + assert_eq!(on_anilist(&relations, 900_003, 14), Some(to(900_006, 1))); + assert_eq!(on_anilist(&relations, 900_003, 20), Some(to(900_006, 7))); + assert_eq!(on_anilist(&relations, 900_003, 25), Some(to(900_006, 12))); + assert_eq!(on_anilist(&relations, 900_003, 13), None); + assert_eq!(on_anilist(&relations, 900_003, 26), None); + } + + #[test] + fn an_open_range_takes_every_episode_from_its_first_on() { + let relations = rules("- 900001|900002|900003:171-? -> 900004|900005|900006:1-?"); + + assert_eq!(on_anilist(&relations, 900_003, 171), Some(to(900_006, 1))); + assert_eq!( + on_anilist(&relations, 900_003, 1000), + Some(to(900_006, 830)) + ); + assert_eq!(on_anilist(&relations, 900_003, 170), None); + } + + #[test] + fn each_service_reads_its_own_column() { + let relations = rules("- 900001|900002|900003:14 -> 900004|900005|900006:1"); + + assert_eq!( + relations.redirect(Service::MyAnimeList, EntryId(900_001), 14), + Some(to(900_004, 1)) + ); + assert_eq!( + relations.redirect(Service::Kitsu, EntryId(900_002), 14), + Some(to(900_005, 1)) + ); + assert_eq!(on_anilist(&relations, 900_003, 14), Some(to(900_006, 1))); + assert_eq!(on_anilist(&relations, 900_001, 14), None); + assert_eq!( + relations.redirect(Service::MyAnimeList, EntryId(900_003), 14), + None + ); + } + + #[test] + fn a_rule_that_ends_in_a_bang_takes_the_second_work_numbered_as_the_first() { + // A group that numbers the second season on from the first names it + // under its own title too. + let bang = rules("- 900001|900002|900003:14-25 -> 900004|900005|900006:1-12!"); + let plain = rules("- 900001|900002|900003:14-25 -> 900004|900005|900006:1-12"); + + assert_eq!(on_anilist(&bang, 900_006, 14), Some(to(900_006, 1))); + assert_eq!(on_anilist(&bang, 900_006, 25), Some(to(900_006, 12))); + assert_eq!(on_anilist(&bang, 900_006, 1), None); + assert_eq!(on_anilist(&bang, 900_009, 14), None); + assert_eq!(on_anilist(&bang, 900_003, 14), Some(to(900_006, 1))); + assert_eq!(on_anilist(&plain, 900_006, 14), None); + assert!(bang.rules()[0].also_to_itself); + assert!(!plain.rules()[0].also_to_itself); + } + + #[test] + fn a_tilde_repeats_the_number_of_the_first_work() { + let relations = rules("- 900001|900002|900003:13 -> ~|~|~:12"); + + assert_eq!(on_anilist(&relations, 900_003, 13), Some(to(900_003, 12))); + assert_eq!( + relations.redirect(Service::Kitsu, EntryId(900_002), 13), + Some(to(900_002, 12)) + ); + } + + #[test] + fn a_column_written_as_a_question_mark_takes_no_episode() { + let relations = rules("- ?|900002|?:13-24 -> ?|900005|900006:1-12"); + + assert_eq!( + relations.redirect(Service::Kitsu, EntryId(900_002), 13), + Some(to(900_005, 1)) + ); + assert_eq!( + relations.redirect(Service::MyAnimeList, EntryId(900_002), 13), + None + ); + assert_eq!(on_anilist(&relations, 900_002, 13), None); + } + + #[test] + fn a_work_sent_to_as_a_question_mark_is_said_so() { + // The rule takes the episode, and writes `?` for the AniList number + // of where it goes: an answer apart from no rule at all. + let relations = rules("- 900001|?|900003:13 -> 900004|?|?:1!"); + + assert_eq!( + on_anilist(&relations, 900_003, 13), + Some(Redirect::Unnumbered) + ); + assert_eq!( + relations.redirect(Service::MyAnimeList, EntryId(900_001), 13), + Some(to(900_004, 1)) + ); + assert_eq!( + relations.redirect(Service::MyAnimeList, EntryId(900_004), 13), + Some(to(900_004, 1)) + ); + } + + #[test] + fn the_first_rule_in_the_file_decides_and_a_bang_counts_right_after_its_rule() { + // The bundled file holds one such pair: a rule of `!` takes episodes + // 60 to 75 of its second work numbered as its first, and a later rule + // takes 17 to 75 of that work to a third. + let relations = rules( + "- 900001|?|900001:60-75 -> 900002|?|900002:1-16!\n\ + - 900002|?|900002:17-75 -> 900003|?|900003:1-59", + ); + + assert_eq!(on_anilist(&relations, 900_002, 60), Some(to(900_002, 1))); + assert_eq!(on_anilist(&relations, 900_002, 59), Some(to(900_003, 43))); + } + + #[test] + fn a_rule_is_read_as_the_file_writes_it() { + let relations = rules( + "# A comment.\n\n\ + - 900001|?|900003:13-? -> ~|900005|?:1-?!", + ); + + let rule = &relations.rules()[0]; + assert_eq!( + rule.from.work, + [Some(EntryId(900_001)), None, Some(EntryId(900_003))] + ); + assert_eq!( + rule.to.work, + [Some(EntryId(900_001)), Some(EntryId(900_005)), None] + ); + assert_eq!( + rule.from.episodes, + Episodes { + first: 13, + last: None + } + ); + assert_eq!( + rule.to.episodes, + Episodes { + first: 1, + last: None + } + ); + assert!(rule.also_to_itself); + assert_eq!(relations.rules().len(), 1); + } + + #[test] + fn a_line_written_another_way_is_refused_where_it_stands() { + let cases = [ + ( + "900001|900002|900003:13 -> 900004|900005|900006:1", + Reason::NotRule("900001|900002|900003:13 -> 900004|900005|900006:1".to_owned()), + ), + ( + "- 900001|900002|900003:13 900004|900005|900006:1", + Reason::NoArrow, + ), + ( + "- 900001|900002:13 -> 900004|900005|900006:1", + Reason::Half("900001|900002:13".to_owned()), + ), + ( + "- 900001|nine|900003:13 -> 900004|900005|900006:1", + Reason::Work("nine".to_owned()), + ), + ( + "- ~|900002|900003:13 -> 900004|900005|900006:1", + Reason::Work("~".to_owned()), + ), + ( + "- 900001|900002|900003:x -> 900004|900005|900006:1", + Reason::Episodes("x".to_owned()), + ), + ( + "- 900001|900002|+900003:13 -> 900004|900005|900006:1", + Reason::Work("+900003".to_owned()), + ), + ( + "- 900001|900002|900003:+13 -> 900004|900005|900006:1", + Reason::Episodes("+13".to_owned()), + ), + ( + "- 900001|900002|900003:13-12 -> 900004|900005|900006:1", + Reason::Backwards("13-12".to_owned()), + ), + ( + "- 900001|900002|900003:13-? -> 900004|900005|900006:1-12", + Reason::OpenOnOneSide, + ), + ( + "- 900001|900002|900003:13-24 -> 900004|900005|900006:1-11", + Reason::Lengths, + ), + ( + "- 900001|900002|900003:13-24 -> 900004|900005|900006:1-?", + Reason::OpenOnOneSide, + ), + ( + "- 900001|900002|900003:13-? -> 900004|900005|900006:1", + Reason::OpenOnOneSide, + ), + ( + "- 900001|900002|900003|900007:13 -> 900004|900005|900006:1", + Reason::Half("900001|900002|900003|900007:13".to_owned()), + ), + ( + "- 900001|900002|900003:4294967296 -> 900004|900005|900006:1", + Reason::Episodes("4294967296".to_owned()), + ), + ( + "- 9223372036854775808|900002|900003:13 -> 900004|900005|900006:1", + Reason::Work("9223372036854775808".to_owned()), + ), + ]; + + for (line, reason) in cases { + assert_eq!(refusal(line), Error { line: 4, reason }, "{line}"); + } + } + + #[test] + fn the_largest_episode_a_rule_can_name_reads() { + let relations = rules("- 900001|900002|900003:4294967295 -> 900004|900005|900006:1"); + + assert_eq!( + on_anilist(&relations, 900_003, u32::MAX), + Some(to(900_006, 1)) + ); + } + + #[test] + fn a_rule_that_takes_an_episode_it_cannot_number_decides_and_numbers_it_not() { + let relations = rules( + "- 900001|900002|900003:0-? -> 900004|900005|900006:5-?\n\ + - 900001|900002|900003:100-? -> 900007|900008|900009:1-?", + ); + + assert_eq!( + on_anilist(&relations, 900_003, u32::MAX - 5), + Some(to(900_006, u32::MAX)) + ); + assert_eq!( + on_anilist(&relations, 900_003, u32::MAX), + Some(Redirect::Unnumbered) + ); + } + + #[test] + fn lines_of_the_meta_section_but_its_version_are_passed_over() { + let relations = Relations::read( + "::meta\n- version: 1.3.0\nhello\n- 900001|900002|900003:13 -> 900004|900005|900006:1\n::rules\n", + ) + .expect("the file reads"); + + assert!(relations.rules().is_empty()); + } + + #[test] + fn a_file_out_of_its_sections_or_with_no_version_is_refused() { + assert_eq!( + Relations::read(""), + Err(Error { + line: 0, + reason: Reason::NoVersion, + }) + ); + assert_eq!( + Relations::read("- 900001|900002|900003:13 -> ~|~|~:12\n"), + Err(Error { + line: 1, + reason: Reason::NoSection, + }) + ); + assert_eq!( + Relations::read("::meta\n- version: 1.3.0\n::other\n"), + Err(Error { + line: 3, + reason: Reason::Section("other".to_owned()), + }) + ); + assert_eq!( + Relations::read("::meta\n- last_modified: 2026-10-11\n::rules\n"), + Err(Error { + line: 3, + reason: Reason::NoVersion, + }) + ); + assert_eq!( + Relations::read("::meta\n- version: \n::rules\n"), + Err(Error { + line: 3, + reason: Reason::NoVersion, + }) + ); + } + + #[test] + fn a_refusal_names_its_line_and_what_is_wrong() { + assert_eq!( + refusal("- 900001|900002|900003:13-12 -> 900004|900005|900006:1").to_string(), + "line 4: range \"13-12\" ends before it begins" + ); + } +} diff --git a/crates/sync/src/resolve.rs b/crates/sync/src/resolve.rs new file mode 100644 index 0000000..3005612 --- /dev/null +++ b/crates/sync/src/resolve.rs @@ -0,0 +1,629 @@ +//! Which episode of which entry of a list an episode of a show is. +//! +//! An episode is kept in the release's numbering, which is all a filename +//! carries. A list numbers it as the service models the work: a group that +//! numbers a second season on from the first names episode 14 of the first, +//! and the service files it as episode 1 of the second. [`resolve`] turns the +//! one into the other for one backend, from what that backend's list says of +//! the show, the rules of `anime-relations` and the pins the user made. +//! +//! **In this order, and the first that answers decides:** +//! +//! 1. a pin of that episode of that show; +//! 2. the entry the list links the show to, where the episode is within its +//! count; +//! 3. a rule that takes that episode of that entry, wherever it sends it; +//! 4. the entry, where the episode is past its count and numbers the first +//! season by the hundred, as `101` names episode 1, and the count holds +//! the episode it names; +//! 5. the entry, where the list holds no count for it and the episode is +//! above zero. +//! +//! Otherwise the episode is unresolved, with the reason. A rule is asked only +//! where the count does not answer, because a rule describes a numbering that +//! falls outside an entry's count. Where the count is not known a rule is +//! asked first, and the entry takes an episode above +//! zero that no rule takes. A rule that sends an episode to an entry the list +//! does not hold resolves to it all the same: the work is the rule's to name, +//! and the write that follows adds it to the list. +//! +//! A name that spells no episode is the one episode of an entry of one. + +use benshi_core::ShowId; +use benshi_core::list::EntryId; + +use crate::relations::{Redirect, Relations, Service}; + +/// One episode of one entry of a list. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Target { + /// The entry, by the number the service gives the work. + pub entry: EntryId, + /// The episode, in the entry's numbering. + pub episode: u32, +} + +/// An episode of a show the user said is an episode of an entry. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Pin { + /// The show. + pub show: ShowId, + /// The episode, in the release's numbering, and none for a name that + /// spells none. + pub episode: Option, + /// Where it goes. + pub to: Target, +} + +/// The entry a list links a show to, and what the list holds of its count. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Listed { + /// The entry. + pub entry: EntryId, + /// How many episodes the work has, and none where the list holds no + /// count or no longer holds the entry. + pub episodes: Option, +} + +/// What decided where an episode goes. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum By { + /// A pin the user made. + Pin, + /// The entry the list links the show to. + Entry, + /// A rule of `anime-relations`. + Rule, +} + +/// Why an episode was not resolved. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Unresolved { + /// No pin names the episode, and the list links the show to no entry. + NotListed, + /// The name spells no episode, and the list does not hold a count of one + /// for the entry. + NoEpisode { + /// The entry's count, where the list holds one. + episodes: Option, + }, + /// The episode is outside the entry's count, and no rule takes it. + Outside { + /// The entry's count, where the list holds one. + episodes: Option, + }, + /// A rule takes the episode, and gives no number for where it goes. + Unnumbered, +} + +/// What an episode of a show resolved to, or why it did not. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Resolution { + /// This episode of this entry, and what said so. + Resolved { + /// Where the episode goes. + to: Target, + /// What decided. + by: By, + }, + /// No episode of any entry, and why. + Unresolved(Unresolved), +} + +/// Which episode of which entry an episode of a show is, on one backend. +/// +/// `episode` is in the release's numbering, `listed` what the backend's list +/// says of the show, `pins` the pins of that backend, and `rules` are read +/// in the column of `service`. +#[must_use] +pub fn resolve( + show: ShowId, + episode: Option, + listed: Option, + pins: &[Pin], + rules: &Relations, + service: Service, +) -> Resolution { + if let Some(pin) = pins + .iter() + .find(|pin| pin.show == show && pin.episode == episode) + { + return Resolution::Resolved { + to: pin.to, + by: By::Pin, + }; + } + let Some(Listed { entry, episodes }) = listed else { + return Resolution::Unresolved(Unresolved::NotListed); + }; + let the_entry = |episode| Resolution::Resolved { + to: Target { entry, episode }, + by: By::Entry, + }; + + let Some(episode) = episode else { + return match episodes { + Some(1) => the_entry(1), + _ => Resolution::Unresolved(Unresolved::NoEpisode { episodes }), + }; + }; + if let Some(count) = episodes + && (1..=count).contains(&episode) + { + return the_entry(episode); + } + match rules.redirect(service, entry, episode) { + Some(Redirect::To { work, episode }) => Resolution::Resolved { + to: Target { + entry: work, + episode, + }, + by: By::Rule, + }, + Some(Redirect::Unnumbered) => Resolution::Unresolved(Unresolved::Unnumbered), + None => match episodes.map(|count| of_the_first_season(episode, count)) { + Some(Some(numbered)) => the_entry(numbered), + None if episode > 0 => the_entry(episode), + _ => Resolution::Unresolved(Unresolved::Outside { episodes }), + }, + } +} + +/// The episode of the first season a release names by the hundred, `101` for +/// the first, where the entry holds it. Asked only of an episode outside the +/// count: an episode the count holds is that episode, however many there are. +fn of_the_first_season(episode: u32, count: u32) -> Option { + let numbered = episode.checked_sub(100)?; + (episode < 200 && (1..=count).contains(&numbered)).then_some(numbered) +} + +#[cfg(test)] +mod tests { + use super::{By, Listed, Pin, Resolution, Target, Unresolved, resolve}; + use crate::relations::{Relations, Service}; + use benshi_core::ShowId; + use benshi_core::list::EntryId; + + /// The show every test asks about. + const SHOW: ShowId = ShowId(1); + + /// The entry the list links [`SHOW`] to. + const ENTRY: EntryId = EntryId(900_001); + + /// A file of these rule lines. + fn rules(lines: &str) -> Relations { + let text = format!("::meta\n- version: 1.3.0\n::rules\n{lines}\n"); + Relations::read(&text).unwrap_or_else(|refused| panic!("{refused}: {text}")) + } + + /// No rules at all. + fn no_rules() -> Relations { + rules("") + } + + /// The entry, listed with this count. + fn of(episodes: Option) -> Listed { + Listed { + entry: ENTRY, + episodes, + } + } + + /// Where an episode of [`SHOW`] goes on AniList, with no pins. + fn on_anilist(episode: Option, listed: Option, rules: &Relations) -> Resolution { + resolve(SHOW, episode, listed, &[], rules, Service::AniList) + } + + /// This episode of this entry, as decided this way. + fn to(entry: i64, episode: u32, by: By) -> Resolution { + Resolution::Resolved { + to: Target { + entry: EntryId(entry), + episode, + }, + by, + } + } + + #[test] + fn an_episode_within_the_count_of_its_entry_is_that_episode() { + for episode in [1, 3, 12] { + assert_eq!( + on_anilist(Some(episode), Some(of(Some(12))), &no_rules()), + to(900_001, episode, By::Entry), + "{episode}" + ); + } + } + + #[test] + fn an_entry_with_no_count_has_its_episode_zero_taken_by_a_rule() { + let special = rules("- ?|?|900001:0 -> ?|?|900003:1"); + + assert_eq!( + on_anilist(Some(5), Some(of(None)), &special), + to(900_001, 5, By::Entry) + ); + assert_eq!( + on_anilist(Some(0), Some(of(None)), &special), + to(900_003, 1, By::Rule) + ); + } + + #[test] + fn an_episode_a_rule_takes_and_cannot_number_is_unnumbered() { + let past = rules("- ?|?|900001:13-? -> ?|?|900002:4294967295-?"); + + assert_eq!( + on_anilist(Some(14), Some(of(None)), &past), + Resolution::Unresolved(Unresolved::Unnumbered) + ); + } + + #[test] + fn an_episode_numbered_by_the_hundred_is_of_the_first_season() { + // A release that numbers its seasons by the hundred names the first + // episode of the first season 101. + let twelve = Some(of(Some(12))); + + assert_eq!( + on_anilist(Some(101), twelve, &no_rules()), + to(900_001, 1, By::Entry) + ); + assert_eq!( + on_anilist(Some(112), twelve, &no_rules()), + to(900_001, 12, By::Entry) + ); + for outside in [100, 113, 201] { + assert_eq!( + on_anilist(Some(outside), twelve, &no_rules()), + Resolution::Unresolved(Unresolved::Outside { episodes: Some(12) }), + "{outside}" + ); + } + } + + #[test] + fn an_episode_the_count_holds_is_that_episode_however_many_there_are() { + for count in [150, 500] { + assert_eq!( + on_anilist(Some(101), Some(of(Some(count))), &no_rules()), + to(900_001, 101, By::Entry), + "{count}" + ); + } + assert_eq!( + on_anilist(Some(101), Some(of(Some(100))), &no_rules()), + to(900_001, 1, By::Entry) + ); + assert_eq!( + on_anilist(Some(230), Some(of(Some(150))), &no_rules()), + Resolution::Unresolved(Unresolved::Outside { + episodes: Some(150) + }) + ); + assert_eq!( + on_anilist(Some(101), Some(of(None)), &no_rules()), + to(900_001, 101, By::Entry) + ); + } + + #[test] + fn a_rule_that_takes_an_episode_past_a_hundred_is_asked_first() { + let later = rules("- ?|?|900001:101-? -> ?|?|900002:1-?"); + + assert_eq!( + on_anilist(Some(101), Some(of(Some(12))), &later), + to(900_002, 1, By::Rule) + ); + } + + #[test] + fn a_count_of_zero_holds_no_episode() { + assert_eq!( + on_anilist(None, Some(of(Some(0))), &no_rules()), + Resolution::Unresolved(Unresolved::NoEpisode { episodes: Some(0) }) + ); + assert_eq!( + on_anilist(Some(1), Some(of(Some(0))), &no_rules()), + Resolution::Unresolved(Unresolved::Outside { episodes: Some(0) }) + ); + } + + #[test] + fn a_pin_answers_before_a_rule_and_the_first_pin_before_the_next() { + let past = rules("- ?|?|900001:2 -> ?|?|900007:1"); + let pins = [ + Pin { + show: SHOW, + episode: Some(2), + to: Target { + entry: EntryId(900_006), + episode: 1, + }, + }, + Pin { + show: SHOW, + episode: Some(2), + to: Target { + entry: EntryId(900_008), + episode: 1, + }, + }, + ]; + + assert_eq!( + on_anilist(Some(2), Some(of(Some(1))), &past), + to(900_007, 1, By::Rule) + ); + assert_eq!( + resolve( + SHOW, + Some(2), + Some(of(Some(1))), + &pins, + &past, + Service::AniList + ), + to(900_006, 1, By::Pin) + ); + } + + #[test] + fn an_episode_past_the_count_that_no_rule_takes_is_outside() { + assert_eq!( + on_anilist(Some(13), Some(of(Some(12))), &no_rules()), + Resolution::Unresolved(Unresolved::Outside { episodes: Some(12) }) + ); + } + + #[test] + fn an_episode_past_the_count_goes_where_a_rule_sends_it() { + // A group numbered the second season on from the first, and the + // service files the second as an entry of its own, which need not be + // on the list. + let continuous = rules("- ?|?|900001:13-24 -> ?|?|900002:1-12!"); + + assert_eq!( + on_anilist(Some(14), Some(of(Some(12))), &continuous), + to(900_002, 2, By::Rule) + ); + assert_eq!( + on_anilist(Some(24), Some(of(Some(12))), &continuous), + to(900_002, 12, By::Rule) + ); + } + + #[test] + fn a_rule_is_not_asked_of_an_episode_within_the_count() { + let within = rules("- ?|?|900001:1-12 -> ?|?|900002:1-12"); + + assert_eq!( + on_anilist(Some(3), Some(of(Some(12))), &within), + to(900_001, 3, By::Entry) + ); + } + + #[test] + fn two_episodes_of_a_release_go_to_the_one_special_a_rule_names() { + let special = rules("- ?|?|900001:11-12 -> ?|?|900003:1"); + + for episode in [11, 12] { + assert_eq!( + on_anilist(Some(episode), Some(of(Some(10))), &special), + to(900_003, 1, By::Rule), + "{episode}" + ); + } + } + + #[test] + fn an_episode_zero_goes_where_a_rule_sends_it_and_is_outside_without_one() { + let prologue = rules("- ?|?|900001:0 -> ?|?|900004:1"); + + assert_eq!( + on_anilist(Some(0), Some(of(Some(12))), &prologue), + to(900_004, 1, By::Rule) + ); + assert_eq!( + on_anilist(Some(0), Some(of(Some(12))), &no_rules()), + Resolution::Unresolved(Unresolved::Outside { episodes: Some(12) }) + ); + } + + #[test] + fn a_long_numbering_goes_where_an_open_rule_sends_it() { + let open = rules("- ?|?|900001:171-? -> ?|?|900005:1-?!"); + + assert_eq!( + on_anilist(Some(180), Some(of(Some(170))), &open), + to(900_005, 10, By::Rule) + ); + } + + #[test] + fn a_rule_to_a_work_the_service_does_not_number_is_unresolved() { + let unnumbered = rules("- 900011|?|900001:13 -> 900012|?|?:1"); + + assert_eq!( + on_anilist(Some(13), Some(of(Some(12))), &unnumbered), + Resolution::Unresolved(Unresolved::Unnumbered) + ); + } + + #[test] + fn the_rules_are_read_in_the_column_of_the_service() { + let both = rules("- 900011|?|900001:13 -> 900012|?|900002:1"); + let listed = |entry| { + Some(Listed { + entry: EntryId(entry), + episodes: Some(12), + }) + }; + + assert_eq!( + resolve( + SHOW, + Some(13), + listed(900_011), + &[], + &both, + Service::MyAnimeList + ), + to(900_012, 1, By::Rule) + ); + assert_eq!( + resolve( + SHOW, + Some(13), + listed(900_001), + &[], + &both, + Service::AniList + ), + to(900_002, 1, By::Rule) + ); + assert_eq!( + resolve( + SHOW, + Some(13), + listed(900_011), + &[], + &both, + Service::AniList + ), + Resolution::Unresolved(Unresolved::Outside { episodes: Some(12) }) + ); + } + + #[test] + fn an_entry_with_no_count_takes_an_episode_above_zero_that_no_rule_takes() { + // A series still airing has no count on the service. Each episode + // above zero is its own, but where a rule takes one it decides. + let continuous = rules("- ?|?|900001:13-24 -> ?|?|900002:1-12"); + + assert_eq!( + on_anilist(Some(30), Some(of(None)), &continuous), + to(900_001, 30, By::Entry) + ); + assert_eq!( + on_anilist(Some(14), Some(of(None)), &continuous), + to(900_002, 2, By::Rule) + ); + assert_eq!( + on_anilist(Some(0), Some(of(None)), &no_rules()), + Resolution::Unresolved(Unresolved::Outside { episodes: None }) + ); + } + + #[test] + fn a_name_with_no_episode_is_the_one_episode_of_an_entry_of_one() { + assert_eq!( + on_anilist(None, Some(of(Some(1))), &no_rules()), + to(900_001, 1, By::Entry) + ); + } + + #[test] + fn a_name_with_no_episode_of_an_entry_of_several_or_of_no_count_is_unresolved() { + for episodes in [Some(2), None] { + assert_eq!( + on_anilist(None, Some(of(episodes)), &no_rules()), + Resolution::Unresolved(Unresolved::NoEpisode { episodes }), + "{episodes:?}" + ); + } + } + + #[test] + fn a_show_the_list_links_to_no_entry_is_not_listed() { + assert_eq!( + on_anilist(Some(3), None, &no_rules()), + Resolution::Unresolved(Unresolved::NotListed) + ); + } + + #[test] + fn a_pin_comes_before_the_entry_and_the_rules() { + // The entry's count holds episode 2 and a rule takes it, and the pin + // still decides. + let pin = Pin { + show: SHOW, + episode: Some(2), + to: Target { + entry: EntryId(900_006), + episode: 1, + }, + }; + let rule = rules("- ?|?|900001:2 -> ?|?|900007:1"); + + assert_eq!( + resolve( + SHOW, + Some(2), + Some(of(Some(2))), + &[pin], + &rule, + Service::AniList + ), + to(900_006, 1, By::Pin) + ); + assert_eq!( + resolve( + SHOW, + Some(1), + Some(of(Some(2))), + &[pin], + &rule, + Service::AniList + ), + to(900_001, 1, By::Entry) + ); + } + + #[test] + fn a_pin_resolves_a_show_the_list_links_to_no_entry() { + let pin = Pin { + show: SHOW, + episode: None, + to: Target { + entry: EntryId(900_008), + episode: 1, + }, + }; + + assert_eq!( + resolve(SHOW, None, None, &[pin], &no_rules(), Service::AniList), + to(900_008, 1, By::Pin) + ); + } + + #[test] + fn a_pin_of_another_show_or_another_episode_is_passed_over() { + let elsewhere = |show, episode| Pin { + show, + episode, + to: Target { + entry: EntryId(900_009), + episode: 1, + }, + }; + let pins = [ + elsewhere(ShowId(2), Some(3)), + elsewhere(SHOW, Some(4)), + elsewhere(SHOW, None), + ]; + + assert_eq!( + resolve( + SHOW, + Some(3), + Some(of(Some(12))), + &pins, + &no_rules(), + Service::AniList + ), + to(900_001, 3, By::Entry) + ); + } +}