Mirror a watch into the account it was watched under

This commit is contained in:
Arkadiusz Fal
2026-09-06 11:06:15 +02:00
parent 3dc129dcfa
commit 7bcc711c40
9 changed files with 1044 additions and 4 deletions

View File

@@ -494,6 +494,59 @@ actor ContentService: ContentServiceProtocol {
}
}
// MARK: - Watch History
/// One page of the credential owner's server-side watch history.
///
/// Invidious is the only backend with a history endpoint at all; the
/// capability gate above this refuses the others long before here.
func watchHistory(
instance: Instance,
credential: String,
page: Int,
maxResults: Int
) async throws -> [String] {
switch instance.type {
case .invidious:
return try await invidiousAPI(for: instance).watchHistory(
instance: instance,
sid: credential,
page: page,
maxResults: maxResults
)
default:
throw APIError.notSupported
}
}
/// Records a video as watched in the credential owner's history.
func markWatched(videoID: String, instance: Instance, credential: String) async throws {
switch instance.type {
case .invidious:
try await invidiousAPI(for: instance).markWatched(
videoID: videoID,
instance: instance,
sid: credential
)
default:
throw APIError.notSupported
}
}
/// Removes one video from the credential owner's history.
func removeWatched(videoID: String, instance: Instance, credential: String) async throws {
switch instance.type {
case .invidious:
try await invidiousAPI(for: instance).removeWatched(
videoID: videoID,
instance: instance,
sid: credential
)
default:
throw APIError.notSupported
}
}
// MARK: - Authentication
/// Signs in to an instance and returns the credential its backend issues —
@@ -658,6 +711,8 @@ actor ContentService: ContentServiceProtocol {
/// that satisfy it; the protocol exists so tests can script a server.
extension ContentService: RemotePlaylistTransport {}
extension ContentService: WatchHistoryTransport {}
// MARK: - Feed Page
/// One page of an authenticated feed, with whatever the backend says about

View File

@@ -820,6 +820,72 @@ actor InvidiousAPI: InstanceAPI {
try await httpClient.sendRequest(endpoint, baseURL: instance.url)
}
// MARK: - Watch History
/// One page of the user's server-side watch history, newest first.
///
/// The server answers a bare array of video IDs — no titles, no durations,
/// nothing else — so a caller that wants to show them has to hydrate each
/// one itself. Past the end the array is empty, which is the only way to
/// tell there is no further page.
/// - Parameters:
/// - instance: The Invidious instance
/// - sid: The session ID from login
/// - page: 1-based page number
/// - maxResults: How many IDs the page may hold
/// - Returns: Video IDs, newest first
func watchHistory(
instance: Instance,
sid: String,
page: Int,
maxResults: Int
) async throws -> [String] {
let endpoint = GenericEndpoint(
path: "/api/v1/auth/history",
queryItems: [
URLQueryItem(name: "page", value: String(page)),
URLQueryItem(name: "max_results", value: String(maxResults))
],
headers: authHeaders(sid: sid)
)
let response: [String] = try await httpClient.fetch(endpoint, baseURL: instance.url)
return response
}
/// Records a video as watched in the user's server-side history.
///
/// Answers 204 with no body, and repeating it is not an error: the server
/// moves an ID it already holds back to the front, which is what a re-watch
/// should do to the ordering.
/// - Parameters:
/// - videoID: The YouTube video ID
/// - instance: The Invidious instance
/// - sid: The session ID from login
func markWatched(videoID: String, instance: Instance, sid: String) async throws {
let endpoint = GenericEndpoint(
path: "/api/v1/auth/history/\(videoID)",
method: .post,
headers: authHeaders(sid: sid)
)
try await httpClient.sendRequest(endpoint, baseURL: instance.url)
}
/// Removes one video from the user's server-side history.
///
/// Idempotent — an ID the history never held answers 204 just the same.
/// - Parameters:
/// - videoID: The YouTube video ID
/// - instance: The Invidious instance
/// - sid: The session ID from login
func removeWatched(videoID: String, instance: Instance, sid: String) async throws {
let endpoint = GenericEndpoint(
path: "/api/v1/auth/history/\(videoID)",
method: .delete,
headers: authHeaders(sid: sid)
)
try await httpClient.sendRequest(endpoint, baseURL: instance.url)
}
/// The headers every authenticated write carries. `Content-Type` is set for
/// all of them, including the bodiless ones, so a proxy in front of the
/// instance cannot guess something else.