The Pinned tab was not filtering. It sent filter=pinned, and Filter::parse in db.rs knows unread, downloaded, flagged and in_progress and falls through to All for anything else -- so the tab returned every item and looked like it had worked. The column is still named flagged, for what it was before the interface called it pinned, and the page had this right all along. Currently Listening was worse in kind. ipx has filter=in_progress for exactly it: started past the first few seconds, short of the 90% the UI calls finished, measured against the length this person's player reported where there is one. The client asked for everything and trimmed the fifty rows it happened to receive, so the view showed whichever started episodes were near the top of the library, left out the rest, and counted wrong. Both came of writing the filter names from the interface's words instead of reading what the server parses. The tests now assert what each filter means rather than how many rows it returns -- every unread row unread, every downloaded row with a file, every pinned row pinned, every in-progress row started -- because the failure here was a full page of entirely plausible rows, which no count would have caught. Pinned also has to match fewer than everything, which is the shape the bug took. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
176 lines
6.5 KiB
Swift
176 lines
6.5 KiB
Swift
import Foundation
|
|
|
|
/// What ipx sends, as Swift types. The names are the server's, so a field here can be checked
|
|
/// against src/web.rs without translating first.
|
|
enum IPX {}
|
|
|
|
extension IPX {
|
|
/// One subscribed feed, as GET /api/feeds sends it.
|
|
struct Feed: Decodable, Identifiable, Hashable {
|
|
let id: String
|
|
let url: String
|
|
let title: String?
|
|
let image: String?
|
|
/// The OPML subscription it came from, if any. Feeds in one are drawn as a folder.
|
|
let group: String?
|
|
/// In a group, but the OPML no longer lists it. Kept because it has downloads.
|
|
let orphaned: Bool
|
|
let folder: String?
|
|
let category: String?
|
|
let feedCategory: String?
|
|
let lastChecked: Int?
|
|
let nextCheck: Int?
|
|
let lastError: String?
|
|
let entries: Int
|
|
let downloaded: Int
|
|
let unread: Int
|
|
/// Including you. More than one means every file here is shared.
|
|
let subscribers: Int
|
|
let pinned: Bool
|
|
|
|
var name: String { title?.isEmpty == false ? title! : id }
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case id, url, title, image, group, orphaned, folder, category, entries, downloaded,
|
|
unread, subscribers, pinned
|
|
case feedCategory = "feed_category"
|
|
case lastChecked = "last_checked"
|
|
case nextCheck = "next_check"
|
|
case lastError = "last_error"
|
|
}
|
|
}
|
|
|
|
/// A file on an entry. `path` is the only thing that says it is downloaded.
|
|
struct Enclosure: Decodable, Identifiable, Hashable {
|
|
let id: Int
|
|
let feedId: String
|
|
let guid: String
|
|
let url: String
|
|
let mime: String?
|
|
let length: Int?
|
|
let path: String?
|
|
let state: String
|
|
let lastError: String?
|
|
|
|
var isDownloaded: Bool { path?.isEmpty == false }
|
|
|
|
/// Whether it is worth handing to a player. Having a file is not the same as being
|
|
/// playable: blog feeds put article images in enclosures.
|
|
var isPlayable: Bool {
|
|
guard isDownloaded else { return false }
|
|
let m = (mime ?? "").lowercased()
|
|
if m.hasPrefix("audio/") || m.hasPrefix("video/") { return true }
|
|
if !m.isEmpty { return false }
|
|
let name = (path ?? url).split(separator: "?").first.map(String.init) ?? ""
|
|
let ext = (name as NSString).pathExtension.lowercased()
|
|
return ["mp3","m4a","m4b","aac","ogg","oga","opus","flac","wav",
|
|
"mp4","m4v","mov","webm","mkv"].contains(ext)
|
|
}
|
|
|
|
var isVideo: Bool { (mime ?? "").lowercased().hasPrefix("video/") }
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case id, guid, url, mime, length, path, state
|
|
case feedId = "feed_id"
|
|
case lastError = "last_error"
|
|
}
|
|
}
|
|
|
|
/// One item. Read, pinned and position are this person's; the entry itself is shared.
|
|
struct Entry: Decodable, Identifiable, Hashable {
|
|
let guid: String
|
|
let feedId: String
|
|
let title: String?
|
|
let link: String?
|
|
let published: Int?
|
|
/// Feed-supplied HTML, already sanitized with ammonia server-side.
|
|
let description: String?
|
|
let read: Bool
|
|
let flagged: Bool
|
|
let image: String?
|
|
let duration: Int?
|
|
let episode: Int?
|
|
let season: Int?
|
|
let position: Int
|
|
let enclosures: [Enclosure]
|
|
|
|
/// Unique across feeds; guid alone is not.
|
|
var id: String { feedId + "\n" + guid }
|
|
|
|
/// Memberwise, so a row can be rebuilt with one flag moved. Declaring it keeps the
|
|
/// synthesised one, which a decoded type does not otherwise expose.
|
|
init(guid: String, feedId: String, title: String?, link: String?, published: Int?,
|
|
description: String?, read: Bool, flagged: Bool, image: String?, duration: Int?,
|
|
episode: Int?, season: Int?, position: Int, enclosures: [Enclosure]) {
|
|
self.guid = guid; self.feedId = feedId; self.title = title; self.link = link
|
|
self.published = published; self.description = description; self.read = read
|
|
self.flagged = flagged; self.image = image; self.duration = duration
|
|
self.episode = episode; self.season = season; self.position = position
|
|
self.enclosures = enclosures
|
|
}
|
|
var playable: Enclosure? { enclosures.first(where: \.isPlayable) }
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case guid, title, link, published, description, read, flagged, image, duration,
|
|
episode, season, position, enclosures
|
|
case feedId = "feed_id"
|
|
}
|
|
}
|
|
|
|
/// A page of items. `total` is the whole result, not the page.
|
|
struct EntryPage: Decodable {
|
|
let total: Int
|
|
let entries: [Entry]
|
|
}
|
|
|
|
struct Me: Decodable {
|
|
let name: String
|
|
let admin: Bool
|
|
/// Where to send someone the proxy signed in; signing out of ipx alone cannot stick.
|
|
let signOut: String?
|
|
let theme: String?
|
|
let mode: String?
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case name, admin, theme, mode
|
|
case signOut = "sign_out"
|
|
}
|
|
}
|
|
|
|
/// Which items to ask for. The server does the work; these go on the query, and the raw
|
|
/// values are what `Filter::parse` in db.rs understands -- anything it does not know falls
|
|
/// through to All, so a wrong name here is a filter that quietly does nothing.
|
|
enum Filter: String, CaseIterable {
|
|
case all
|
|
case unread
|
|
case downloaded
|
|
/// `flagged` on the wire: the column is named for what it was before the interface
|
|
/// called it pinned.
|
|
case pinned = "flagged"
|
|
/// Started past the first few seconds and short of the 90% the UI calls finished. Not
|
|
/// the same as unread: opening an item marks it read.
|
|
case inProgress = "in_progress"
|
|
|
|
/// The tabs, which are not every filter: Currently Listening is a place of its own.
|
|
static var tabs: [Filter] { [.all, .unread, .downloaded, .pinned] }
|
|
|
|
var label: String {
|
|
switch self {
|
|
case .all: return "All"
|
|
case .unread: return "Unread"
|
|
case .downloaded: return "Downloaded"
|
|
case .pinned: return "Pinned"
|
|
case .inProgress: return "Listening"
|
|
}
|
|
}
|
|
}
|
|
|
|
enum Sort: String {
|
|
case published, title, feed, kept, type, size
|
|
}
|
|
|
|
enum Direction: String {
|
|
case asc, desc
|
|
}
|
|
}
|